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) {