~eliasnaur/gio

c645c2ec8e19ae3395c3fc168c10a8ed86dd2bd6 — Chris Waldon 4 months ago 95ca7b5
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>
2 files changed, 196 insertions(+), 145 deletions(-)

M widget/editor.go
M widget/editor_test.go
M widget/editor.go => widget/editor.go +183 -136
@@ 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
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
			}
		}
	}()

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

	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 combinedEvents
	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

M widget/editor_test.go => widget/editor_test.go +13 -9
@@ 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()},