Two feature bodies accumulated in the working tree:
1. Pinch to change the app font size, continuously (no snapping):
- internal/ui/pinch_tracker.go: logic-free touch state machine.
Two-mover formation (the resting palm can land first or last;
movement is the only signal valid for both), pair = the mover
pair whose distance changed most, baseline = press distance
(formDist), lazy pending releases, survivor-scroll forwarding
after a pair break. Robust to ~1 fps frames: a whole pinch can
land in one drain (formDist/brokeFactor/lazy releases).
- render.go: pinch probe (raw pointer events) + grab lifecycle so
the pair is exclusive (scroll sees nothing of the pair) and the
survivor's finger keeps working as a scroll after the pinch.
- state.go/logic.go/session.go/frame.go: app-local float font
scale, content-point pin (buffer byte + offset from baseline,
not a layout point, so rewrap keeps the same character under
the center), restore/font pins, session persistence.
- pinch_test.go, pinch_font_test.go, tag_identity_test.go,
real_draw_probe_test.go: unit + real-Renderer/real-Router tests.
2. Soft keyboard must not shift content:
- Root cause: gioui.org/app calls Router.RevealFocus on any frame
the viewport shrinks (IME open under adjustResize) and
synthesizes a pointer.Scroll nudge aimed at the focused field's
stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
content jump.
- Fix: main.go flags the shrink frame; render.go drains that one
synthetic scroll for the gesture's tag before Update (scroll-
range clamping cannot work: the router UNIONs ranges across
frames). Finger scroll (pointer.Drag) and the flinger are
untouched. reveal_focus_drain_test.go reproduces RevealFocus at
the router level and verifies the drain + zero delta.
Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
1384 lines
51 KiB
Go
1384 lines
51 KiB
Go
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 // window-relative rune index of last-pushed selection start; -1 = no selection
|
||
lastSelCaret int // window-relative rune index of last-pushed selection end/caret
|
||
lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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`
|
||
cursorX, cursorY := caretPoint(cursorPos)
|
||
cursorX = reg.X + cursorX
|
||
cursorY = reg.Y - scrollOffset + cursorY - 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
|
||
}
|