cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Route formatting through change and place for undo

Dami committed fee01d3
Every cell mutation now builds a new cell and stores it with place
inside a labelled change, as the undo history on main requires:
Set, EraseRange, SetFormat, SetStyle, ClearFormatting and
AdjustDecimals ("format B3:B5 as currency"). history.go is a
stand-in with main's change, record, clone and Batch minus undo;
take main's version when merging. Also renames power and finder,
which collide with helpers on main.
commit
fee01d3c5d5fe12019ff2a6a1bdfea9308eba459
parent
9ebb04866dc7bc515b18fececd10ee030177b70b
author
Dami <dami.etoile@gmail.com>
9 files changed, +254 -85
Minternal/sheet/cellformat.go59++++++++------
Ainternal/sheet/cellformat_test.go48+++++++++++
Minternal/sheet/file.go13+--
Minternal/sheet/format.go15++++
Minternal/sheet/functions.go3+-
Minternal/sheet/functions_math.go4+-
Minternal/sheet/functions_text.go8+-
Ainternal/sheet/history.go63+++++++++++++++
Minternal/sheet/sheet.go126+++++++++++++++++++-----------
M internal/sheet/cellformat.go +35 -24
@@ -25,38 +25,49 @@ func (s *Sheet) DisplayFormat(a Addr) Format {
2525 // SetFormat gives every cell in r the number format f, including blank2626 // cells, which keep it for when something is typed.2727 func (s *Sheet) SetFormat(r Rect, f Format) {28- s.eachCell(r, !f.IsZero(), func(_ Addr, c *Cell) { c.Format = f })28+ s.change("format "+r.String()+" as "+f.Kind.label(), r, func() {29+ s.eachCell(r, !f.IsZero(), func(_ Addr, c *Cell) { c.Format = f })30+ })2931 }3032 3133 // SetStyle changes the text style of every cell in r with fn, e.g. to32-// turn on bold while keeping italics.34+// turn on bold while keeping italics. Callers wanting a specific undo35+// label ("bold B2:B5") wrap it in Batch.3336 func (s *Sheet) SetStyle(r Rect, fn func(*Style)) {34- s.eachCell(r, true, func(_ Addr, c *Cell) { fn(&c.Style) })37+ s.change("style "+r.String(), r, func() {38+ s.eachCell(r, true, func(_ Addr, c *Cell) { fn(&c.Style) })39+ })3540 }3641 3742 // ClearFormatting resets the format and style of every cell in r, as3843 // Sheets' Format > Clear formatting.3944 func (s *Sheet) ClearFormatting(r Rect) {40- s.eachCell(r, false, func(_ Addr, c *Cell) {41- c.Format, c.Style = Format{}, Style{}45+ s.change("clear formatting "+r.String(), r, func() {46+ s.eachCell(r, false, func(_ Addr, c *Cell) { c.Format, c.Style = Format{}, Style{} })4247 })4348 }4449 4550 // AdjustDecimals shows delta more (or fewer) decimal places in every4651 // non-blank number cell of r, starting from what each cell shows now.4752 func (s *Sheet) AdjustDecimals(r Rect, delta int) {48- s.eachCell(r, false, func(a Addr, c *Cell) {49- if c.Blank() {50- return51- }52- c.Format = s.DisplayFormat(a).WithDecimals(delta, c.Value.Num)53+ label := "more decimals in "54+ if delta < 0 {55+ label = "fewer decimals in "56+ }57+ s.change(label+r.String(), r, func() {58+ s.eachCell(r, false, func(a Addr, c *Cell) {59+ if !c.Blank() {60+ c.Format = s.DisplayFormat(a).WithDecimals(delta, c.Value.Num)61+ }62+ })5363 })5464 }5565 56-// eachCell applies fn to the cells in r, then recalculates them so57-// formulas that infer their format from them follow. With create, blank58-// cells get a cell to hold the formatting (up to materializeLimit);59-// cells left with neither contents nor formatting are dropped.66+// eachCell applies fn to a copy of each cell in r and places the copy,67+// so the change can be undone. With create, blank cells get a cell to68+// hold the formatting (up to materializeLimit cells); cells left with69+// neither contents nor formatting are removed. Cells that fn leaves70+// unchanged are not touched.6071 func (s *Sheet) eachCell(r Rect, create bool, fn func(Addr, *Cell)) {6172 area := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)6273 var addrs []Addr
@@ -67,24 +78,24 @@ func (s *Sheet) eachCell(r Rect, create bool, fn func(Addr, *Cell)) {
6778 }6879 }6980 } else {70- for a := range s.cells {71- if r.Contains(a) {72- addrs = append(addrs, a)73- }74- }81+ addrs = s.cellsIn(r)7582 }7683 for _, a := range addrs {77- c := s.cells[a]84+ old := s.cells[a]85+ c := old.clone()7886 if c == nil {7987 c = &Cell{}80- s.cells[a] = c8188 }8289 fn(a, c)83- if c.Blank() && c.Format.IsZero() && c.Style.IsZero() {84- delete(s.cells, a)90+ switch {91+ case c.Blank() && c.Format.IsZero() && c.Style.IsZero():92+ if old != nil {93+ s.place(a, nil)94+ }95+ case old == nil || c.Format != old.Format || c.Style != old.Style:96+ s.place(a, c)8597 }8698 }87- s.recalc(addrs)8899 }89100 90101 // isVolatile reports whether n calls a function whose result changes
A internal/sheet/cellformat_test.go +48 -0
@@ -0,0 +1,48 @@
1+package sheet2+3+import "testing"4+5+// Undo snapshots cells by pointer, so formatting must store new cells6+// rather than change stored ones.7+func TestFormattingReplacesCells(t *testing.T) {8+ s := New()9+ s.Set(at("A1"), "5")10+ r := NewRect(at("A1"), at("A1"))11+ for _, change := range []func(){12+ func() { s.SetFormat(r, Preset(FmtCurrency)) },13+ func() { s.SetStyle(r, func(st *Style) { st.Bold = true }) },14+ func() { s.AdjustDecimals(r, 1) },15+ func() { s.ClearFormatting(r) },16+ func() { s.EraseRange(r) },17+ } {18+ before := s.Cell(at("A1"))19+ snapshot := *before20+ change()21+ if before.Format != snapshot.Format || before.Style != snapshot.Style || before.Input != snapshot.Input {22+ t.Fatalf("a stored cell changed in place: %+v, was %+v", before, snapshot)23+ }24+ if s.Cell(at("A1")) == nil {25+ s.SetFormat(r, Preset(FmtNumber)) // keep a cell for the next step26+ }27+ }28+}29+30+func TestBatchJoinsFormattingSteps(t *testing.T) {31+ s := New()32+ r := NewRect(at("A1"), at("B2"))33+ s.Set(at("C1"), "=A1")34+ s.Set(at("A1"), "5")35+ s.Batch(Change{Label: "bold A1:B2", Focus: r}, func() error {36+ s.SetStyle(r, func(st *Style) { st.Bold = true })37+ s.SetFormat(r, Preset(FmtCurrency))38+ return nil39+ })40+ if c := s.Cell(at("B2")); c == nil || !c.Style.Bold || c.Format.Kind != FmtCurrency {41+ t.Errorf("B2 = %+v", c)42+ }43+ // Formulas following a reformatted cell are recalculated once the44+ // batch ends.45+ if f := s.DisplayFormat(at("C1")); f.Kind != FmtCurrency {46+ t.Errorf("C1 format %+v", f)47+ }48+}
M internal/sheet/file.go +5 -8
M internal/sheet/format.go +15 -0
M internal/sheet/functions.go +2 -1
M internal/sheet/functions_math.go +2 -2
M internal/sheet/functions_text.go +4 -4
A internal/sheet/history.go +63 -0
@@ -0,0 +1,63 @@
1+package sheet2+3+// This file is a stand-in for the undo history on main4+// (internal/sheet/history.go there), with the same change, record, clone,5+// Batch and ClearHistory but no undo. Every mutation in this branch goes6+// through change and place as it does on main, so formatting changes7+// become undo steps once merged. When merging, take main's version of8+// this file.9+10+type history struct {11+ depth int // nesting of change calls12+ dirty []Addr // cells changed in the open step13+}14+15+// Change describes a step: what it did, in lower case for use in a16+// sentence ("format B3:B5 as currency"), and the range it affected.17+type Change struct {18+ Label string19+ Focus Rect20+}21+22+// Batch runs fn as a single step, recalculating once at the end. Batches23+// nest; inner ones join the outermost.24+func (s *Sheet) Batch(c Change, fn func() error) error {25+ var err error26+ s.change(c.Label, c.Focus, func() { err = fn() })27+ return err28+}29+30+// change runs fn and, on the way out of the outermost call, recalculates31+// what changed.32+func (s *Sheet) change(_ string, _ Rect, fn func()) {33+ h := &s.hist34+ h.depth++35+ defer func() {36+ h.depth--37+ if h.depth > 0 {38+ return39+ }40+ dirty := h.dirty41+ h.dirty = nil42+ s.recalc(dirty)43+ }()44+ fn()45+}46+47+// record notes that the cell at a is about to change.48+func (s *Sheet) record(a Addr) {49+ if s.hist.depth > 0 {50+ s.hist.dirty = append(s.hist.dirty, a)51+ }52+}53+54+func (c *Cell) clone() *Cell {55+ if c == nil {56+ return nil57+ }58+ cp := *c59+ return &cp60+}61+62+// ClearHistory forgets all steps.63+func (s *Sheet) ClearHistory() { s.hist = history{} }
M internal/sheet/sheet.go +80 -46
@@ -58,6 +58,8 @@ type Sheet struct {
5858 5959 // Circular is set when the last recalculation found a cycle.6060 Circular bool61+62+ hist history // undo and redo, see history.go6163 }6264 6365 // New returns an empty worksheet.
@@ -182,58 +184,88 @@ func classify(input string) (Node, Format, error) {
182184 // fails to parse is rejected with a *ParseError and the sheet is left183185 // unchanged.184186 func (s *Sheet) Set(a Addr, input string) error {185- if err := s.put(a, input); err != nil {187+ var err error188+ s.change("edit "+a.String(), Rect{a, a}, func() { err = s.put(a, input) })189+ return err190+}191+192+// put stores an entry without recalculating, keeping the cell's193+// formatting.194+func (s *Sheet) put(a Addr, input string) error {195+ var f Format196+ var st Style197+ if old := s.cells[a]; old != nil {198+ f, st = old.Format, old.Style199+ }200+ c, err := newCell(input, f, st, true)201+ if err != nil {186202 return err187203 }188- s.recalc([]Addr{a})204+ s.place(a, c)189205 return nil190206 }191207 192-// put stores an entry without recalculating, keeping the cell's193-// formatting. An entry that implies a format, such as "$5" or a date,194-// sets it, as in Sheets. In a Plain text cell every entry is text.195-// Control characters are dropped so a worksheet file can't smuggle escape196-// sequences to the terminal.197-func (s *Sheet) put(a Addr, input string) error {208+// newCell builds a cell for an entry with formatting, or returns nil when209+// there is neither. With implied, an entry that implies a format, such as210+// "$5" or a date, sets it, as in Sheets. In a Plain text cell every entry211+// is text. Control characters are dropped so a worksheet file can't212+// smuggle escape sequences to the terminal.213+func newCell(input string, f Format, st Style, implied bool) (*Cell, error) {198214 input = strings.Map(func(r rune) rune {199215 if unicode.IsControl(r) {200216 return -1201217 }202218 return r203219 }, input)204- var f Format205- var st Style206- if old := s.cells[a]; old != nil {207- f, st = old.Format, old.Style208- }209220 if input == "" {210- s.erase(a)211- s.restoreFormatting(a, f, st)212- return nil221+ return formattingOnly(f, st), nil213222 }214223 var n Node215224 if f.Kind != FmtText {216- var implied Format225+ var fi Format217226 var err error218- if n, implied, err = classify(input); err != nil {219- return err227+ if n, fi, err = classify(input); err != nil {228+ return nil, err220229 }221- if !implied.IsZero() {222- f = implied230+ if implied && !fi.IsZero() {231+ f = fi223232 }224233 }225- c := &Cell{Input: input, expr: n, Format: f, Style: st}234+ c := &Cell{Input: input, Format: f, Style: st}235+ c.setExpr(n)236+ return c, nil237+}238+239+// formattingOnly is a blank cell holding formatting, or nil when there is240+// none.241+func formattingOnly(f Format, st Style) *Cell {242+ if f.IsZero() && st.IsZero() {243+ return nil244+ }245+ return &Cell{Format: f, Style: st}246+}247+248+// setExpr sets c's expression and the references indexed from it.249+func (c *Cell) setExpr(n Node) {250+ c.expr, c.refs, c.ranges, c.volatile = n, nil, nil, false226251 if n != nil {227252 walkRefs(n,228253 func(r Addr) { c.refs = append(c.refs, r) },229254 func(r Rect) { c.ranges = append(c.ranges, r) })230255 c.volatile = isVolatile(n)231256 }232- s.erase(a)233- s.cells[a] = c234- if c.volatile {235- s.volatile[a] = struct{}{}257+}258+259+// place stores c at a (nil blanks it), recording the old cell for undo and260+// keeping the dependency indexes current. Every cell mutation goes through261+// here.262+func (s *Sheet) place(a Addr, c *Cell) {263+ s.record(a)264+ s.unlink(a)265+ if c == nil {266+ return236267 }268+ s.cells[a] = c237269 for _, r := range c.refs {238270 if s.dependents[r] == nil {239271 s.dependents[r] = make(map[Addr]struct{})
@@ -243,36 +275,36 @@ func (s *Sheet) put(a Addr, input string) error {
243275 if len(c.ranges) > 0 {244276 s.rangeUsers[a] = struct{}{}245277 }246- return nil278+ if c.volatile {279+ s.volatile[a] = struct{}{}280+ }247281 }248282 249283 // EraseRange clears the contents of every cell in r, keeping their250284 // formatting as Sheets' Delete does.251285 func (s *Sheet) EraseRange(r Rect) {252- var changed []Addr253- for a, c := range s.cells {254- if r.Contains(a) && !c.Blank() {255- changed = append(changed, a)286+ s.change("clear "+r.String(), r, func() {287+ for _, a := range s.cellsIn(r) {288+ if c := s.cells[a]; !c.Blank() {289+ s.place(a, formattingOnly(c.Format, c.Style))290+ }256291 }257- }258- for _, a := range changed {259- c := s.cells[a]260- s.erase(a)261- s.restoreFormatting(a, c.Format, c.Style)262- }263- s.recalc(changed)292+ })264293 }265294 266-// restoreFormatting leaves a blank cell holding just formatting, or no267-// cell at all when there is none.268-func (s *Sheet) restoreFormatting(a Addr, f Format, st Style) {269- if f.IsZero() && st.IsZero() {270- return295+// cellsIn returns the cells in r that have contents or formatting.296+func (s *Sheet) cellsIn(r Rect) []Addr {297+ var out []Addr298+ for a := range s.cells {299+ if r.Contains(a) {300+ out = append(out, a)301+ }271302 }272- s.cells[a] = &Cell{Format: f, Style: st}303+ return out273304 }274305 275-func (s *Sheet) erase(a Addr) {306+// unlink removes the cell at a and its dependency edges.307+func (s *Sheet) unlink(a Addr) {276308 old := s.cells[a]277309 if old == nil {278310 return
@@ -288,9 +320,11 @@ func (s *Sheet) erase(a Addr) {
288320 delete(s.cells, a)289321 }290322 291-// RecalcAll recomputes every formula, e.g. after loading a file.323+// RecalcAll recomputes every formula. Loaders call it once the sheet is324+// built, so it also starts a fresh undo history: loading isn't undoable.292325 func (s *Sheet) RecalcAll() {293326 s.recalc(s.Addrs())327+ s.ClearHistory()294328 }295329 296330 // recalc recomputes the changed cells, volatile formulas, and everything