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 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 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) // 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 } // 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"} { 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 // 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) 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. 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, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool) { if str == "" { return } r.gestureExclusions = nil // rebuilt from this frame's handle boxes // Fixed line height based on font size, not glyph metrics. lineHeightSp := unit.Sp(float32(r.theme.FontSize) * 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). fontScale := float32(1) if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 { fontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp } ascent := Dp(float32(r.theme.FontSize) * 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(r.theme.FontSize)), 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: 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] - 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: 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) // 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 the cursor's byte is inside the shaped window // (window-relative: [0, len(str)]). 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 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) 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 }