From 41de0048dbccc6811b57e7d83f7a55d143245fe6 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Sun, 10 Jul 2022 09:36:59 -0400 Subject: [PATCH] widget: implement editor undo/redo This commit adds a simple linear-history undo/redo mechanism to widget.Editor bound to Short-(Shift)-Z as well as tests for this new feature. Notes on the implementation: - using a slice to hold the history does mean that we incur allocations as the user types, but I hope that the Go slice growth heuristic means that the number of times we pay this penalty is very small. We also never shrink the slice in this implementation, which ensures that undoing work and then making additional modifications is very efficient, but could be framed as a memory leak. - this implementation creates a new history element every time we call replace(). This means that, on desktop, it's essentially one per rune of input. Users likely want to be able to undo larger units of change, so a future improvement could be to coalesce changes so long as the selection doesn't change between them. - I think it's possible to store only one of the Apply/Reverse change contents in the history slice, but it's significantly more complicated. To implement this, you'd need to add a field indicating if the modification represented a forward or backward change, and then rewrite the modification's content as you performed undo/redo operations.For the time being, I'm not sure it's worth this complexity. - Future work could introduce a limit to the number of history entries stored. If we did this, we should also change the data structure for storing history. Enforcing such a limit using a simple slice like this would be extremely inefficient. Perhaps a ring buffer or a linked list would make more sense? - Applications will likely want to be able to manipulate undo history in the future. We may wish to export undo() and redo() from the editor. Applications will also likely want a mechanism to save the undo history to disk and restore it (implementing persistent undo). I'm not sure what the most suitable API for that is yet, so I decided not to try to tackle it yet. Signed-off-by: Chris Waldon --- widget/editor.go | 102 +++++++++++++++++++++++++++++++++++++----- widget/editor_test.go | 58 ++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/widget/editor.go b/widget/editor.go index 35149acd..a319981a 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -106,6 +106,13 @@ type Editor struct { prevEvents int locale system.Locale + + // history contains undo history. + history []modification + // nextHistoryIdx is the index within the history of the next modification. This + // is only not len(history) immediately after undo operations occur. It is framed as the "next" value + // to make the zero value consistent. + nextHistoryIdx int } type offEntry struct { @@ -365,7 +372,7 @@ func (e *Editor) processKey(gtx layout.Context) { case key.EditEvent: e.caret.scroll = true e.scroller.Stop() - moves := e.replace(ke.Range.Start, ke.Range.End, ke.Text) + moves := e.replace(ke.Range.Start, ke.Range.End, ke.Text, true) adjust += utf8.RuneCountInString(ke.Text) - moves e.caret.xoff = 0 // Complete a paste event, initiated by Shortcut-V in Editor.command(). @@ -470,6 +477,12 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { case "A": e.caret.end = 0 e.caret.start = e.Len() + case "Z": + if k.Modifiers.Contain(key.ModShift) { + e.redo() + } else { + e.undo() + } } } @@ -616,10 +629,10 @@ func (e *Editor) layout(gtx layout.Context, content layout.Widget) layout.Dimens defer clip.Rect(image.Rectangle{Max: e.viewSize}).Push(gtx.Ops).Pop() pointer.CursorText.Add(gtx.Ops) - const keyFilterNoLeftUp = "(ShortAlt)-(Shift)-[→,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]" - const keyFilterNoRightDown = "(ShortAlt)-(Shift)-[←,↑]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]" - const keyFilterNoArrows = "(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]" - const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]" + const keyFilterNoLeftUp = "(ShortAlt)-(Shift)-[→,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" + const keyFilterNoRightDown = "(ShortAlt)-(Shift)-[←,↑]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" + const keyFilterNoArrows = "(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" + const keyFilterAllArrows = "(ShortAlt)-(Shift)-[←,→,↑,↓]|(Shift)-[⏎,⌤]|(ShortAlt)-(Shift)-[⌫,⌦]|(Shift)-[⇞,⇟,⇱,⇲]|Short-[C,V,X,A]|Short-(Shift)-Z" caret := e.closestPosition(combinedPos{runes: e.caret.start}) switch { case caret.runes == 0 && caret.runes == e.Len(): @@ -858,7 +871,7 @@ func (e *Editor) SetText(s string) { e.rr = editBuffer{} e.caret.start = 0 e.caret.end = 0 - e.replace(e.caret.start, e.caret.end, s) + e.replace(e.caret.start, e.caret.end, s, true) e.caret.xoff = 0 } @@ -1139,7 +1152,7 @@ func (e *Editor) Delete(runes int) { } end += runes - e.replace(start, end, "") + e.replace(start, end, "", true) e.caret.xoff = 0 e.ClearSelection() } @@ -1155,7 +1168,7 @@ func (e *Editor) Insert(s string) { // there is a selection, append overwrites it. // xxx|yyy + append zzz => xxxzzz|yyy func (e *Editor) append(s string) { - moves := e.replace(e.caret.start, e.caret.end, s) + moves := e.replace(e.caret.start, e.caret.end, s, true) e.caret.xoff = 0 start := e.caret.start if end := e.caret.end; end < start { @@ -1165,9 +1178,54 @@ func (e *Editor) append(s string) { e.caret.end = e.caret.start } +// modification represents a change to the contents of the editor buffer. +// It contains the necessary information to both apply the change and +// reverse it, and is useful for implementing undo/redo. +type modification struct { + // StartRune is the inclusive index of the first rune + // modified. + StartRune int + // ApplyContent is the data inserted at StartRune to + // apply this operation. It overwrites len([]rune(ReverseContent)) runes. + ApplyContent string + // ReverseContent is the data inserted at StartRune to + // apply this operation. It overwrites len([]rune(ApplyContent)) runes. + ReverseContent string +} + +// undo applies the modification at e.history[e.historyIdx] and decrements +// e.historyIdx. +func (e *Editor) undo() { + if len(e.history) < 1 || e.nextHistoryIdx == 0 { + return + } + mod := e.history[e.nextHistoryIdx-1] + replaceEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) + e.replace(mod.StartRune, replaceEnd, mod.ReverseContent, false) + caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) + e.SetCaret(caretEnd, mod.StartRune) + e.nextHistoryIdx-- +} + +// redo applies the modification at e.history[e.historyIdx] and increments +// e.historyIdx. +func (e *Editor) redo() { + if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) { + return + } + mod := e.history[e.nextHistoryIdx] + end := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) + e.replace(mod.StartRune, end, mod.ApplyContent, false) + caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) + e.SetCaret(caretEnd, mod.StartRune) + e.nextHistoryIdx++ +} + // replace the text between start and end with s. Indices are in runes. // It returns the number of runes inserted. -func (e *Editor) replace(start, end int, s string) int { +// addHistory controls whether this modification is recorded in the undo +// history. +func (e *Editor) replace(start, end int, s string, addHistory bool) int { if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") } @@ -1177,7 +1235,6 @@ func (e *Editor) replace(start, end int, s string) int { startPos := e.closestPosition(combinedPos{runes: start}) endPos := e.closestPosition(combinedPos{runes: end}) startOff := e.runeOffset(startPos.runes) - e.rr.deleteRunes(startOff, endPos.runes-startPos.runes) sc := utf8.RuneCountInString(s) el := e.Len() for e.MaxLen > 0 && el+sc > e.MaxLen { @@ -1185,8 +1242,29 @@ func (e *Editor) replace(start, end int, s string) int { s = s[:len(s)-n] sc-- } - e.rr.prepend(startOff, s) newEnd := startPos.runes + sc + replaceSize := endPos.runes - startPos.runes + + if addHistory { + e.rr.Seek(int64(startOff), 0) + deleted := make([]rune, 0, replaceSize) + for i := 0; i < replaceSize; i++ { + ru, _, _ := e.rr.ReadRune() + deleted = append(deleted, ru) + } + if e.nextHistoryIdx < len(e.history) { + e.history = e.history[:e.nextHistoryIdx] + } + e.history = append(e.history, modification{ + StartRune: startPos.runes, + ApplyContent: s, + ReverseContent: string(deleted), + }) + e.nextHistoryIdx++ + } + + e.rr.deleteRunes(startOff, replaceSize) + e.rr.prepend(startOff, s) adjust := func(pos int) int { switch { case newEnd < pos && pos <= endPos.runes: @@ -1329,7 +1407,7 @@ func (e *Editor) deleteWord(distance int) { } return r } - var runes = 1 + runes := 1 for ii := 0; ii < words; ii++ { r := next(runes) wantSpace := unicode.IsSpace(r) diff --git a/widget/editor_test.go b/widget/editor_test.go index 454c0c70..40d6f345 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -36,6 +36,64 @@ var english = system.Locale{ Direction: system.LTR, } +// TestEditorHistory ensures that undo and redo behave correctly. +func TestEditorHistory(t *testing.T) { + e := new(Editor) + // Insert some multi-byte unicode text. + e.SetText("안П你 hello 안П你") + assertContents(t, e, "안П你 hello 안П你", 0, 0) + // Overwrite all of the text with the empty string. + e.SetCaret(0, len([]rune("안П你 hello 안П你"))) + e.Insert("") + assertContents(t, e, "", 0, 0) + // Ensure that undoing the overwrite succeeds. + e.undo() + assertContents(t, e, "안П你 hello 안П你", 13, 0) + // Ensure that redoing the overwrite succeeds. + e.redo() + assertContents(t, e, "", 0, 0) + // Insert some smaller text. + e.Insert("안П你 hello") + assertContents(t, e, "안П你 hello", 9, 9) + // Replace a region in the middle of the text. + e.SetCaret(1, 5) + e.Insert("П") + assertContents(t, e, "안Пello", 2, 2) + // Replace a second region in the middle. + e.SetCaret(3, 4) + e.Insert("П") + assertContents(t, e, "안ПeПlo", 4, 4) + // Ensure both operations undo successfully. + e.undo() + assertContents(t, e, "안Пello", 4, 3) + e.undo() + assertContents(t, e, "안П你 hello", 5, 1) + // Make a new modification. + e.Insert("Something New") + // Ensure that redo history is discarded now that + // we've diverged from the linear editing history. + // This redo() call should do nothing. + text := e.Text() + start, end := e.Selection() + e.redo() + assertContents(t, e, text, start, end) +} + +func assertContents(t *testing.T, e *Editor, contents string, selectionStart, selectionEnd int) { + t.Helper() + actualContents := e.Text() + if actualContents != contents { + t.Errorf("expected editor to contain %s, got %s", contents, actualContents) + } + actualStart, actualEnd := e.Selection() + if actualStart != selectionStart { + t.Errorf("expected selection start to be %d, got %d", selectionStart, actualStart) + } + if actualEnd != selectionEnd { + t.Errorf("expected selection end to be %d, got %d", selectionEnd, actualEnd) + } +} + // TestEditorZeroDimensions ensures that an empty editor still reserves // space for displaying its caret when the constraints allow for it. func TestEditorZeroDimensions(t *testing.T) {