widget: [API] convert Editor to return one event at a time

This commit eliminates (*widget.Editor).Events() in favor of making
(*widget.Editor).Update() return events as they are generated in response to
input. This makes the behavior of the editor match the rest of the core widgets.
Callers who previously invoked Events() can now achieve the same thing by using
a loop like this:

for {
	ev, ok := editor.Update(gtx)
	if !ok {
		break
    }
	// Handle ev
}

This is undeniably more verbose, but it enables more sophisticated event processing.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
Chris Waldon
2024-01-17 13:17:15 -05:00
committed by Elias Naur
parent 95ca7b5b59
commit c645c2ec8e
2 changed files with 197 additions and 146 deletions
+184 -137
View File
@@ -87,16 +87,14 @@ type Editor struct {
clicker gesture.Click clicker gesture.Click
// events is the list of events not yet processed.
events []EditorEvent
// prevEvents is the number of events from the previous frame.
prevEvents int
// history contains undo history. // history contains undo history.
history []modification history []modification
// nextHistoryIdx is the index within the history of the next modification. This // 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 // is only not len(history) immediately after undo operations occur. It is framed as the "next" value
// to make the zero value consistent. // to make the zero value consistent.
nextHistoryIdx int nextHistoryIdx int
pending []EditorEvent
} }
type offEntry struct { type offEntry struct {
@@ -189,30 +187,37 @@ const (
maxBlinkDuration = 10 * time.Second maxBlinkDuration = 10 * time.Second
) )
// Events returns available editor events. func (e *Editor) processEvents(gtx layout.Context) (ev EditorEvent, ok bool) {
func (e *Editor) Events() []EditorEvent { if len(e.pending) > 0 {
events := e.events out := e.pending[0]
e.events = nil e.pending = e.pending[:copy(e.pending, e.pending[1:])]
e.prevEvents = 0 return out, true
return events
}
func (e *Editor) processEvents(gtx layout.Context) {
// Flush events from before the previous Layout.
n := copy(e.events, e.events[e.prevEvents:])
e.events = e.events[:n]
e.prevEvents = n
oldStart, oldLen := min(e.text.Selection()), e.text.SelectionLen()
e.processPointer(gtx)
e.processKey(gtx)
// Queue a SelectEvent if the selection changed, including if it went away.
if newStart, newLen := min(e.text.Selection()), e.text.SelectionLen(); oldStart != newStart || oldLen != newLen {
e.events = append(e.events, SelectEvent{})
} }
selStart, selEnd := e.Selection()
defer func() {
afterSelStart, afterSelEnd := e.Selection()
if selStart != afterSelStart || selEnd != afterSelEnd {
if ok {
e.pending = append(e.pending, SelectEvent{})
} else {
ev = SelectEvent{}
ok = true
}
}
}()
ev, ok = e.processPointer(gtx)
if ok {
return ev, ok
}
ev, ok = e.processKey(gtx)
if ok {
return ev, ok
}
return nil, false
} }
func (e *Editor) processPointer(gtx layout.Context) { func (e *Editor) processPointer(gtx layout.Context) (EditorEvent, bool) {
sbounds := e.text.ScrollBounds() sbounds := e.text.ScrollBounds()
var smin, smax int var smin, smax int
var axis gesture.Axis var axis gesture.Axis
@@ -244,92 +249,96 @@ func (e *Editor) processPointer(gtx layout.Context) {
e.text.ScrollRel(0, sdist) e.text.ScrollRel(0, sdist)
soff = e.text.ScrollOff().Y soff = e.text.ScrollOff().Y
} }
for _, evt := range e.clickDragEvents(gtx) {
switch evt := evt.(type) {
case gesture.ClickEvent:
switch {
case evt.Kind == gesture.KindPress && evt.Source == pointer.Mouse,
evt.Kind == gesture.KindClick && evt.Source != pointer.Mouse:
prevCaretPos, _ := e.text.Selection()
e.blinkStart = gtx.Now
e.text.MoveCoord(image.Point{
X: int(math.Round(float64(evt.Position.X))),
Y: int(math.Round(float64(evt.Position.Y))),
})
gtx.Execute(key.FocusCmd{Tag: e})
if e.scroller.State() != gesture.StateFlinging {
e.scrollCaret = true
}
if evt.Modifiers == key.ModShift {
start, end := e.text.Selection()
// If they clicked closer to the end, then change the end to
// where the caret used to be (effectively swapping start & end).
if abs(end-start) < abs(start-prevCaretPos) {
e.text.SetCaret(start, prevCaretPos)
}
} else {
e.text.ClearSelection()
}
e.dragging = true
// Process multi-clicks.
switch {
case evt.NumClicks == 2:
e.text.MoveWord(-1, selectionClear)
e.text.MoveWord(1, selectionExtend)
e.dragging = false
case evt.NumClicks >= 3:
e.text.MoveStart(selectionClear)
e.text.MoveEnd(selectionExtend)
e.dragging = false
}
}
case pointer.Event:
release := false
switch {
case evt.Kind == pointer.Release && evt.Source == pointer.Mouse:
release = true
fallthrough
case evt.Kind == pointer.Drag && evt.Source == pointer.Mouse:
if e.dragging {
e.blinkStart = gtx.Now
e.text.MoveCoord(image.Point{
X: int(math.Round(float64(evt.Position.X))),
Y: int(math.Round(float64(evt.Position.Y))),
})
e.scrollCaret = true
if release {
e.dragging = false
}
}
}
}
}
if (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin) {
e.scroller.Stop()
}
}
func (e *Editor) clickDragEvents(gtx layout.Context) []event.Event {
var combinedEvents []event.Event
for { for {
evt, ok := e.clicker.Update(gtx.Source) evt, ok := e.clicker.Update(gtx.Source)
if !ok { if !ok {
break break
} }
combinedEvents = append(combinedEvents, evt) ev, ok := e.processPointerEvent(gtx, evt)
if ok {
return ev, ok
}
} }
for { for {
evt, ok := e.dragger.Update(gtx.Metric, gtx.Source, gesture.Both) evt, ok := e.dragger.Update(gtx.Metric, gtx.Source, gesture.Both)
if !ok { if !ok {
break break
} }
combinedEvents = append(combinedEvents, evt) ev, ok := e.processPointerEvent(gtx, evt)
if ok {
return ev, ok
}
} }
return combinedEvents
if (sdist > 0 && soff >= smax) || (sdist < 0 && soff <= smin) {
e.scroller.Stop()
}
return nil, false
}
func (e *Editor) processPointerEvent(gtx layout.Context, ev event.Event) (EditorEvent, bool) {
switch evt := ev.(type) {
case gesture.ClickEvent:
switch {
case evt.Kind == gesture.KindPress && evt.Source == pointer.Mouse,
evt.Kind == gesture.KindClick && evt.Source != pointer.Mouse:
prevCaretPos, _ := e.text.Selection()
e.blinkStart = gtx.Now
e.text.MoveCoord(image.Point{
X: int(math.Round(float64(evt.Position.X))),
Y: int(math.Round(float64(evt.Position.Y))),
})
gtx.Execute(key.FocusCmd{Tag: e})
if e.scroller.State() != gesture.StateFlinging {
e.scrollCaret = true
}
if evt.Modifiers == key.ModShift {
start, end := e.text.Selection()
// If they clicked closer to the end, then change the end to
// where the caret used to be (effectively swapping start & end).
if abs(end-start) < abs(start-prevCaretPos) {
e.text.SetCaret(start, prevCaretPos)
}
} else {
e.text.ClearSelection()
}
e.dragging = true
// Process multi-clicks.
switch {
case evt.NumClicks == 2:
e.text.MoveWord(-1, selectionClear)
e.text.MoveWord(1, selectionExtend)
e.dragging = false
case evt.NumClicks >= 3:
e.text.MoveStart(selectionClear)
e.text.MoveEnd(selectionExtend)
e.dragging = false
}
}
case pointer.Event:
release := false
switch {
case evt.Kind == pointer.Release && evt.Source == pointer.Mouse:
release = true
fallthrough
case evt.Kind == pointer.Drag && evt.Source == pointer.Mouse:
if e.dragging {
e.blinkStart = gtx.Now
e.text.MoveCoord(image.Point{
X: int(math.Round(float64(evt.Position.X))),
Y: int(math.Round(float64(evt.Position.Y))),
})
e.scrollCaret = true
if release {
e.dragging = false
}
}
}
}
return nil, false
} }
func condFilter(pred bool, f key.Filter) event.Filter { func condFilter(pred bool, f key.Filter) event.Filter {
@@ -340,9 +349,9 @@ func condFilter(pred bool, f key.Filter) event.Filter {
} }
} }
func (e *Editor) processKey(gtx layout.Context) { func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) {
if e.text.Changed() { if e.text.Changed() {
e.events = append(e.events, ChangeEvent{}) return ChangeEvent{}, true
} }
caret, _ := e.text.Selection() caret, _ := e.text.Selection()
atBeginning := caret == 0 atBeginning := caret == 0
@@ -396,15 +405,17 @@ func (e *Editor) processKey(gtx layout.Context) {
if !e.ReadOnly && e.Submit && (ke.Name == key.NameReturn || ke.Name == key.NameEnter) { if !e.ReadOnly && e.Submit && (ke.Name == key.NameReturn || ke.Name == key.NameEnter) {
if !ke.Modifiers.Contain(key.ModShift) { if !ke.Modifiers.Contain(key.ModShift) {
e.scratch = e.text.Text(e.scratch) e.scratch = e.text.Text(e.scratch)
e.events = append(e.events, SubmitEvent{ return SubmitEvent{
Text: string(e.scratch), Text: string(e.scratch),
}) }, true
continue
} }
} }
e.command(gtx, ke)
e.scrollCaret = true e.scrollCaret = true
e.scroller.Stop() e.scroller.Stop()
ev, ok := e.command(gtx, ke)
if ok {
return ev, ok
}
case key.SnippetEvent: case key.SnippetEvent:
e.updateSnippet(gtx, ke.Start, ke.End) e.updateSnippet(gtx, ke.Start, ke.End)
case key.EditEvent: case key.EditEvent:
@@ -431,13 +442,15 @@ func (e *Editor) processKey(gtx layout.Context) {
// Reset caret xoff. // Reset caret xoff.
e.text.MoveCaret(0, 0) e.text.MoveCaret(0, 0)
if submit { if submit {
if e.text.Changed() {
e.events = append(e.events, ChangeEvent{})
}
e.scratch = e.text.Text(e.scratch) e.scratch = e.text.Text(e.scratch)
e.events = append(e.events, SubmitEvent{ submitEvent := SubmitEvent{
Text: string(e.scratch), Text: string(e.scratch),
}) }
if e.text.Changed() {
e.pending = append(e.pending, submitEvent)
return ChangeEvent{}, true
}
return submitEvent, true
} }
// Complete a paste event, initiated by Shortcut-V in Editor.command(). // Complete a paste event, initiated by Shortcut-V in Editor.command().
case transfer.DataEvent: case transfer.DataEvent:
@@ -445,7 +458,9 @@ func (e *Editor) processKey(gtx layout.Context) {
e.scroller.Stop() e.scroller.Stop()
content, err := io.ReadAll(ke.Open()) content, err := io.ReadAll(ke.Open())
if err == nil { if err == nil {
e.Insert(string(content)) if e.Insert(string(content)) != 0 {
return ChangeEvent{}, true
}
} }
case key.SelectionEvent: case key.SelectionEvent:
e.scrollCaret = true e.scrollCaret = true
@@ -457,11 +472,12 @@ func (e *Editor) processKey(gtx layout.Context) {
} }
} }
if e.text.Changed() { if e.text.Changed() {
e.events = append(e.events, ChangeEvent{}) return ChangeEvent{}, true
} }
return nil, false
} }
func (e *Editor) command(gtx layout.Context, k key.Event) { func (e *Editor) command(gtx layout.Context, k key.Event) (EditorEvent, bool) {
direction := 1 direction := 1
if gtx.Locale.Direction.Progression() == system.TowardOrigin { if gtx.Locale.Direction.Progression() == system.TowardOrigin {
direction = -1 direction = -1
@@ -485,7 +501,9 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
if text := string(e.scratch); text != "" { if text := string(e.scratch); text != "" {
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(text))}) gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(text))})
if k.Name == "X" && !e.ReadOnly { if k.Name == "X" && !e.ReadOnly {
e.Delete(1) if e.Delete(1) != 0 {
return ChangeEvent{}, true
}
} }
} }
// Select all // Select all
@@ -494,33 +512,47 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
case "Z": case "Z":
if !e.ReadOnly { if !e.ReadOnly {
if k.Modifiers.Contain(key.ModShift) { if k.Modifiers.Contain(key.ModShift) {
e.redo() if ev, ok := e.redo(); ok {
return ev, ok
}
} else { } else {
e.undo() if ev, ok := e.undo(); ok {
return ev, ok
}
} }
} }
} }
return return nil, false
} }
switch k.Name { switch k.Name {
case key.NameReturn, key.NameEnter: case key.NameReturn, key.NameEnter:
if !e.ReadOnly { if !e.ReadOnly {
e.Insert("\n") if e.Insert("\n") != 0 {
return ChangeEvent{}, true
}
} }
case key.NameDeleteBackward: case key.NameDeleteBackward:
if !e.ReadOnly { if !e.ReadOnly {
if moveByWord { if moveByWord {
e.deleteWord(-1) if e.deleteWord(-1) != 0 {
return ChangeEvent{}, true
}
} else { } else {
e.Delete(-1) if e.Delete(-1) != 0 {
return ChangeEvent{}, true
}
} }
} }
case key.NameDeleteForward: case key.NameDeleteForward:
if !e.ReadOnly { if !e.ReadOnly {
if moveByWord { if moveByWord {
e.deleteWord(1) if e.deleteWord(1) != 0 {
return ChangeEvent{}, true
}
} else { } else {
e.Delete(1) if e.Delete(1) != 0 {
return ChangeEvent{}, true
}
} }
} }
case key.NameUpArrow: case key.NameUpArrow:
@@ -554,6 +586,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) {
case key.NameEnd: case key.NameEnd:
e.text.MoveEnd(selAct) e.text.MoveEnd(selAct)
} }
return nil, false
} }
// initBuffer should be invoked first in every exported function that accesses // initBuffer should be invoked first in every exported function that accesses
@@ -572,10 +605,13 @@ func (e *Editor) initBuffer() {
e.text.WrapPolicy = e.WrapPolicy e.text.WrapPolicy = e.WrapPolicy
} }
// Update the state of the editor in response to input events. // Update the state of the editor in response to input events. Update consumes editor
func (e *Editor) Update(gtx layout.Context) { // input events until there are no remaining events or an editor event is generated.
// To fully update the state of the editor, callers should call Update until it returns
// false.
func (e *Editor) Update(gtx layout.Context) (EditorEvent, bool) {
e.initBuffer() e.initBuffer()
e.processEvents(gtx) event, ok := e.processEvents(gtx)
// Notify IME of selection if it changed. // Notify IME of selection if it changed.
newSel := e.ime.selection newSel := e.ime.selection
start, end := e.text.Selection() start, end := e.text.Selection()
@@ -595,13 +631,19 @@ func (e *Editor) Update(gtx layout.Context) {
} }
e.updateSnippet(gtx, e.ime.start, e.ime.end) e.updateSnippet(gtx, e.ime.start, e.ime.end)
return event, ok
} }
// Layout lays out the editor using the provided textMaterial as the paint material // Layout lays out the editor using the provided textMaterial as the paint material
// for the text glyphs+caret and the selectMaterial as the paint material for the // for the text glyphs+caret and the selectMaterial as the paint material for the
// selection rectangle. // selection rectangle.
func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectMaterial op.CallOp) layout.Dimensions { func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectMaterial op.CallOp) layout.Dimensions {
e.Update(gtx) for {
_, ok := e.Update(gtx)
if !ok {
break
}
}
e.text.Layout(gtx, lt, font, size) e.text.Layout(gtx, lt, font, size)
return e.layout(gtx, textMaterial, selectMaterial) return e.layout(gtx, textMaterial, selectMaterial)
@@ -760,10 +802,10 @@ func (e *Editor) CaretCoords() f32.Point {
// //
// If there is a selection, it is deleted and counts as a single grapheme // If there is a selection, it is deleted and counts as a single grapheme
// cluster. // cluster.
func (e *Editor) Delete(graphemeClusters int) { func (e *Editor) Delete(graphemeClusters int) (deletedRunes int) {
e.initBuffer() e.initBuffer()
if graphemeClusters == 0 { if graphemeClusters == 0 {
return return 0
} }
start, end := e.text.Selection() start, end := e.text.Selection()
@@ -779,9 +821,10 @@ func (e *Editor) Delete(graphemeClusters int) {
// Reset xoff. // Reset xoff.
e.text.MoveCaret(0, 0) e.text.MoveCaret(0, 0)
e.ClearSelection() e.ClearSelection()
return end - start
} }
func (e *Editor) Insert(s string) { func (e *Editor) Insert(s string) (insertedRunes int) {
e.initBuffer() e.initBuffer()
if e.SingleLine { if e.SingleLine {
s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\n", " ")
@@ -795,6 +838,7 @@ func (e *Editor) Insert(s string) {
e.text.MoveCaret(0, 0) e.text.MoveCaret(0, 0)
e.SetCaret(start+moves, start+moves) e.SetCaret(start+moves, start+moves)
e.scrollCaret = true e.scrollCaret = true
return moves
} }
// modification represents a change to the contents of the editor buffer. // modification represents a change to the contents of the editor buffer.
@@ -814,10 +858,10 @@ type modification struct {
// undo applies the modification at e.history[e.historyIdx] and decrements // undo applies the modification at e.history[e.historyIdx] and decrements
// e.historyIdx. // e.historyIdx.
func (e *Editor) undo() { func (e *Editor) undo() (EditorEvent, bool) {
e.initBuffer() e.initBuffer()
if len(e.history) < 1 || e.nextHistoryIdx == 0 { if len(e.history) < 1 || e.nextHistoryIdx == 0 {
return return nil, false
} }
mod := e.history[e.nextHistoryIdx-1] mod := e.history[e.nextHistoryIdx-1]
replaceEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) replaceEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent)
@@ -825,14 +869,15 @@ func (e *Editor) undo() {
caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent)
e.SetCaret(caretEnd, mod.StartRune) e.SetCaret(caretEnd, mod.StartRune)
e.nextHistoryIdx-- e.nextHistoryIdx--
return ChangeEvent{}, true
} }
// redo applies the modification at e.history[e.historyIdx] and increments // redo applies the modification at e.history[e.historyIdx] and increments
// e.historyIdx. // e.historyIdx.
func (e *Editor) redo() { func (e *Editor) redo() (EditorEvent, bool) {
e.initBuffer() e.initBuffer()
if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) { if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) {
return return nil, false
} }
mod := e.history[e.nextHistoryIdx] mod := e.history[e.nextHistoryIdx]
end := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) end := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent)
@@ -840,6 +885,7 @@ func (e *Editor) redo() {
caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent)
e.SetCaret(caretEnd, mod.StartRune) e.SetCaret(caretEnd, mod.StartRune)
e.nextHistoryIdx++ e.nextHistoryIdx++
return ChangeEvent{}, true
} }
// replace the text between start and end with s. Indices are in runes. // replace the text between start and end with s. Indices are in runes.
@@ -923,18 +969,18 @@ func (e *Editor) MoveCaret(startDelta, endDelta int) {
// Positive is forward, negative is backward. // Positive is forward, negative is backward.
// Absolute values greater than one will delete that many words. // Absolute values greater than one will delete that many words.
// The selection counts as a single word. // The selection counts as a single word.
func (e *Editor) deleteWord(distance int) { func (e *Editor) deleteWord(distance int) (deletedRunes int) {
if distance == 0 { if distance == 0 {
return return
} }
start, end := e.text.Selection() start, end := e.text.Selection()
if start != end { if start != end {
e.Delete(1) deletedRunes = e.Delete(1)
distance -= sign(distance) distance -= sign(distance)
} }
if distance == 0 { if distance == 0 {
return return deletedRunes
} }
// split the distance information into constituent parts to be // split the distance information into constituent parts to be
@@ -974,7 +1020,8 @@ func (e *Editor) deleteWord(distance int) {
runes += 1 runes += 1
} }
} }
e.Delete(runes * direction) deletedRunes += e.Delete(runes * direction)
return deletedRunes
} }
// SelectionLen returns the length of the selection, in runes; it is // SelectionLen returns the length of the selection, in runes; it is
+13 -9
View File
@@ -905,7 +905,6 @@ g 2 4 6 8 g
gtx.Execute(key.FocusCmd{Tag: e}) gtx.Execute(key.FocusCmd{Tag: e})
// Layout once with no events; populate e.lines. // Layout once with no events; populate e.lines.
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Events() // throw away any events from this layout
r.Frame(gtx.Ops) r.Frame(gtx.Ops)
gtx.Source = r.Source() gtx.Source = r.Source()
@@ -929,14 +928,13 @@ g 2 4 6 8 g
) )
tim += time.Second // Avoid multi-clicks. tim += time.Second // Avoid multi-clicks.
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) for {
for _, evt := range e.Events() { _, ok := e.Update(gtx) // throw away any events from this layout
switch evt.(type) { if !ok {
case SelectEvent: break
return e.SelectedText()
} }
} }
return "" return e.SelectedText()
} }
type screenPos image.Point type screenPos image.Point
logicalPosMatch := func(t *testing.T, n int, label string, expected screenPos, actual combinedPos) { logicalPosMatch := func(t *testing.T, n int, label string, expected screenPos, actual combinedPos) {
@@ -1162,12 +1160,18 @@ func TestEditor_Submit(t *testing.T) {
r.Queue( r.Queue(
key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"}, key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"},
) )
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
got := []EditorEvent{}
for {
ev, ok := e.Update(gtx)
if !ok {
break
}
got = append(got, ev)
}
if got, want := e.Text(), "ab1"; got != want { if got, want := e.Text(), "ab1"; got != want {
t.Errorf("editor failed to filter newline") t.Errorf("editor failed to filter newline")
} }
got := e.Events()
want := []EditorEvent{ want := []EditorEvent{
ChangeEvent{}, ChangeEvent{},
SubmitEvent{Text: e.Text()}, SubmitEvent{Text: e.Text()},