From c645c2ec8e19ae3395c3fc168c10a8ed86dd2bd6 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Wed, 17 Jan 2024 13:17:15 -0500 Subject: [PATCH] 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 --- widget/editor.go | 321 ++++++++++++++++++++++++------------------ widget/editor_test.go | 22 +-- 2 files changed, 197 insertions(+), 146 deletions(-) diff --git a/widget/editor.go b/widget/editor.go index 455e95e0..c43e794b 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -87,16 +87,14 @@ type Editor struct { 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 []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 + + pending []EditorEvent } type offEntry struct { @@ -189,30 +187,37 @@ const ( maxBlinkDuration = 10 * time.Second ) -// Events returns available editor events. -func (e *Editor) Events() []EditorEvent { - events := e.events - e.events = nil - e.prevEvents = 0 - 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{}) +func (e *Editor) processEvents(gtx layout.Context) (ev EditorEvent, ok bool) { + if len(e.pending) > 0 { + out := e.pending[0] + e.pending = e.pending[:copy(e.pending, e.pending[1:])] + return out, true } + 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() var smin, smax int var axis gesture.Axis @@ -244,92 +249,96 @@ func (e *Editor) processPointer(gtx layout.Context) { e.text.ScrollRel(0, sdist) 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 { evt, ok := e.clicker.Update(gtx.Source) if !ok { break } - combinedEvents = append(combinedEvents, evt) + ev, ok := e.processPointerEvent(gtx, evt) + if ok { + return ev, ok + } } for { evt, ok := e.dragger.Update(gtx.Metric, gtx.Source, gesture.Both) if !ok { 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 { @@ -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() { - e.events = append(e.events, ChangeEvent{}) + return ChangeEvent{}, true } caret, _ := e.text.Selection() 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 !ke.Modifiers.Contain(key.ModShift) { e.scratch = e.text.Text(e.scratch) - e.events = append(e.events, SubmitEvent{ + return SubmitEvent{ Text: string(e.scratch), - }) - continue + }, true } } - e.command(gtx, ke) e.scrollCaret = true e.scroller.Stop() + ev, ok := e.command(gtx, ke) + if ok { + return ev, ok + } case key.SnippetEvent: e.updateSnippet(gtx, ke.Start, ke.End) case key.EditEvent: @@ -431,13 +442,15 @@ func (e *Editor) processKey(gtx layout.Context) { // Reset caret xoff. e.text.MoveCaret(0, 0) if submit { - if e.text.Changed() { - e.events = append(e.events, ChangeEvent{}) - } e.scratch = e.text.Text(e.scratch) - e.events = append(e.events, SubmitEvent{ + submitEvent := SubmitEvent{ 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(). case transfer.DataEvent: @@ -445,7 +458,9 @@ func (e *Editor) processKey(gtx layout.Context) { e.scroller.Stop() content, err := io.ReadAll(ke.Open()) if err == nil { - e.Insert(string(content)) + if e.Insert(string(content)) != 0 { + return ChangeEvent{}, true + } } case key.SelectionEvent: e.scrollCaret = true @@ -457,11 +472,12 @@ func (e *Editor) processKey(gtx layout.Context) { } } 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 if gtx.Locale.Direction.Progression() == system.TowardOrigin { direction = -1 @@ -485,7 +501,9 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { if text := string(e.scratch); text != "" { gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(text))}) if k.Name == "X" && !e.ReadOnly { - e.Delete(1) + if e.Delete(1) != 0 { + return ChangeEvent{}, true + } } } // Select all @@ -494,33 +512,47 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { case "Z": if !e.ReadOnly { if k.Modifiers.Contain(key.ModShift) { - e.redo() + if ev, ok := e.redo(); ok { + return ev, ok + } } else { - e.undo() + if ev, ok := e.undo(); ok { + return ev, ok + } } } } - return + return nil, false } switch k.Name { case key.NameReturn, key.NameEnter: if !e.ReadOnly { - e.Insert("\n") + if e.Insert("\n") != 0 { + return ChangeEvent{}, true + } } case key.NameDeleteBackward: if !e.ReadOnly { if moveByWord { - e.deleteWord(-1) + if e.deleteWord(-1) != 0 { + return ChangeEvent{}, true + } } else { - e.Delete(-1) + if e.Delete(-1) != 0 { + return ChangeEvent{}, true + } } } case key.NameDeleteForward: if !e.ReadOnly { if moveByWord { - e.deleteWord(1) + if e.deleteWord(1) != 0 { + return ChangeEvent{}, true + } } else { - e.Delete(1) + if e.Delete(1) != 0 { + return ChangeEvent{}, true + } } } case key.NameUpArrow: @@ -554,6 +586,7 @@ func (e *Editor) command(gtx layout.Context, k key.Event) { case key.NameEnd: e.text.MoveEnd(selAct) } + return nil, false } // initBuffer should be invoked first in every exported function that accesses @@ -572,10 +605,13 @@ func (e *Editor) initBuffer() { e.text.WrapPolicy = e.WrapPolicy } -// Update the state of the editor in response to input events. -func (e *Editor) Update(gtx layout.Context) { +// Update the state of the editor in response to input events. Update consumes editor +// 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.processEvents(gtx) + event, ok := e.processEvents(gtx) // Notify IME of selection if it changed. newSel := e.ime.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) + return event, ok } // 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 // selection rectangle. 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) 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 // cluster. -func (e *Editor) Delete(graphemeClusters int) { +func (e *Editor) Delete(graphemeClusters int) (deletedRunes int) { e.initBuffer() if graphemeClusters == 0 { - return + return 0 } start, end := e.text.Selection() @@ -779,9 +821,10 @@ func (e *Editor) Delete(graphemeClusters int) { // Reset xoff. e.text.MoveCaret(0, 0) e.ClearSelection() + return end - start } -func (e *Editor) Insert(s string) { +func (e *Editor) Insert(s string) (insertedRunes int) { e.initBuffer() if e.SingleLine { s = strings.ReplaceAll(s, "\n", " ") @@ -795,6 +838,7 @@ func (e *Editor) Insert(s string) { e.text.MoveCaret(0, 0) e.SetCaret(start+moves, start+moves) e.scrollCaret = true + return moves } // 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 // e.historyIdx. -func (e *Editor) undo() { +func (e *Editor) undo() (EditorEvent, bool) { e.initBuffer() if len(e.history) < 1 || e.nextHistoryIdx == 0 { - return + return nil, false } mod := e.history[e.nextHistoryIdx-1] replaceEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) @@ -825,14 +869,15 @@ func (e *Editor) undo() { caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) e.SetCaret(caretEnd, mod.StartRune) e.nextHistoryIdx-- + return ChangeEvent{}, true } // redo applies the modification at e.history[e.historyIdx] and increments // e.historyIdx. -func (e *Editor) redo() { +func (e *Editor) redo() (EditorEvent, bool) { e.initBuffer() if len(e.history) < 1 || e.nextHistoryIdx == len(e.history) { - return + return nil, false } mod := e.history[e.nextHistoryIdx] end := mod.StartRune + utf8.RuneCountInString(mod.ReverseContent) @@ -840,6 +885,7 @@ func (e *Editor) redo() { caretEnd := mod.StartRune + utf8.RuneCountInString(mod.ApplyContent) e.SetCaret(caretEnd, mod.StartRune) e.nextHistoryIdx++ + return ChangeEvent{}, true } // 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. // Absolute values greater than one will delete that many words. // The selection counts as a single word. -func (e *Editor) deleteWord(distance int) { +func (e *Editor) deleteWord(distance int) (deletedRunes int) { if distance == 0 { return } start, end := e.text.Selection() if start != end { - e.Delete(1) + deletedRunes = e.Delete(1) distance -= sign(distance) } if distance == 0 { - return + return deletedRunes } // split the distance information into constituent parts to be @@ -974,7 +1020,8 @@ func (e *Editor) deleteWord(distance int) { runes += 1 } } - e.Delete(runes * direction) + deletedRunes += e.Delete(runes * direction) + return deletedRunes } // SelectionLen returns the length of the selection, in runes; it is diff --git a/widget/editor_test.go b/widget/editor_test.go index 7021b97a..56e3716d 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -905,7 +905,6 @@ g 2 4 6 8 g gtx.Execute(key.FocusCmd{Tag: e}) // Layout once with no events; populate e.lines. e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) - e.Events() // throw away any events from this layout r.Frame(gtx.Ops) gtx.Source = r.Source() @@ -929,14 +928,13 @@ g 2 4 6 8 g ) tim += time.Second // Avoid multi-clicks. - e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{}) - for _, evt := range e.Events() { - switch evt.(type) { - case SelectEvent: - return e.SelectedText() + for { + _, ok := e.Update(gtx) // throw away any events from this layout + if !ok { + break } } - return "" + return e.SelectedText() } type screenPos image.Point 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( 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 { t.Errorf("editor failed to filter newline") } - got := e.Events() want := []EditorEvent{ ChangeEvent{}, SubmitEvent{Text: e.Text()},