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>
This commit is contained in:
Chris Waldon
2022-07-10 09:36:59 -04:00
committed by Elias Naur
parent 1d9ab65313
commit 41de0048db
2 changed files with 148 additions and 12 deletions
+58
View File
@@ -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) {