Pad/internal/ui/render.go
Greg Pomerantz 2a16c0017c Touch selection (v1): long-press/double-tap word selection, drag handles, floating copy/cut/paste menu
Implement the Android-native touch selection model, verified on-device:
- long-press selects the word under the finger (blank -> caret + paste-only
  menu); double-tap selects the word; drag handles resize the selection,
  drag the highlighted body to move it; floating menu offers copy/cut/paste
  (selection) or paste (bare caret), closing on any item tap.
- Renderer reports finger positions (app-local Dp) as tap/double-tap/
  long-press/selection-drag events; the logic goroutine owns all geometry
  (EditorRegion, menu rect, hit-testing, handles) and the renderer only
  draws the frame snapshot.
- Long press: 400 ms still-press on the editor, cancelled by movement
  (non-grabbing raw pointer probe) or by a scroll/handle grab. The main
  loop keeps invalidating while a press is pending (Gio renders on demand;
  a stationary finger produces no frames).
- Clipboard crosses the goroutine boundary via buffered channels
  (clipboardSetChan/pasteReqChan logic->main, pasteChan main->logic);
  main executes the Gio ops and, on Android, invalidates after ReadCmd
  because a queued transfer.DataEvent schedules no frame of its own.

Renderer fixes found while validating on-device:
- clickReg was stored by value in a map; range yielded copies so per-frame
  press bookkeeping (long-press state) was silently discarded. Now pointers.
- On Android a tap's press+release arrive in the same frame and
  gesture.Click/Drag return one event per Update call; without draining
  each gesture's queue every frame the release was lost on an idle window
  and every menu tap was swallowed (needed a second tap to 'rescue' it).
  Click and drag loops now drain to exhaustion (scroll already does).
- pointer.Filter queries must name Kinds: a zero-kinds filter matches
  nothing (the press-probe query was dead).
- Menu.Draw offsets items by the menu origin; the clippable drawElement
  branch registers SelDrag (handles now draw for the TextField).

Tests: internal/editor/touch_selection_test.go (word range, long-press,
double-tap, tap/menu guards, handle drags, menu actions, selection edits)
and internal/test/e2e/touch_selection_e2e_test.go; full suite green under
-race. Docs: spec.md §2.2 + §7, architecture.md §6.3a, development_plan.md
Phases 8-9.
2026-08-17 08:57:55 -04:00

931 lines
31 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)
}
// 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
// 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
// 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 struct{}
ppLast f32.Point
ppActive bool
ppMoved bool
longPressID string
// 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)
}
// 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),
}
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"} {
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
// 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)
}
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)
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 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:
r.selDragEmitting[which] = 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
}
}
}
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.
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
if delta != 0 {
events = append(events, InputEvent{
Handler: reg.handler,
Data: delta,
})
}
}
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.
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})
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:
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)
}
}
}
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.
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool) {
if str == "" {
return
}
// Fixed line height based on font size, not glyph metrics
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0,
MaxWidth: int(r.toPx(wrapWidth)),
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 = Dp(float32(lineHeightSp))
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.
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: selection highlight (translucent blue), one rect per covered
// glyph, emitted before the text so glyphs draw on top of it.
if selStart >= 0 && selEnd > selStart {
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
}
hx := reg.X + layout.X[i]
hy := reg.Y - scrollOffset + layout.Y[i] - Dp(r.theme.FontSize)
hw := layout.Advance[i]
hh := Dp(r.theme.FontSize) * 1.2
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: color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
rect.Pop()
}
}
// 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)
// Determine cursor position from `layout` and `cursorPos`
var cursorX, cursorY Dp
if len(layout.ByteOffsets) == 0 {
cursorX = reg.X
cursorY = reg.Y
} else {
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= cursorPos
})
if idx < len(layout.ByteOffsets) {
cursorX = reg.X + layout.X[idx]
cursorY = reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
} else {
cursorX = reg.X + layout.X[len(layout.X)-1] + layout.Advance[len(layout.Advance)-1]
cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - Dp(r.theme.FontSize)
}
}
// Draw the cursor (thin vertical bar)
cursorRegion := Region{
X: cursorX,
Y: cursorY,
W: Dp(2),
H: Dp(r.theme.FontSize) * 1.2, // Use line height
}
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)
r.longPressID = "editor_text"
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
lineH := Dp(r.theme.FontSize) * 1.2
// handleAt mirrors the cursor computation above: window-relative byte
// offset -> screen Dp of the caret insertion point.
handleAt := func(byteOff int) (x, y Dp) {
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= byteOff
})
if idx < len(layout.ByteOffsets) {
return reg.X + layout.X[idx], reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
}
n := len(layout.X) - 1
return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - Dp(r.theme.FontSize)
}
registerDrag := func(d *gesture.Drag, hx, hy Dp) {
hc := clip.Rect{
Min: image.Point{X: int(r.toPx(hx - 8)), Y: int(r.toPx(hy - 6))},
Max: image.Point{X: int(r.toPx(hx + 8)), Y: int(r.toPx(hy + lineH + 6))},
}.Push(gtx.Ops)
d.Add(gtx.Ops)
hc.Pop()
}
if selStart >= 0 && selEnd > selStart {
sx, sy := handleAt(selStart)
ex, ey := handleAt(selEnd)
registerDrag(&r.selDragStart, sx, sy)
r.selDragsOn[0] = true
registerDrag(&r.selDragEnd, ex, ey)
r.selDragsOn[1] = true
// 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] - Dp(r.theme.FontSize)
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
}
r.drawHandle(gtx, sx, sy, lineH)
r.drawHandle(gtx, ex, ey, lineH)
} 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)
}
}
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: a short vertical stem with a filled
// square foot at the line's bottom (v1 approximation of Android's circle).
func (r *Renderer) drawHandle(gtx layout.Context, x, y, lineH Dp) {
col := Color{R: 51, G: 153, B: 255, A: 255}
r.drawBg(gtx, Region{X: x - 1, Y: y, W: 2, H: lineH}, col)
r.drawBg(gtx, Region{X: x - 5, Y: y + lineH - 2, W: 10, H: 10}, col)
}
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()
}