Pad/internal/ui/render.go
Greg Pomerantz cb8ebc003f IME: force a re-syncing snippet re-push when the IME desynchronizes
After the selection-replacement autocorrect on the phone, Gboard's
local text was out of sync with ours and it re-sent the same empty
fix-up commit in an endless loop (~one per 150 ms, each drift-snapped
to the caret and applied as a no-op). The file was never damaged, but
the IME never converged because it kept 'fixing' text that did not
exist in its own model.

The app cannot see the IME's model; the only recovery the IME
contract offers is a restartInput, which makes it re-fetch the
real text and selection around the caret. Arm that recovery
automatically: three consecutive anomalous commits (drift-snapped,
or empty text) set IMEForceResync, and the next frame ships the
snippet trimmed by one rune, which changes the pushed text and
forces the restart. The streak resets on any normal commit, so
isolated anomalies never trigger it, and the resync is one-shot.

TestRealFile_IMEForceResync pins the arm/reset/consume cycle.
2026-09-13 18:23:06 -04:00

1618 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package ui
import (
"bytes"
"embed"
"image"
"image/color"
_ "image/png"
"sort"
"time"
"unicode/utf8"
"gioui.org/f32"
"gioui.org/gesture"
"gioui.org/io/event" // Import event package
"gioui.org/io/input"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"golang.org/x/image/math/fixed"
)
// maxInt32 is a large value used as MaxWidth for single-line text layout.
const maxInt32 = 1<<31 - 1
//go:embed icons/*.png
var iconFS embed.FS
// clickReg pairs a gesture.Click with its handler.
type clickReg struct {
click *gesture.Click
handler func(any)
// pressAt/pressPos record the current press (set on KindPress) so the
// per-frame long-press check knows how long the finger has been still.
pressAt time.Time
pressPos image.Point
longFired bool
}
// longPressDuration is how long a still press must hold before a long-press
// fires (Android uses ~500ms; 400ms feels snappier for text selection).
const longPressDuration = 400 * time.Millisecond
// longPressSlopPx is how far the finger may drift (px) before a pending
// long press is cancelled (that motion becomes a scroll/drag instead).
const longPressSlopPx = 8
// keyReg pairs a handler for key events.
type keyReg struct {
Handler func(any)
}
// scrollReg pairs a gesture.Scroll with its handler.
type scrollReg struct {
scroll *gesture.Scroll
handler func(any)
}
// Probe tags for the raw-pointer probes (long-press, pinch). Each probe
// needs its OWN named type: the unnamed fieldless struct{} is a single
// canonical Go type, so distinct `struct{}` fields are the SAME value.
// Gio's router keys handlers by the tag value, so two `struct{}` probes
// collapse into one handler and whichever probe drains first (the press
// probe, which is consumed before the pinch probe) consumes every event,
// starving the other — this is why pinch received nothing on device while
// long-press appeared to work.
type pressProbeTag struct{}
type pinchProbeTag struct{}
// Renderer consumes a slice of elements and draws them.
//
// The Renderer is owned by the main goroutine. It is the home of any state
// that Gio mutates during draw (e.g. the search bar's widget.Editor): such
// state must not live in the logic goroutine's State (architecture.md §1).
type Renderer struct {
theme Theme
shp *text.Shaper
scale float32 // px-per-Dp for the current draw pass; set in Draw
icons map[string]image.Image
clicks map[string]*clickReg
Keys map[string]keyReg // Exported Keys map
scrolls map[string]scrollReg
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
displayLineCount int // number of display lines from last drawWrappedText
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText
// ZeroWheelScroll, set by main on a frame whose window size shrank, makes
// CheckGestures drain any pointer.Scroll events queued for the editor's
// scroll gesture before consuming gestures. Gio's window calls RevealFocus
// on any frame the viewport shrinks (e.g. the IME opening under
// adjustResize) and synthesizes a pointer.Scroll nudge to bring the focused
// field's (stale, pre-resize) bounds into view; consumed by gesture.Scroll
// that nudge shifts the editor content. The drain kills only the
// synthesized event: finger scroll (pointer.Drag) and the flinger are
// untouched, and normal frames consume pointer.Scroll as before.
ZeroWheelScroll bool
// IME dedup (main-owned, persistent across frames). Re-pushing an unchanged
// snippet or selection every frame resets the IME's composition and caret,
// which desyncs fast commits; push only on change, as widget.Editor does in
// updateSnippet and its selection gating. Keyed to the focused field's ID so
// it stays correct if a second TextField is ever added.
lastIMEField string
lastWasFocused bool
lastSnippet key.Snippet
lastSelStart int // absolute rune index of last-pushed selection start; -1 = no selection
lastSelCaret int // absolute rune index of last-pushed selection end/caret
lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true}
// IME model (main-owned, persistent across frames). The renderer keeps a
// local copy of the snippet the IME currently holds and updates it in
// place as IME edits arrive (ApplyIMEEdit / ApplyIMEKey, called from the
// key-event drain), so the snippet pushed each frame (FlushIME) is
// byte-identical to the IME's own post-commit snippet. Gioui calls
// imm.restartInput() whenever the pushed snippet differs from the IME's
// internal state (app/os_android.go EditorStateChanged); a restart makes
// the keyboard (Gboard) re-derive its auto-cap state from scratch, which
// is the root cause of the random mid-word capitals. The model is flushed
// AFTER the key events of the frame were drained and BEFORE e.Frame
// flushes the ops, so the commit frame pushes the post-edit text instead
// of the pre-edit frame text (which is what desynced the old push-from-
// Draw path and restarted the IME on every keystroke).
// Caret geometry drawn during the most recent frame, in view-local
// pixels (imeCaretPos is the caret/baseline intersection, per key.Caret).
// FlushIME attaches it to the pushed SelectionCmd: gioui only calls
// GioView.updateCaret (-> imm.updateCursorAnchorInfo, the update that
// actually reaches the IME and makes Gboard re-evaluate its auto-cap
// state) when the SelectionCmd's Caret changes. A zero caret never
// changes, so Gboard never saw a caret move and kept its stale
// auto-shift after the caret jumped (e.g. from a line start, where
// caps are legitimately on, to mid-word).
// lastIMECaretPos is the caret last shipped in a SelectionCmd.
imeCaretPos f32.Point
imeCaretAscent float32
imeCaretDescent float32
lastIMECaretPos f32.Point
// imeHold: a commit was applied to the pushed model (see
// ApplyIMECommitToModel) but the logic's post-commit frame has not been
// drawn yet. The frame(s) drawn in the gap are pre-commit; their
// snippets would clobber the model with the stale text, regressing
// gioui's editor state and causing a restart per keystroke. While the
// hold is active, FlushIME skips a frame whose EditSeq equals the
// pre-commit sequence and resumes on the next one.
imeHoldActive bool
imeHoldEditSeq uint64
imeHoldFrames int
// lastEditSeq: the EditSeq of the last editor frame drawn (the
// pre-commit sequence when an IME commit is in flight).
lastEditSeq int64
// FocusCmd dedup (main-owned, persistent across frames). key.FocusCmd is
// issued ONLY on a focus transition, never per frame: even a no-op FocusCmd
// (same focus) takes the router's "immediate command" path, which re-queues
// the frame's pending pointer events and re-delivers every touch event to
// every gesture. During a selection-handle drag that replayed each event
// several times per frame, making the selection unusable. The key queue
// keeps the focus until the handler stops registering as focusable, so one
// command per transition is sufficient.
lastFocusCmdID string
// focusSeenThisFrame is reset at the start of each Draw; if no focused
// TextField was drawn in the frame, lastFocusCmdID is cleared so a field
// regaining focus later re-issues the command.
focusSeenThisFrame bool
// Long-press detection. pressProbe is a plain event tag observing raw
// pointer events inside the editor text region: gesture.Click reports
// nothing until release, so a long press (finger held still for
// longPressDuration) can only be detected this way. ppMoved cancels the
// pending long press once the finger leaves longPressSlopPx (it is a
// scroll/drag then, not a press). longPressID gates the long-press to the
// editor's click reg (browser rows etc. don't long-press). ppLast is in
// f32.Point because pointer.Event.Position is window-space f32.
pressProbe pressProbeTag
ppLast f32.Point
ppActive bool
ppMoved bool
longPressID string
// Pinch-to-change-font-size (editor text region). Gio v0.10 has no
// two-finger pinch primitive: pinchProbe is a raw event tag inside the
// editor clip, and pinchT (pinch_tracker.go) is the gesture's state
// machine. When two fresh fingers are down the tracker names them as an
// EXPLICIT pair and the adapter grabs both (pointer.GrabCmd): exclusive
// event delivery (releases always arrive, even off-clip; scroll/click
// are dropped with a Cancel, which also stops the first finger dragging
// the text mid-pinch). The per-frame factor is the pair-distance ratio
// (FontPinchEvent to pinchHandler). The pair is never re-derived from
// whatever pointers happen to be present: that re-derivation (two
// lowest IDs) let a resting third finger pair with a live one and made
// single-finger scrolls scale the font on the phone. When one pair
// finger lifts, the survivor stays grabbed (v0.10 has no release-grab)
// and its drags are forwarded as scroll via pinchScrollHandler. State
// is dropped when the editor leaves the screen (see Draw).
pinchProbe pinchProbeTag
pinchT pinchTracker
pinchHandler func(any)
pinchScrollHandler func(any)
pinchProbeOn bool
// appFontScale is the app-local font-size multiplier (pinch zoom;
// 1.0 = default, 0 = not set yet). The main goroutine feeds it from the
// frame's snapshot via SetAppFontScale before Draw; drawWrappedText
// multiplies the editor font size by it. The system user font scale is
// separate and already folded into gtx.Metric/gtx.Sp.
appFontScale float32
// Selection / caret drag handles (0 = start, 1 = end, 2 = body, 3 = caret
// handle). Registered clipped in drawWrappedText only while a selection or
// caret handle is visible; a gesture.Drag grabs the pointer once movement
// exceeds slop, which cancels the scroll and click handlers so a handle
// drag never fights a fling. selDragEmitting tracks whether a Drag event
// was delivered for the current gesture (so a plain tap on a handle does
// not emit a spurious SelectionDragEnd).
selDragStart gesture.Drag
selDragEnd gesture.Drag
selDragBody gesture.Drag
selDragCaret gesture.Drag
selDragsOn [4]bool
selDragEmitting [4]bool
selDragHandler func(any)
// selDraggingWhich is which handle drag (0-3) is currently in flight
// (-1 = none), set by CheckGestures and read by drawWrappedText to
// enlarge the grabbed handle, as the framework does while dragging.
selDraggingWhich int
// selDragActive reports that a selection/caret drag is in progress.
// TextField.Draw skips the key.SelectionCmd IME sync while it is set:
// the command triggers the router's immediate-command path, which
// re-queues the frame's pointer events and replays every drag event
// into the gestures (a replay storm — each re-queued event lands in
// q.changes and is re-queued again on the next SelectionCmd). The IME
// only needs the final selection, pushed on the first frame after the
// drag ends.
selDragActive bool
// gestureExclusions holds the selection-handle grab boxes (view-local
// px, [x0, y0, x1, y1]) collected by drawWrappedText for the current
// frame. On Android the main loop forwards them to
// View.setSystemGestureExclusionRects (API 29+) so drags starting on an
// edge handle are not stolen by the system back gesture.
gestureExclusions [][4]int
}
// SetAppFontScale sets the app-local font-size multiplier for subsequent
// draw passes (1.0 = default; <= 0 is treated as 1). Main-goroutine-only;
// call before Draw (see appFontScale).
func (r *Renderer) SetAppFontScale(v float32) {
if v > 0 {
r.appFontScale = v
} else {
r.appFontScale = 1
}
}
// GestureExclusions returns the handle grab boxes collected for the last
// frame (see gestureExclusions).
func (r *Renderer) GestureExclusions() [][4]int { return r.gestureExclusions }
// pointInHandleBox reports whether the window-pixel point lies inside one of
// the last frame's selection/caret handle grab boxes (gestureExclusions holds
// those boxes, clipped to the editor region).
func (r *Renderer) pointInHandleBox(p image.Point) bool {
for _, b := range r.gestureExclusions {
// Boxes are [x0, y0, x1, y1].
if p.X >= b[0] && p.X < b[2] && p.Y >= b[1] && p.Y < b[3] {
return true
}
}
return false
}
// ApplyIMECommitToModel applies a drained IME commit to the pushed snippet
// model and re-pushes it IMMEDIATELY, on the drain pass. This is required
// because gioui's EditEvent callback applies the same commit to its own
// window state (w.imeState) directly, bypassing the op queue; the op-queue
// state then lags w.imeState by exactly this commit, and gioui's
// per-frame comparison (updateState) sees the mismatch as a SNIPPET
// REGRESSION — it rolls the state back and sends a restartInput with the
// PRE-commit text on every single keystroke. Gboard resets its per-word
// input session on that restart (and it never queries the app for
// context), which is the source of the random mid-word capitalization.
// Pushing the post-commit snippet here makes the op queue match w.imeState
// before the next frame: the comparison is a no-op, no restart is sent,
// and Gboard's session (auto-shift, composition) survives the keystroke.
// The logic goroutine applies the same commit to the buffer asynchronously;
// when its frame arrives, FlushIME sees the snippet unchanged (already
// pushed) and does nothing. If the logic SNAPS the commit to a different
// position (drift guard), the post-commit frame's snippet differs from the
// model and one resyncing restart is pushed then — correct.
func (r *Renderer) ApplyIMECommitToModel(gtx layout.Context, ev key.EditEvent) {
sn := r.lastSnippet
local := ev.Range.Start - sn.Range.Start
total := utf8.RuneCountInString(sn.Text)
if local < 0 || local > total || ev.Range.End-sn.Range.Start > total {
return // commit outside the pushed window; the next frame resyncs
}
// Replace [local, local+repl) runes with ev.Text (repl is the commit's
// own replaced span — 0 for a plain insertion).
rs := []rune(sn.Text)
repl := ev.Range.End - ev.Range.Start
newRs := make([]rune, 0, len(rs)+len([]rune(ev.Text)))
newRs = append(newRs, rs[:local]...)
newRs = append(newRs, []rune(ev.Text)...)
if local+repl < len(rs) {
newRs = append(newRs, rs[local+repl:]...)
}
newSnip := key.Snippet{
Range: key.Range{
Start: sn.Range.Start,
End: sn.Range.End + len([]rune(ev.Text)) - repl,
},
Text: string(newRs),
}
r.lastSnippet = newSnip
gtx.Execute(key.SnippetCmd{Tag: "editor_text", Snippet: newSnip})
// The caret is at the end of the inserted text: push the selection so
// the op queue's selection matches w.imeState (the EditEvent's Replace
// moved it there). The px caret position is the current one (the next
// frame corrects it); gioui's updateCaret is cosmetic for the IME.
// Hold the pre-commit frame(s) out of FlushIME until the post-commit
// frame arrives (they would clobber the model just pushed).
r.imeHoldActive = true
r.imeHoldEditSeq = uint64(r.lastEditSeq)
r.imeHoldFrames = 0
caret := ev.Range.End + utf8.RuneCountInString(ev.Text)
r.lastSelStart, r.lastSelCaret = -1, caret
r.lastIMECaretPos = r.imeCaretPos
gtx.Execute(key.SelectionCmd{
Tag: "editor_text",
Range: key.Range{Start: caret, End: caret},
Caret: key.Caret{
Pos: r.imeCaretPos,
Ascent: r.imeCaretAscent,
Descent: r.imeCaretDescent,
},
})
}
func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) {
// An IME commit is in flight (see ApplyIMECommitToModel): a pre-commit
// frame must not push its stale snippet and regress gioui's state.
if r.imeHoldActive {
if tf.EditSeq == r.imeHoldEditSeq {
// Bounded so a no-op commit (no EditSeq advance) cannot stick
// the hold: after a few frames resume normal flushing.
if r.imeHoldFrames++; r.imeHoldFrames > 8 {
r.imeHoldActive = false
} else {
return
}
}
r.imeHoldActive = false
}
if tf.EditSeq > 0 {
r.lastEditSeq = int64(tf.EditSeq)
}
// The snippet is the hysteresis window around the caret (see
// State.computeIMESnippetWindow), shipped precomputed by the logic:
// stable across render-window moves (keyboard show/hide, tap
// re-centering, scrolls), so those events do NOT re-push the snippet —
// a re-push reaches Gboard as imm.restartInput, which starts a fresh
// input session that capitalizes the first committed character even
// mid-word. It re-anchors only when the caret leaves the window
// margins (far tap, fling past the window, file switch), and on edits
// (whose text ApplyIMECommitToModel has already pushed, so the frame's
// copy dedupes away).
snippet := key.Snippet{
Range: key.Range{
Start: tf.IMESnippetStartRune,
End: tf.IMESnippetEndRune,
},
Text: tf.IMESnippetText,
}
if tf.IMEForceResync && utf8.RuneCountInString(tf.IMESnippetText) > 2 {
// The IME's local text has desynchronized (several consecutive
// drift-snapped or empty commits — see EditorState.IMEForceResync)
// and it keeps re-sending the same fix in a loop. Pushing the
// window trimmed by one rune forces a snippet change (and thus a
// restartInput): the IME re-fetches the real text and selection
// around the caret and its model converges. The next frame pushes
// the full window again (one more restart), then everything is
// quiet again.
rs := []rune(tf.IMESnippetText)
snippet = key.Snippet{
Range: key.Range{
Start: tf.IMESnippetStartRune + 1,
End: tf.IMESnippetEndRune,
},
Text: string(rs[1:]),
}
}
if snippet != r.lastSnippet {
r.lastSnippet = snippet
// A snippet change resets the IME's selection: force the selection
// re-push below so the caret is re-anchored in the new text in the
// same frame. (A drained IME commit is applied to the model BEFORE
// this runs — see ApplyIMECommitToModel — so a normal keystroke
// never reaches this branch; it is the re-anchoring path: scroll,
// tap, file switch, snapped commit, external edit.)
r.lastSelStart, r.lastSelCaret = -1, -1
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet})
}
// Push the caret/selection (deduped) in absolute file runes, the same
// coordinate space as the snippet (see widget.Editor's updateIMEState).
var selStart, selEnd int
if tf.IMESelStartRune >= 0 && tf.IMESelEndRune > tf.IMESelStartRune {
selStart, selEnd = tf.IMESelStartRune, tf.IMESelEndRune
} else {
selStart = -1
selEnd = tf.IMECaretRune
}
selectionJumped := selStart != r.lastSelStart || selEnd != r.lastSelCaret
caretMoved := r.imeCaretPos != r.lastIMECaretPos
if selectionJumped || caretMoved {
// Skip while a selection/caret handle drag is in flight (see
// Renderer.selDragActive): a mid-gesture updateSelection makes the
// IME's selection jump and aborts the gesture. key.SelDragActive is
// stale here (renderer-owned), so use the renderer's own flag.
if !r.selDragActive {
r.lastSelStart = selStart
r.lastSelCaret = selEnd
r.lastIMECaretPos = r.imeCaretPos
rng := key.Range{Start: selStart, End: selEnd}
if selStart < 0 {
rng = key.Range{Start: selEnd, End: selEnd}
}
// The SelectionCmd must reach the IME on every caret jump: it is
// the signal (GioView.updateSelection -> imm.updateSelection,
// delivered as onUpdateSelection) that makes Gboard re-derive
// its auto-shift state from the new position — mid-word: off;
// line/sentence start: on. It must NOT be shadowed by a snippet
// push in the same frame: a snippet change makes gioui's
// EditorStateChanged take the restartInput branch instead (which
// Gboard does not use to re-derive caps), so a caret jump with an
// unchanged snippet is exactly the case that works.
// The Caret field carries the real position (view-local px, Pos
// on the baseline) so gioui also fires updateCaret
// (imm.updateCursorAnchorInfo) for the jump.
gtx.Execute(key.SelectionCmd{
Tag: tf.id,
Range: rng,
Caret: key.Caret{
Pos: r.imeCaretPos,
Ascent: r.imeCaretAscent,
Descent: r.imeCaretDescent,
},
})
}
}
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer {
r := &Renderer{
theme: th,
shp: shp,
icons: make(map[string]image.Image),
clicks: make(map[string]*clickReg),
Keys: make(map[string]keyReg),
scrolls: make(map[string]scrollReg),
gioEditors: make(map[string]*widget.Editor),
selDraggingWhich: -1,
}
r.loadIcons()
return r
}
// RegisterGioEditor attaches a main-owned widget.Editor to an element ID.
// GioEditor elements with that ID render the widget during draw. Must be
// called from the main goroutine before the first frame.
func (r *Renderer) RegisterGioEditor(id string, ed *widget.Editor) {
r.gioEditors[id] = ed
}
// GioEditor returns the main-owned widget editor registered for id, if any.
func (r *Renderer) GioEditor(id string) (*widget.Editor, bool) {
ed, ok := r.gioEditors[id]
return ed, ok
}
// loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() {
for _, name := range []string{"back", "cut", "copy", "paste", "search", "close", "chevron_up", "chevron_down"} {
data, err := iconFS.ReadFile("icons/" + name + ".png")
if err != nil {
continue
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
continue
}
r.icons[name] = img
}
}
// icon returns a loaded icon image by name, or nil if not found.
func (r *Renderer) icon(name string) image.Image {
return r.icons[name]
}
// toPx converts Dp to physical pixels using State's scale.
func (r *Renderer) toPx(dp Dp) Px {
return ToPx(dp, r.scale)
}
// toDp converts physical pixels to Dp using State's scale.
func (r *Renderer) toDp(px Px) Dp {
return ToDp(px, r.scale)
}
// Draw iterates elements and draws each in slice order (back-to-front).
// Draw renders the given elements. scale is the px-per-Dp factor for this
// draw pass, taken from the frame's view-state snapshot (the renderer never
// reads logic state directly).
func (r *Renderer) Draw(gtx layout.Context, elems []Element, scale float32) {
r.scale = scale
r.focusSeenThisFrame = false // per-frame reset for FocusCmd dedup
if !r.pinchProbeOn {
// No editor text in the previous frame: drop all pinch state so a
// later pinch starts clean.
r.pinchT.reset()
}
r.pinchProbeOn = false
// Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min.
winW := gtx.Constraints.Max.X
winH := gtx.Constraints.Max.Y
clipRect := clip.Rect{
Min: image.Point{X: 0, Y: 0},
Max: image.Point{X: winW, Y: winH},
}.Push(gtx.Ops)
for _, e := range elems {
if !e.Visible() {
continue
}
r.drawElement(gtx, e)
}
// If no focused TextField was drawn this frame, the key queue will drop
// the focus (the focused handler stops registering as focusable). Clear the
// dedup so the same field re-issues key.FocusCmd when it regains focus.
if !r.focusSeenThisFrame {
r.lastFocusCmdID = ""
}
clipRect.Pop()
}
// registerInteraction registers a gesture for an element.
// Supports Tap and Scroll.
// The clip context must already be set to the element's bounds before calling this.
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context) {
switch interaction.Gesture {
case Tap:
reg, ok := r.clicks[id]
if !ok {
reg = &clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
}
reg.handler = interaction.Handler
// Register click within current clip context
reg.click.Add(gtx.Ops)
case Scroll:
reg, ok := r.scrolls[id]
if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}}
}
// Register scroll within current clip context
reg.scroll.Add(gtx.Ops)
reg.handler = interaction.Handler
r.scrolls[id] = reg
case SelDrag:
// The renderer registers the actual gesture.Drag ops in
// drawWrappedText (it owns the handle geometry); this only records the
// logic handler that receives the drag events.
r.selDragHandler = interaction.Handler
}
}
// RegisterClick registers a click gesture for a rectangular region.
// Used by ListView to register per-row click areas. The click.Add() call
// is made within a clip so only the region is clickable.
func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) {
reg, ok := r.clicks[id]
if !ok {
reg = &clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
}
reg.handler = handler
// Clip to the specified region for click area
clickClip := clip.Rect{
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))},
}.Push(gtx.Ops)
reg.click.Add(gtx.Ops)
clickClip.Pop()
}
// RegisterScroll registers a scroll gesture for a rectangular region.
// Should be called while the element's clip is active (e.g., inside a container
// or clippable element's Draw method).
func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) {
reg, ok := r.scrolls[id]
if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}}
}
reg.handler = handler
r.scrolls[id] = reg
reg.scroll.Add(gtx.Ops)
}
// PendingLongPress reports whether a press is currently held still on the
// editor (long-press armed but not yet fired). Gio renders on demand: with a
// stationary finger there are no pointer events, hence no frames, and the
// 400 ms threshold could never be checked. The main loop calls this and
// invalidates the window while it is true, keeping frames flowing until the
// long press fires or the finger moves up.
func (r *Renderer) PendingLongPress() bool {
reg, ok := r.clicks[r.longPressID]
return ok && reg.click.Pressed() && !reg.longFired && !r.ppMoved && !reg.pressAt.IsZero()
}
// CheckGestures checks all registered gestures and returns any events.
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
var events []InputEvent
// Long-press motion probe first: it must see the press/drag events before
// the click loop decides about a long press.
r.consumePressProbe(q)
// Pinch probe: drain the raw pointer events, grab/release the pair,
// and emit the frame's scale factor and any survivor-finger scroll.
// Runs before the click/scroll loops so the pinch is applied in the
// same input batch that carried the finger moves, and the pair's
// grabs are queued ahead of any competing scroll grab.
events = append(events, r.consumePinchProbe(q)...)
for id, reg := range r.clicks {
// Drain every queued event for this gesture in this frame.
// gesture.Click returns one event per Update call, but on Android a
// tap's press and release routinely arrive in the same frame. Without
// draining, the release would sit unprocessed until the next redraw —
// which on an idle window may never come — and the tap is swallowed.
for {
evt, ok := reg.click.Update(q)
if !ok {
break
}
switch evt.Kind {
case gesture.KindPress:
reg.pressAt = time.Now()
reg.pressPos = evt.Position
reg.longFired = false
case gesture.KindClick:
if reg.longFired {
break // the press was consumed as a long press
}
if r.pointInHandleBox(evt.Position) {
// A tap inside a handle grab box is a handle touch that
// never reached the drag slop (or a deliberate light
// touch on the handle): it must be a no-op, not a text
// tap. Forwarding it would clear the selection and move
// the caret, so a light touch on a handle destroyed the
// selection. (Native Android: tapping a handle does
// nothing.)
break
}
if evt.NumClicks >= 2 {
events = append(events, InputEvent{
Handler: reg.handler,
Data: DoubleTapPoint{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
})
} else {
events = append(events, InputEvent{
Handler: reg.handler,
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
})
}
case gesture.KindCancel:
reg.pressAt = time.Time{}
reg.longFired = false
}
}
// Long press: the finger must still be down on the probed (editor)
// region, held still, for the long-press duration.
if id == r.longPressID && reg.click.Pressed() && !reg.longFired &&
!r.ppMoved && !reg.pressAt.IsZero() && time.Since(reg.pressAt) >= longPressDuration {
reg.longFired = true
events = append(events, InputEvent{
Handler: reg.handler,
Data: LongPressPoint{X: r.toDp(Px(reg.pressPos.X)), Y: r.toDp(Px(reg.pressPos.Y))},
})
}
}
// Selection / caret drags before scroll: a handle grab must win over fling.
drags := [4]*gesture.Drag{&r.selDragStart, &r.selDragEnd, &r.selDragBody, &r.selDragCaret}
for which, d := range drags {
if !r.selDragsOn[which] || r.selDragHandler == nil {
continue
}
// wasDragging is captured before Update: Update resets Dragging() on
// Release/Cancel, so it would read false afterwards.
wasDragging := d.Dragging()
// Drain all queued events for this drag in this frame (same
// one-event-per-Update rationale as the click loop above).
for {
e, ok := d.Update(m, q, gesture.Both)
if !ok {
break
}
switch e.Kind {
case pointer.Drag:
// Forward only grabbed events: gesture.Drag also returns the
// pre-grab (Shared priority) moves, and acting on those would
// start moving the selection at the press position — the grab
// jitter. The first Grabbed event lands just past the touch slop,
// matching native Android, where the handle follows only after
// the slop.
if e.Priority != pointer.Grabbed {
continue
}
r.selDragEmitting[which] = true
r.selDraggingWhich = which
r.selDragActive = true
events = append(events, InputEvent{
Handler: r.selDragHandler,
Data: SelectionDragEvent{Which: which, X: r.toDp(Px(e.Position.X)), Y: r.toDp(Px(e.Position.Y))},
})
case pointer.Release, pointer.Cancel:
if wasDragging && r.selDragEmitting[which] {
events = append(events, InputEvent{
Handler: r.selDragHandler,
Data: SelectionDragEnd{},
})
}
r.selDragEmitting[which] = false
if r.selDraggingWhich == which {
r.selDraggingWhich = -1
}
}
}
}
// Safety net: if no gesture is still dragging but a selection/caret drag
// was active (e.g. the release was never delivered), clear the flag so the
// IME selection sync (key.SelectionCmd) resumes — see TextField.Draw.
if r.selDragActive {
stillDragging := false
for _, d := range drags {
if d.Dragging() {
stillDragging = true
break
}
}
if !stillDragging {
r.selDragActive = false
}
}
for _, reg := range r.scrolls {
// gesture.Scroll.Update returns scroll delta in pixels.
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
// With Min==Max==0, clampSplit consumes zero scroll.
// Update runs unconditionally (it keeps the gesture's flinger state
// healthy). Emission is suppressed while a pinch owns the pair: the
// pair is grabbed (scroll is dropped from its path) once the grabs
// commit, and this guard covers the one-frame window before that.
if r.ZeroWheelScroll {
// RevealFocus (see ZeroWheelScroll) queued a synthetic
// pointer.Scroll on this shrink frame; consume it here so the
// gesture never sees it. Scroll-range clamping is no use: the
// router UNIONs ranges across frames and the historical max
// can never shrink back to zero.
for {
if _, ok := q.Event(pointer.Filter{Target: reg.scroll, Kinds: pointer.Scroll}); !ok {
break
}
}
}
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
if delta != 0 && !r.pinchT.on {
events = append(events, InputEvent{
Handler: reg.handler,
Data: delta,
})
}
}
return events
}
// consumePinchProbe drains the pinch probe's raw pointer events into the
// tracker, issues the pair's grabs, and returns this frame's events: the
// scale factor (when the active pair moved) and the survivor finger's
// forwarded scroll (when a pair broke with one finger still down).
func (r *Renderer) consumePinchProbe(q input.Source) []InputEvent {
var events []InputEvent
for {
evt, ok := q.Event(pointer.Filter{Target: r.pinchProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel | pointer.Leave})
if !ok {
break
}
pe, ok := evt.(pointer.Event)
if !ok {
continue
}
if s := r.pinchT.step(pe); len(s.grabs) > 0 {
for _, id := range s.grabs {
q.Execute(pointer.GrabCmd{Tag: r.pinchProbe, ID: id})
}
}
}
// The tracker may have formed the pair during factor() (after the full
// frame's events); issue those grabs now. Either way they commit
// before any scroll grab queued later in this frame (FIFO command
// queue), so the pair wins the race even if a finger is already past
// the scroll slop.
f, mid, ok, grabs := r.pinchT.factor()
for _, id := range grabs {
q.Execute(pointer.GrabCmd{Tag: r.pinchProbe, ID: id})
}
if ok && r.pinchHandler != nil {
events = append(events, InputEvent{
Handler: r.pinchHandler,
Data: FontPinchEvent{
Scale: f,
Center: Point{X: r.toDp(Px(mid.X)), Y: r.toDp(Px(mid.Y))},
},
})
}
if d := r.pinchT.survivorScroll(); d != 0 && r.pinchScrollHandler != nil {
events = append(events, InputEvent{
Handler: r.pinchScrollHandler,
Data: d,
})
}
return events
}
// consumePressProbe drains the raw pointer events of the long-press probe
// and updates ppLast/ppMoved. It runs before the click loop each frame.
// Leave ends the pending press: a finger that drifts off the editor region
// must not keep a long press armed (its release would never come to the
// probe if it was grabbed by scroll, so without this the timer could fire
// for a finger that is long gone).
func (r *Renderer) consumePressProbe(q input.Source) {
for {
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel | pointer.Leave})
if !ok {
return
}
pe, ok := evt.(pointer.Event)
if !ok {
continue
}
switch pe.Kind {
case pointer.Press:
r.ppLast = pe.Position
r.ppActive = true
r.ppMoved = false
case pointer.Drag:
if r.ppActive {
dx, dy := pe.Position.X-r.ppLast.X, pe.Position.Y-r.ppLast.Y
if dx*dx+dy*dy > float32(longPressSlopPx*longPressSlopPx) {
r.ppMoved = true
}
}
r.ppLast = pe.Position
case pointer.Release, pointer.Cancel, pointer.Leave:
r.ppMoved = false
r.ppActive = false
}
}
}
// DisplayLineCount returns the number of display lines from the last
// drawWrappedText call. Used by the main loop to report back to logic.
func (r *Renderer) DisplayLineCount() int {
return r.displayLineCount
}
// LastLineY returns the last line baseline offset from the text origin, in Dp.
// Used by logic to compute max scroll without off-by-one errors.
func (r *Renderer) LastLineY() Dp {
return r.lastLineY
}
// GlyphLayout returns the glyph layout data captured during the last
// drawWrappedText call. Used by the logic goroutine to position the cursor
// and navigate by glyph instead of byte offset.
func (r *Renderer) GlyphLayout() GlyphLayout {
return r.glyphLayout
}
// clippableElement is implemented by elements that need their own clip region
// around all their content and interaction registrations.
type clippableElement interface {
NeedsClip() bool
}
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
reg := e.Region()
if container, ok := e.(Container); ok {
// Clip to container bounds, draw background, then offset children
clipRect := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
e.Draw(gtx, r) // draw container background
offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops)
for _, child := range container.Children {
r.drawElement(gtx, child)
}
offset.Pop()
clipRect.Pop()
} else if clippable, ok := e.(clippableElement); ok && clippable.NeedsClip() {
// Clip to element bounds before registering interactions and drawing.
// event.Op for key events must be within the clip so Gio routes events
// to this element's tag.
clipRect := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
// Register interactions inside the clip so event.Op is scoped to this region.
if interactive, ok := e.(Interactive); ok {
for _, interaction := range interactive.Interactions() {
if interaction.Gesture == KeyDown || interaction.Gesture == KeyUp {
event.Op(gtx.Ops, interactive.ID())
reg := keyReg{Handler: interaction.Handler}
r.Keys[interactive.ID()] = reg
break
}
}
for _, interaction := range interactive.Interactions() {
if interaction.Gesture == Scroll {
reg, ok := r.scrolls[interactive.ID()]
if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}}
}
reg.scroll.Add(gtx.Ops)
reg.handler = interaction.Handler
r.scrolls[interactive.ID()] = reg
}
if interaction.Gesture == Tap {
reg, ok := r.clicks[interactive.ID()]
if !ok {
reg = &clickReg{click: &gesture.Click{}}
r.clicks[interactive.ID()] = reg
}
reg.handler = interaction.Handler
reg.click.Add(gtx.Ops)
}
if interaction.Gesture == SelDrag {
// Store the logic handler; the drag ops themselves are added
// clipped in drawWrappedText where the handle geometry is known.
r.registerInteraction(interactive.ID(), interaction, gtx)
}
if interaction.Gesture == Pinch {
// The renderer owns the probe (raw two-pointer geometry);
// this records the logic handlers. The scroll handler is
// the survivor-finger's forwarding target: after a pinch
// breaks with one finger still down, that finger stays
// grabbed by the probe (v0.10 has no release-grab), so its
// drags are emitted as plain scroll deltas.
r.pinchHandler = interaction.Handler
if reg, ok := r.scrolls[interactive.ID()]; ok {
r.pinchScrollHandler = reg.handler
}
}
}
}
e.Draw(gtx, r)
clipRect.Pop()
} else {
// Leaf element: register click handlers, then draw.
// For text elements, click registration happens inside Draw after shaping.
if interactive, ok := e.(Interactive); ok {
// Register input tag for key events
for _, interaction := range interactive.Interactions() {
if interaction.Gesture == KeyDown || interaction.Gesture == KeyUp {
event.Op(gtx.Ops, interactive.ID())
// Register handler
reg := keyReg{Handler: interaction.Handler}
r.Keys[interactive.ID()] = reg
break
}
}
for _, interaction := range interactive.Interactions() {
// Set up click handler (but don't call Add for text elements)
if interaction.Gesture == Tap {
reg, ok := r.clicks[interactive.ID()]
if !ok {
reg = &clickReg{click: &gesture.Click{}}
r.clicks[interactive.ID()] = reg
}
reg.handler = interaction.Handler
}
// For non-text elements, register click immediately
if _, isLabel := e.(Label); !isLabel {
elemClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
r.registerInteraction(interactive.ID(), interaction, gtx)
elemClip.Pop()
}
}
}
e.Draw(gtx, r)
}
}
func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) {
bgClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()
}
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, align TextAlign, col Color, id string) {
if str == "" {
return
}
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: maxInt32,
MaxLines: 1,
}
// Measure text width via glyph iteration (consumes iterator)
r.shp.LayoutString(params, str)
var totalAdvance fixed.Int26_6
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
totalAdvance += g.Advance
}
textW := Dp(float32(totalAdvance>>6) / r.scale)
// Compute aligned X position
var drawX Dp
switch align {
case AlignStart:
drawX = reg.X
case AlignCenter:
drawX = reg.X + (reg.W-textW)/2
case AlignEnd:
drawX = reg.X + reg.W - textW
}
// Clip to just the text area
textClip := clip.Rect{
Min: image.Point{X: int(r.toPx(drawX)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(drawX + textW)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
// Register click within the text clip if this is an interactive label
if id != "" {
if reg, ok := r.clicks[id]; ok && reg.click != nil {
reg.click.Add(gtx.Ops)
}
}
// Layout again (iterator consumed) and draw
r.shp.LayoutString(params, str)
r.drawLineText(gtx, drawX, reg.Y, col)
textClip.Pop()
}
func (r *Renderer) drawLineText(gtx layout.Context, x, y Dp, col Color) {
m := op.Record(gtx.Ops)
var glyphs [32]text.Glyph
line := glyphs[:0]
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
r.drawLine(gtx, line, x, y, col)
line = line[:0]
}
}
if len(line) > 0 {
r.drawLine(gtx, line, x, y, col)
}
call := m.Stop()
call.Add(gtx.Ops)
}
// drawLine draws a single line of glyphs at the given position.
// Matches Gio's paintGlyph: offset by (x + first.X, y + first.Y).
func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col Color) {
if len(line) == 0 {
return
}
first := line[0]
// Offset: desired document position + first glyph's relative position.
// first.X is in fixed.Int26_6 (divide by 64 for pixels), first.Y is in pixels.
offX := float32(gtx.Dp(unit.Dp(x))) + float32(first.X)/64.0
offY := float32(gtx.Dp(unit.Dp(y))) + float32(first.Y)
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
// Draw vector glyphs
path := r.shp.Shape(line)
outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
outline.Pop()
// Draw bitmap glyphs (emoji, etc.)
if call := r.shp.Bitmaps(line); call != (op.CallOp{}) {
call.Add(gtx.Ops)
}
t.Pop()
}
// drawWrappedText shapes text once with word wrap and draws display lines inline.
// One LayoutString call - no double-shaping. The shaper handles word boundary
// detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing.
// drawRangeHighlight paints one translucent rect per glyph covered by the
// window-relative byte range [start,end), the same per-glyph tiling the
// selection highlight uses, so wrapped lines are covered too.
func (r *Renderer) drawRangeHighlight(gtx layout.Context, layout *GlyphLayout, str string, reg Region, scrollOffset Dp, start, end int, ascent, lineH Dp, c color.NRGBA) {
for i := range layout.ByteOffsets {
b0 := layout.ByteOffsets[i]
b1 := len(str)
if i+1 < len(layout.ByteOffsets) {
b1 = layout.ByteOffsets[i+1]
}
if b0 >= end || b1 <= start {
continue
}
hx := reg.X + layout.X[i]
hy := reg.Y - scrollOffset + layout.Y[i] - ascent
hw := layout.Advance[i]
hh := lineH
rect := clip.Rect{
Min: image.Point{X: int(r.toPx(hx)), Y: int(r.toPx(hy))},
Max: image.Point{X: int(r.toPx(hx + hw)), Y: int(r.toPx(hy + hh))},
}.Op().Push(gtx.Ops)
paint.ColorOp{Color: c}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
rect.Pop()
}
}
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool, matchRanges [][2]int, currentMatch int, focused bool) {
if str == "" {
return
}
r.gestureExclusions = nil // rebuilt from this frame's handle boxes
// App-local font-size multiplier (pinch zoom; SetAppFontScale keeps it
// > 0). It multiplies the sp font size directly, so the value is a
// continuous float — no rounding to whole points anywhere.
appScale := r.appFontScale
if appScale <= 0 {
appScale = 1
}
size := float32(r.theme.FontSize) * appScale
// Fixed line height based on font size, not glyph metrics.
lineHeightSp := unit.Sp(size * LineHeightScale)
// User font-size setting (sp per dp). The shaper draws baselines at
// Sp(...) physical px, so the RENDERED line pitch in density-dp is
// lineHeightSp × fontScale. Every dp-space value below (line height,
// ascent) uses the scaled form so caret/handles/highlight follow the
// drawn glyphs; the logic side tracks the same factor via
// EffectiveLineHeight (ScaleEvent.FontScale × app font scale).
fontScale := float32(1)
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
fontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp
}
ascent := Dp(size * fontScale)
lineH := Dp(float32(lineHeightSp) * fontScale)
// Wrap disabled: shape with unlimited width so lines extend past the
// region (clipped by textClip below) instead of wrapping.
maxWidthPx := maxInt32
if wordWrap {
maxWidthPx = int(r.toPx(wrapWidth))
}
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(unit.Sp(size))),
MinWidth: 0,
MaxWidth: maxWidthPx,
MaxLines: 0, // unlimited - wrap at MaxWidth
LineHeight: fixed.I(gtx.Sp(lineHeightSp)),
LineHeightScale: 1.0, // use LineHeight directly, don't scale
WrapPolicy: text.WrapHeuristically,
}
r.shp.LayoutString(params, str)
// Clip to TextField region so text doesn't spill into status/bottom bars
textClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
// Y position: region top minus scroll offset.
// The shaper's first.Y handles line spacing - each line's first.Y is
// ascent + lineHeight × lineIndex. drawLine adds first.Y to y,
// so passing the same y for all lines gives correct baseline spacing.
y := reg.Y - scrollOffset
col := Color{R: 0, G: 0, B: 0, A: 255} // black text
// Pass 1: collect glyph lines and per-glyph layout data (no drawing yet),
// so the selection highlight can be emitted before the text ops and render
// underneath it.
var lines [][]text.Glyph
var glyphs [32]text.Glyph
line := glyphs[:0]
lineCount := 0
// Capture per-glyph layout data for cursor positioning and navigation.
var layout GlyphLayout
layout.LineHeight = lineH
byteOffset := 0
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
flushLine := func() {
lines = append(lines, append([]text.Glyph(nil), line...))
line = line[:0]
}
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
// Record layout data for this glyph.
// g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp.
// g.Y is the baseline in device pixels.
// The shaper flags the LAST glyph of every visual line with
// FlagLineBreak. For hard lines that is the zero-width "\n" cluster
// glyph (and, after a trailing "\n", one more synthetic end-of-text
// glyph) — not a character, so it is skipped to keep ByteOffsets a 1:1
// map to bytes. For SOFT-wrapped lines (and a final line without a
// trailing newline) the flag instead sits on the line's last visible
// character, which MUST stay in the layout: dropping it would make taps
// on the right half of that character and selection highlights of it
// miss. Zero width is the discriminator (a real glyph always advances).
if g.Flags&text.FlagLineBreak == 0 || g.Advance != 0 {
layout.ByteOffsets = append(layout.ByteOffsets, byteOffset)
layout.X = append(layout.X, Dp(float32(g.X>>6)/r.scale))
layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale))
layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale))
}
// Advance byteOffset by g.Runes.
for i := uint16(0); i < g.Runes; i++ {
_, sz := utf8.DecodeRuneInString(str[byteOffset:])
byteOffset += sz
}
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
flushLine()
if g.Flags&text.FlagLineBreak != 0 {
lineCount++
layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset)
}
}
}
if len(line) > 0 {
flushLine()
lineCount++
}
// Pass 2: highlights, emitted before the text so glyphs draw on top of
// them. In-file search matches first (translucent yellow), then the
// selection (translucent blue), then the CURRENT search match again in a
// stronger orange so it stands out even though the selection also covers
// it.
for i, m := range matchRanges {
if i != currentMatch {
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, m[0], m[1], ascent, lineH,
color.NRGBA{R: 0xFF, G: 0xE2, B: 0x4D, A: 0x66})
}
}
if selStart >= 0 && selEnd > selStart {
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, selStart, selEnd, ascent, lineH,
color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59})
}
if currentMatch >= 0 && currentMatch < len(matchRanges) {
m := matchRanges[currentMatch]
r.drawRangeHighlight(gtx, &layout, str, reg, scrollOffset, m[0], m[1], ascent, lineH,
color.NRGBA{R: 0xFF, G: 0x98, B: 0x00, A: 0x80})
}
// Pass 3: the text itself.
m := op.Record(gtx.Ops)
for _, ln := range lines {
r.drawLine(gtx, ln, reg.X, y, col)
}
call := m.Stop()
call.Add(gtx.Ops)
// caretPoint maps a window-relative byte offset to the insertion
// point's region-relative (x, y) in Dp, where y is the line's baseline
// (see CaretPoint).
caretPoint := func(byteOff int) (x, y Dp) {
return CaretPoint(layout, str, byteOff, ascent, lineH)
}
// Draw the caret only when (a) the field holds key focus and (b) the
// cursor's byte is inside the shaped window (window-relative:
// [0, len(str)]). (a): while another input is focused — the find bar's
// search input is the case that motivated this — a live caret in the
// editor reads as if the editor still had focus; the caret returns when
// focus comes back. (b): the window covers the viewport exactly, so an
// out-of-range cursor is off-screen and its caret must not be drawn; the
// caller used to clamp it to 0, which made the caret jump onto the top
// (or, past the end, the bottom) visible line whenever the user scrolled
// past it. The boundary values are on-screen: 0 is the window's first
// byte and len(str) is the window's last insertion point.
if focused && cursorPos >= 0 && cursorPos <= len(str) {
// Determine cursor position from `layout` and `cursorPos`
caretX, caretY := caretPoint(cursorPos) // y is the line's baseline, region-relative
cursorX := reg.X + caretX
cursorY := reg.Y - scrollOffset + caretY - ascent
// Save the caret geometry (view-local px; Pos on the baseline) for
// FlushIME's SelectionCmd so the IME is told where the caret moved.
// See imeCaretPos.
r.imeCaretPos = f32.Point{
X: float32(r.toPx(cursorX)),
Y: float32(r.toPx(reg.Y - scrollOffset + caretY)),
}
r.imeCaretAscent = float32(r.toPx(ascent))
r.imeCaretDescent = float32(r.toPx(lineH - ascent))
// Draw the cursor (thin vertical bar)
cursorRegion := Region{
X: cursorX,
Y: cursorY,
W: Dp(2),
H: lineH, // line height (font-scale aware)
}
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
}
// Long-press probe and selection/caret drag handles. The probe op and the
// (clipped) drag registrations live in the text clip so they only respond
// inside the editor region.
r.selDragsOn = [4]bool{}
event.Op(gtx.Ops, r.pressProbe)
// Pinch probe: same clip as the press probe, so a pinch only registers
// when both fingers' presses/moves land in the editor text region.
event.Op(gtx.Ops, r.pinchProbe)
r.pinchProbeOn = true
r.longPressID = "editor_text"
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
// handleAt mirrors the cursor computation above: window-relative byte
// offset -> screen Dp of the caret insertion point.
handleAt := func(byteOff int) (x, y Dp) {
hx, hy := caretPoint(byteOff)
return reg.X + hx, reg.Y - scrollOffset + hy - ascent
}
// The visual handle (drawHandle) is a ~20dp teardrop centred at
// (hx, hy+lineH+handleRadius); the GRAB region is a 48dp box around
// that centre — Android's own handles are small but their touch
// targets are not (framework slop + 48dp minimum touch target), and
// 16dp was far too small to grab reliably by finger.
const handleRadius = Dp(10)
registerDrag := func(d *gesture.Drag, hx, hy Dp) {
cy := hy + lineH + handleRadius
const grab = Dp(24) // 48dp box
minx, miny := int(r.toPx(hx-grab)), int(r.toPx(cy-grab))
maxx, maxy := int(r.toPx(hx+grab)), int(r.toPx(cy+grab))
// System-gesture exclusion (Android API 29+): a drag that STARTS
// inside ~20dp of the screen edge can be taken over by the system
// back gesture (predictive back) — it cancels the handle drag and
// navigates the app away. Excluding the grab boxes (clipped to the
// editor region, which is what is actually grabbable) keeps edge
// handles, e.g. the left handle of a line-start selection, usable.
// The main loop forwards these rects to
// View.setSystemGestureExclusionRects (see SetGestureExclusions).
ex0, ey0, ex1, ey1 := minx, miny, maxx, maxy
if ex0 < int(r.toPx(reg.X)) {
ex0 = int(r.toPx(reg.X))
}
if ey0 < int(r.toPx(reg.Y)) {
ey0 = int(r.toPx(reg.Y))
}
if ex1 > int(r.toPx(reg.X+reg.W)) {
ex1 = int(r.toPx(reg.X + reg.W))
}
if ey1 > int(r.toPx(reg.Y+reg.H)) {
ey1 = int(r.toPx(reg.Y + reg.H))
}
if ex1 > ex0 && ey1 > ey0 {
r.gestureExclusions = append(r.gestureExclusions, [4]int{ex0, ey0, ex1, ey1})
}
hc := clip.Rect{
Min: image.Point{X: minx, Y: miny},
Max: image.Point{X: maxx, Y: maxy},
}.Push(gtx.Ops)
d.Add(gtx.Ops)
hc.Pop()
}
if selStart >= 0 && selEnd > selStart {
sx, sy := handleAt(selStart)
ex, ey := handleAt(selEnd)
// Body FIRST, handles after: Gio routes a touch to the TOPMOST op
// whose clip contains the point, and a drag only grabs after it
// received the PRESS. The handle boxes reach up into the text line
// (their centres hang below the line) and the body box spans the
// line, so wherever they overlap the later-registered op wins. The
// handles are the more specific target and must win the overlap;
// registering the body last made line-start handles effectively
// ungrabbable (the body ate the presses).
// Body: bounding box of the selected glyphs (only for 2+ glyphs; a
// single-glyph selection is already covered by its two handles).
var bx0, by0, bx1, by1 Dp
hasGlyph := false
for i := range layout.ByteOffsets {
b0 := layout.ByteOffsets[i]
b1 := len(str)
if i+1 < len(layout.ByteOffsets) {
b1 = layout.ByteOffsets[i+1]
}
if b0 >= selEnd || b1 <= selStart {
continue
}
gx := reg.X + layout.X[i]
gy := reg.Y - scrollOffset + layout.Y[i] - ascent
gw := layout.Advance[i]
gh := lineH
if !hasGlyph {
bx0, by0, bx1, by1 = gx, gy, gx+gw, gy+gh
hasGlyph = true
} else {
if gx < bx0 {
bx0 = gx
}
if gy < by0 {
by0 = gy
}
if gx+gw > bx1 {
bx1 = gx + gw
}
if gy+gh > by1 {
by1 = gy + gh
}
}
}
if hasGlyph {
bc := clip.Rect{
Min: image.Point{X: int(r.toPx(bx0)), Y: int(r.toPx(by0))},
Max: image.Point{X: int(r.toPx(bx1)), Y: int(r.toPx(by1))},
}.Push(gtx.Ops)
r.selDragBody.Add(gtx.Ops)
bc.Pop()
r.selDragsOn[2] = true
}
registerDrag(&r.selDragStart, sx, sy)
r.selDragsOn[0] = true
registerDrag(&r.selDragEnd, ex, ey)
r.selDragsOn[1] = true
r.drawHandle(gtx, sx, sy, lineH, r.selDraggingWhich == 0)
r.drawHandle(gtx, ex, ey, lineH, r.selDraggingWhich == 1)
} else {
// Caret drag (long press on blank space): a single handle on the caret.
cx, cy := handleAt(cursorPos)
registerDrag(&r.selDragCaret, cx, cy)
r.selDragsOn[3] = true
r.drawHandle(gtx, cx, cy, lineH, r.selDraggingWhich == 3)
}
}
textClip.Pop()
r.displayLineCount = lineCount
// Store captured layout; derive lastLineY from it.
r.glyphLayout = layout
if len(layout.Y) > 0 {
r.lastLineY = layout.Y[len(layout.Y)-1]
}
}
// drawHandle draws a selection handle mimicking the native Android
// teardrop: a filled circle below the line with a short stem reaching up
// toward the line, in the system selection blue. (x, y) is the insertion
// point at the top of the line, as returned by handleAt. While the handle
// is being dragged it is drawn enlarged, as the framework does.
func (r *Renderer) drawHandle(gtx layout.Context, x, y, lineH Dp, dragging bool) {
col := Color{R: 51, G: 153, B: 255, A: 255}
radius, stemW, stemLen := Dp(10), Dp(3), Dp(12)
if dragging {
radius, stemW, stemLen = Dp(13), Dp(4), Dp(15)
}
// Stem: from just below the line's bottom up into the line, meeting the
// top of the circle (2dp overlap avoids a seam between the two shapes).
circleTop := y + lineH
r.drawBg(gtx, Region{X: x - stemW/2, Y: circleTop - stemLen, W: stemW, H: stemLen + 2}, col)
r.drawCircle(gtx, x, circleTop+radius, radius, col)
}
// drawCircle draws a filled circle of radius r centred at (cx, cy), as a
// square RRect clip with all corner radii at half the side.
func (r *Renderer) drawCircle(gtx layout.Context, cx, cy, rad Dp, col Color) {
rr := clip.UniformRRect(image.Rectangle{
Min: image.Point{X: int(r.toPx(cx - rad)), Y: int(r.toPx(cy - rad))},
Max: image.Point{X: int(r.toPx(cx + rad)), Y: int(r.toPx(cy + rad))},
}, int(r.toPx(rad))).Push(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
rr.Pop()
}
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
if img == nil {
return
}
// Auto-size: if width or height is 0, use the region dimensions
w := width
h := height
if w == 0 && h == 0 {
w, h = reg.W, reg.H
} else if w == 0 {
w = h
} else if h == 0 {
h = w
}
xPx := int(r.toPx(reg.X))
yPx := int(r.toPx(reg.Y))
wPx := int(r.toPx(w))
hPx := int(r.toPx(h))
origW := img.Bounds().Dx()
origH := img.Bounds().Dy()
if origW == 0 || origH == 0 {
return
}
sx := float32(wPx) / float32(origW)
sy := float32(hPx) / float32(origH)
// Position, then scale so the image fills the target size
offset := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
scale := op.Affine(f32.Affine2D{}.Scale(f32.Pt(0, 0), f32.Pt(sx, sy))).Push(gtx.Ops)
paint.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
scale.Pop()
offset.Pop()
}
// CaretPoint maps a window-relative byte offset to the insertion point's
// (x, y) in Dp relative to the shaped window's origin, where y is the
// line's baseline. A byte at a real glyph's start sits at the glyph's left
// edge; any other insertion point (a line's terminating "\n", an empty
// line's lone byte, EOF, or a wrapped line's first byte) sits on the visual
// line whose first byte is at or before it — the last such line — at the
// line origin for a line's first byte, and at the last glyph's right edge
// otherwise. (The first glyph at/past such a byte sits on the NEXT line,
// so it cannot be used for the line resolution.)
//
// The line's baseline comes from the uniform shaper grid
// (firstBaseline + line*lineH). firstBaseline is the window's FIRST visual
// line's baseline. When that line is empty (no recorded glyphs), the
// smallest recorded Y is the first NON-empty line's baseline =
// firstBaseline + j*lineH, j being the first recorded glyph's visual line;
// anchoring on the smallest Y instead drew every boundary caret one line
// too low per leading empty line, and the caret visibly jumped a line when
// the window scrolled past the empty line.
func CaretPoint(layout GlyphLayout, str string, byteOff int, ascent, lineH Dp) (x, y Dp) {
if idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= byteOff
}); idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] == byteOff {
return layout.X[idx], layout.Y[idx]
}
line := 0
lineStart, lineEnd := 0, len(str)
if starts := layout.VisualLineStarts; len(starts) > 0 {
k := sort.Search(len(starts), func(i int) bool {
return starts[i] > byteOff
})
if k > 0 {
line = k - 1
}
lineStart = starts[line]
if k < len(starts) {
lineEnd = starts[k]
}
}
var anchor Dp
if len(layout.ByteOffsets) > 0 {
j := 0
if starts := layout.VisualLineStarts; len(starts) > 0 {
if k := sort.Search(len(starts), func(i int) bool {
return starts[i] > layout.ByteOffsets[0]
}); k > 0 {
j = k - 1
}
}
anchor = layout.Y[0] - Dp(j)*lineH
} else {
anchor = ascent // no glyphs in the window (pure newlines)
}
y = anchor + Dp(line)*lineH
if byteOff == lineStart {
return 0, y // line origin (empty line, or wrapped line start)
}
// The line's "\n" (or EOF on the last line): the last glyph's right
// edge on the line.
var rightX Dp
found := false
for i, bo := range layout.ByteOffsets {
if bo < lineStart || bo >= lineEnd {
continue
}
if xe := layout.X[i] + layout.Advance[i]; !found || xe > rightX {
rightX, found = xe, true
}
}
return rightX, y
}