cat / 012
clone

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

Add an undo history to the engine

Dami committed 0d885ce
Every cell and column width change now goes through place or setWidth,
which record a before-image into the open step. Steps snapshot whole
cells, so fields added to Cell later are covered. Batch groups a
multi-cell operation into one step and recalculates once at the end.
Undo and redo swap before-images, StateID tells the UI whether it's back
at the saved state, and history keeps the last 100 steps.

Consecutive column width changes merge until Seal, so a live width
preview and its result or cancellation are one step (or none). Loading
a file starts a fresh history.
commit
0d885ce92063f9f2cc04e16a73640ae13ef27ddc
parent
f9df936e417d22c7e3f5cd90d50c25927521987f
author
Dami <dami.etoile@gmail.com>
3 files changed, +421 -17
Ainternal/sheet/history.go226++++++++++++++++++++++++++++++
Ainternal/sheet/history_test.go147++++++++++++++++++++
Minternal/sheet/sheet.go65++++++--
A internal/sheet/history.go +226 -0
@@ -0,0 +1,226 @@
1+package sheet2+3+// MaxUndo is how many steps of undo history a sheet keeps.4+const MaxUndo = 1005+6+// History is a command log of before-images. Each step records, for every7+// cell and column width it touched, the state before the step; undoing8+// swaps those back in and records the state it replaced as the redo step.9+//10+// Steps snapshot whole cells (a shallow copy of the struct), so fields11+// added to Cell later are covered without changes here. That relies on12+// one rule: a stored *Cell is never modified in place except for its13+// computed Value. To change a cell, build a new one and store it with14+// place.15+type history struct {16+ undo, redo []*step17+ open *step // the step being built; nil outside change18+ depth int // nesting of change calls19+ dirty []Addr20+ lastID int21+ // mergeWidths lets the next width-only step join the top one, so a22+ // live preview of a column width and its final value (or its23+ // cancellation) are one step. Seal ends the run.24+ mergeWidths bool25+}26+27+type step struct {28+ id int29+ focus Rect // what the UI selects when the step is undone or redone30+ cells map[Addr]*Cell // before the step; nil for blank31+ widths map[int]int // before the step; 0 for the default width32+}33+34+func (st *step) empty() bool { return len(st.cells) == 0 && len(st.widths) == 0 }35+36+func (c *Cell) clone() *Cell {37+ if c == nil {38+ return nil39+ }40+ cp := *c41+ return &cp42+}43+44+// Batch runs fn as a single undo step: everything it changes is undone45+// together, and formulas are recalculated once at the end. focus is the46+// range to select when the step is undone or redone. Batches nest; inner47+// ones join the outermost.48+func (s *Sheet) Batch(focus Rect, fn func() error) error {49+ var err error50+ s.change(focus, func() { err = fn() })51+ return err52+}53+54+// change opens a step (unless one is open), runs fn, and on the way out55+// of the outermost call recalculates what changed and pushes the step.56+func (s *Sheet) change(focus Rect, fn func()) {57+ h := &s.hist58+ if h.depth == 0 {59+ h.open = &step{focus: focus, cells: map[Addr]*Cell{}, widths: map[int]int{}}60+ }61+ h.depth++62+ defer func() {63+ h.depth--64+ if h.depth > 0 {65+ return66+ }67+ st, dirty := h.open, h.dirty68+ h.open, h.dirty = nil, nil69+ s.recalc(dirty)70+ s.push(st)71+ }()72+ fn()73+}74+75+// record saves the cell at a before its first change in the open step.76+func (s *Sheet) record(a Addr) {77+ st := s.hist.open78+ if st == nil {79+ return80+ }81+ if _, seen := st.cells[a]; !seen {82+ st.cells[a] = s.cells[a].clone()83+ }84+ s.hist.dirty = append(s.hist.dirty, a)85+}86+87+// recordWidth saves column c's width before its first change in the open88+// step.89+func (s *Sheet) recordWidth(c int) {90+ st := s.hist.open91+ if st == nil {92+ return93+ }94+ if _, seen := st.widths[c]; !seen {95+ st.widths[c] = s.widths[c]96+ }97+}98+99+// push adds a finished step to the undo stack and clears redo, dropping100+// no-op changes.101+func (s *Sheet) push(st *step) {102+ h := &s.hist103+ for c, w := range st.widths {104+ if s.widths[c] == w {105+ delete(st.widths, c)106+ }107+ }108+ for a, c := range st.cells {109+ if c == nil && s.cells[a] == nil {110+ delete(st.cells, a)111+ }112+ }113+ if st.empty() {114+ return115+ }116+ h.redo = nil117+ h.lastID++118+ widthOnly := len(st.cells) == 0119+ if top := h.top(); widthOnly && h.mergeWidths && top != nil && len(top.cells) == 0 {120+ for c, w := range st.widths {121+ if _, ok := top.widths[c]; !ok {122+ top.widths[c] = w123+ }124+ }125+ for c, w := range top.widths {126+ if s.widths[c] == w {127+ delete(top.widths, c)128+ }129+ }130+ top.id, top.focus = h.lastID, union(top.focus, st.focus)131+ if top.empty() {132+ h.undo = h.undo[:len(h.undo)-1]133+ }134+ return135+ }136+ st.id = h.lastID137+ h.undo = append(h.undo, st)138+ if len(h.undo) > MaxUndo {139+ h.undo = h.undo[len(h.undo)-MaxUndo:]140+ }141+ h.mergeWidths = widthOnly142+}143+144+func (h *history) top() *step {145+ if len(h.undo) == 0 {146+ return nil147+ }148+ return h.undo[len(h.undo)-1]149+}150+151+// Seal ends a run of column width changes, so the next one starts a new152+// undo step. The UI calls it between user actions.153+func (s *Sheet) Seal() { s.hist.mergeWidths = false }154+155+// Undo reverts the last step and returns the range it affected.156+func (s *Sheet) Undo() (Rect, bool) {157+ return s.swap(&s.hist.undo, &s.hist.redo)158+}159+160+// Redo reapplies the last undone step and returns the range it affected.161+func (s *Sheet) Redo() (Rect, bool) {162+ return s.swap(&s.hist.redo, &s.hist.undo)163+}164+165+// CanUndo and CanRedo report whether there is a step to undo or redo.166+func (s *Sheet) CanUndo() bool { return len(s.hist.undo) > 0 }167+func (s *Sheet) CanRedo() bool { return len(s.hist.redo) > 0 }168+169+// StateID identifies the sheet's contents in its undo history: undoing170+// back to a saved state returns the ID it had when saved, so the UI can171+// tell whether there are unsaved changes.172+func (s *Sheet) StateID() int {173+ if top := s.hist.top(); top != nil {174+ return top.id175+ }176+ return 0177+}178+179+// swap pops a step from one stack, restores its before-image, and pushes180+// the state it replaced onto the other stack.181+func (s *Sheet) swap(from, to *[]*step) (Rect, bool) {182+ if len(*from) == 0 || s.hist.open != nil {183+ return Rect{}, false184+ }185+ st := (*from)[len(*from)-1]186+ *from = (*from)[:len(*from)-1]187+ // The inverse keeps the step's ID: on the redo stack, an ID names the188+ // state the step leads back to.189+ inv := &step{id: st.id, focus: st.focus, cells: make(map[Addr]*Cell, len(st.cells)), widths: map[int]int{}}190+ changed := make([]Addr, 0, len(st.cells))191+ for a, c := range st.cells {192+ inv.cells[a] = s.cells[a].clone()193+ s.place(a, c.clone())194+ changed = append(changed, a)195+ }196+ for c, w := range st.widths {197+ inv.widths[c] = s.widths[c]198+ s.setWidth(c, w)199+ }200+ s.recalc(changed)201+ *to = append(*to, inv)202+ s.hist.mergeWidths = false203+ return st.focus, true204+}205+206+// ClearHistory forgets all undo and redo steps.207+func (s *Sheet) ClearHistory() {208+ s.hist = history{}209+}210+211+// colRect is the range covering whole columns from..to.212+func colRect(from, to int) Rect {213+ return Rect{Addr{Col: from}, Addr{Col: to, Row: MaxRows - 1}}214+}215+216+// rowRect is the range covering whole rows from..to.217+func rowRect(from, to int) Rect {218+ return Rect{Addr{Row: from}, Addr{Col: MaxCols - 1, Row: to}}219+}220+221+func union(a, b Rect) Rect {222+ return Rect{223+ From: Addr{Col: min(a.From.Col, b.From.Col), Row: min(a.From.Row, b.From.Row)},224+ To: Addr{Col: max(a.To.Col, b.To.Col), Row: max(a.To.Row, b.To.Row)},225+ }226+}
A internal/sheet/history_test.go +147 -0
@@ -0,0 +1,147 @@
1+package sheet2+3+import (4+ "reflect"5+ "strings"6+ "testing"7+)8+9+// sheetOf builds a sheet from cell inputs, without undo history.10+func sheetOf(t *testing.T, cells map[string]string) *Sheet {11+ t.Helper()12+ s := New()13+ for a, in := range cells {14+ if err := s.put(at(a), in); err != nil {15+ t.Fatalf("%s: %v", a, err)16+ }17+ }18+ s.RecalcAll()19+ return s20+}21+22+// inputs returns every cell's input by address.23+func inputs(s *Sheet) map[string]string {24+ out := map[string]string{}25+ for a, c := range s.cells {26+ out[a.String()] = c.Input27+ }28+ return out29+}30+31+func TestUndoRedo(t *testing.T) {32+ s := New()33+ s.Set(at("A1"), "1")34+ s.Set(at("A2"), "=A1*10")35+ s.Set(at("A1"), "2")36+ s.EraseRange(NewRect(at("A1"), at("A2")))37+ if s.Len() != 0 || s.StateID() != 4 {38+ t.Fatalf("len %d state %d", s.Len(), s.StateID())39+ }40+ r, ok := s.Undo()41+ if !ok || r.String() != "A1:A2" || s.Value(at("A2")).Num != 20 {42+ t.Fatalf("undo erase: %v %v A2=%+v", r, ok, s.Value(at("A2")))43+ }44+ s.Undo()45+ if s.Value(at("A2")).Num != 10 || s.StateID() != 2 {46+ t.Errorf("undo set: A2=%+v state %d", s.Value(at("A2")), s.StateID())47+ }48+ s.Redo()49+ if s.Value(at("A2")).Num != 20 || s.StateID() != 3 {50+ t.Errorf("redo: A2=%+v state %d", s.Value(at("A2")), s.StateID())51+ }52+ // A new change drops the redo stack.53+ s.Set(at("B1"), "x")54+ if s.CanRedo() {55+ t.Error("redo survived a new change")56+ }57+ for s.CanUndo() {58+ s.Undo()59+ }60+ if s.Len() != 0 || s.StateID() != 0 {61+ t.Errorf("undo all: %v", inputs(s))62+ }63+ // A parse error changes nothing and adds no step.64+ s.Set(at("A1"), "=1+")65+ if s.CanUndo() {66+ t.Error("failed Set added a step")67+ }68+}69+70+func TestUndoKeepsWholeCells(t *testing.T) {71+ s := New()72+ s.Set(at("A1"), "=1+1")73+ orig := s.Cell(at("A1"))74+ s.Set(at("A1"), "x")75+ s.Undo()76+ got := s.Cell(at("A1"))77+ if got == orig || !reflect.DeepEqual(*got, *orig) {78+ t.Errorf("restored %+v, want a copy of %+v", got, orig)79+ }80+}81+82+func TestBatchGroupsAndHistoryCap(t *testing.T) {83+ s := New()84+ s.Batch(NewRect(at("A1"), at("A3")), func() error {85+ for i := range 3 {86+ s.Set(Addr{Row: i}, "1")87+ }88+ s.SetColWidth(0, 20)89+ return nil90+ })91+ s.Undo()92+ if s.Len() != 0 || s.ColWidth(0) != DefaultWidth || s.CanUndo() {93+ t.Errorf("batch undo left %v width %d", inputs(s), s.ColWidth(0))94+ }95+ for i := range MaxUndo + 10 {96+ s.Set(at("A1"), strings.Repeat("x", i+1))97+ }98+ n := 099+ for s.CanUndo() {100+ s.Undo()101+ n++102+ }103+ if n != MaxUndo || inputs(s)["A1"] != strings.Repeat("x", 10) {104+ t.Errorf("undid %d steps, A1 %q", n, inputs(s)["A1"])105+ }106+}107+108+func TestWidthPreviewMerges(t *testing.T) {109+ s := New()110+ s.Set(at("A1"), "1")111+ // A live preview over two columns, then the final value: one step.112+ for w := 11; w <= 13; w++ {113+ s.SetColWidth(0, w)114+ s.SetColWidth(1, w)115+ }116+ s.Undo()117+ if s.ColWidth(0) != DefaultWidth || s.ColWidth(1) != DefaultWidth || s.Len() != 1 {118+ t.Errorf("widths %d %d len %d", s.ColWidth(0), s.ColWidth(1), s.Len())119+ }120+ // A preview that is cancelled leaves no step at all.121+ s.Redo()122+ id := s.StateID()123+ s.Seal()124+ s.SetColWidth(0, 20)125+ s.SetColWidth(0, 13)126+ if s.StateID() != id {127+ t.Errorf("cancelled preview left a step")128+ }129+ // Separate actions are separate steps.130+ s.Seal()131+ s.SetColWidth(2, 5)132+ s.Seal()133+ s.SetColWidth(3, 5)134+ s.Undo()135+ if s.ColWidth(2) != 5 || s.ColWidth(3) != DefaultWidth {136+ t.Errorf("sealed width changes merged")137+ }138+}139+140+func TestLoadIsNotUndoable(t *testing.T) {141+ s := sheetOf(t, map[string]string{"A1": "1"})142+ s.SetColWidth(0, 12)143+ s.RecalcAll()144+ if s.CanUndo() || s.StateID() != 0 {145+ t.Error("RecalcAll kept history")146+ }147+}
M internal/sheet/sheet.go +48 -17
@@ -42,6 +42,8 @@ type Sheet struct {
4242 4343 // Circular is set when the last recalculation found a cycle.4444 Circular bool45+46+ hist history // undo and redo, see history.go4547 }4648 4749 // New returns an empty worksheet.
@@ -93,6 +95,11 @@ func (s *Sheet) ColWidth(c int) int {
9395 9496 // SetColWidth sets column c's width; w <= 0 resets it to the default.9597 func (s *Sheet) SetColWidth(c, w int) {98+ s.change(colRect(c, c), func() { s.setWidth(c, w) })99+}100+101+func (s *Sheet) setWidth(c, w int) {102+ s.recordWidth(c)96103 if w <= 0 {97104 delete(s.widths, c)98105 return
@@ -147,11 +154,9 @@ func classify(input string) (Node, error) {
147154 // erases the cell. A formula that fails to parse is rejected with a148155 // *ParseError and the sheet is left unchanged.149156 func (s *Sheet) Set(a Addr, input string) error {150- if err := s.put(a, input); err != nil {151- return err152- }153- s.recalc([]Addr{a})154- return nil157+ var err error158+ s.change(Rect{a, a}, func() { err = s.put(a, input) })159+ return err155160 }156161 157162 // put stores an entry without recalculating. Control characters are
@@ -164,20 +169,38 @@ func (s *Sheet) put(a Addr, input string) error {
164169 return r165170 }, input)166171 if input == "" {167- s.erase(a)172+ s.place(a, nil)168173 return nil169174 }170175 n, err := classify(input)171176 if err != nil {172177 return err173178 }174- c := &Cell{Input: input, expr: n}179+ c := &Cell{Input: input}180+ c.setExpr(n)181+ s.place(a, c)182+ return nil183+}184+185+// setExpr sets c's expression and the references indexed from it.186+func (c *Cell) setExpr(n Node) {187+ c.expr, c.refs, c.ranges = n, nil, nil175188 if n != nil {176189 walkRefs(n,177190 func(r Addr) { c.refs = append(c.refs, r) },178191 func(r Rect) { c.ranges = append(c.ranges, r) })179192 }180- s.erase(a)193+}194+195+// place stores c at a (nil blanks it), recording the old cell for undo and196+// keeping the dependency indexes current. Every cell mutation goes through197+// here.198+func (s *Sheet) place(a Addr, c *Cell) {199+ s.record(a)200+ s.unlink(a)201+ if c == nil {202+ return203+ }181204 s.cells[a] = c182205 for _, r := range c.refs {183206 if s.dependents[r] == nil {
@@ -188,24 +211,30 @@ func (s *Sheet) put(a Addr, input string) error {
188211 if len(c.ranges) > 0 {189212 s.rangeUsers[a] = struct{}{}190213 }191- return nil192214 }193215 194216 // EraseRange blanks every cell in r.195217 func (s *Sheet) EraseRange(r Rect) {196- var changed []Addr218+ s.change(r, func() {219+ for _, a := range s.cellsIn(r) {220+ s.place(a, nil)221+ }222+ })223+}224+225+// cellsIn returns the non-blank cells in r.226+func (s *Sheet) cellsIn(r Rect) []Addr {227+ var out []Addr197228 for a := range s.cells {198229 if r.Contains(a) {199- changed = append(changed, a)230+ out = append(out, a)200231 }201232 }202- for _, a := range changed {203- s.erase(a)204- }205- s.recalc(changed)233+ return out206234 }207235 208-func (s *Sheet) erase(a Addr) {236+// unlink removes the cell at a and its dependency edges.237+func (s *Sheet) unlink(a Addr) {209238 old := s.cells[a]210239 if old == nil {211240 return
@@ -220,9 +249,11 @@ func (s *Sheet) erase(a Addr) {
220249 delete(s.cells, a)221250 }222251 223-// RecalcAll recomputes every formula, e.g. after loading a file.252+// RecalcAll recomputes every formula. Loaders call it once the sheet is253+// built, so it also starts a fresh undo history: loading isn't undoable.224254 func (s *Sheet) RecalcAll() {225255 s.recalc(s.Addrs())256+ s.ClearHistory()226257 }227258 228259 // recalc recomputes the changed cells and everything that transitively