~eliasnaur/gio

41de0048dbccc6811b57e7d83f7a55d143245fe6 — Chris Waldon 10 months ago 1d9ab65
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 <christopher.waldon.dev@gmail.com>
2 files changed, 148 insertions(+), 12 deletions(-)

M widget/editor.go
M widget/editor_test.go
M widget/editor.go => widget/editor.go +90 -12
@@ 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)

M widget/editor_test.go => widget/editor_test.go +58 -0
@@ 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) {