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) // 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) 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 = 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. 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) // 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] - ascent } 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] - 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) { 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] - ascent } n := len(layout.X) - 1 return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - ascent } 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] - 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 } 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() }