package ui import ( "bytes" "embed" "image" "image/color" _ "image/png" "log" "sort" "time" "unicode/utf8" "gioui.org/f32" "gioui.org/gesture" "gioui.org/io/event" // Import event package "gioui.org/io/input" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" "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 // ScaleProvider provides access to the current scale factor. type ScaleProvider interface { Scale() float32 } // clickReg pairs a gesture.Click with its handler. type clickReg struct { click *gesture.Click handler func(any) } // 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. type Renderer struct { theme Theme shp *text.Shaper scale ScaleProvider icons map[string]image.Image clicks map[string]clickReg Keys map[string]keyReg // Exported Keys map scrolls map[string]scrollReg 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 } // New creates a new Renderer. func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer { r := &Renderer{ theme: th, shp: shp, scale: scale, icons: make(map[string]image.Image), clicks: make(map[string]clickReg), Keys: make(map[string]keyReg), scrolls: make(map[string]scrollReg), } r.loadIcons() return r } // 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.Scale()) } // toDp converts physical pixels to Dp using State's scale. func (r *Renderer) toDp(px Px) Dp { return ToDp(px, r.scale.Scale()) } // Draw iterates elements and draws each in slice order (back-to-front). func (r *Renderer) Draw(gtx layout.Context, elems []Element) { // 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{}} } reg.handler = interaction.Handler r.clicks[id] = reg // 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 } } // 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{}} } reg.handler = handler r.clicks[id] = reg // 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) } // CheckGestures checks all registered gestures and returns any events. func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { var events []InputEvent for id, reg := range r.clicks { evt, ok := reg.click.Update(q) if ok { if evt.Kind == gesture.KindClick { log.Printf("Renderer: Click detected on %s, pos=%v", id, evt.Position) events = append(events, InputEvent{ Handler: reg.handler, Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))}, }) } } } 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 } // 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 { log.Printf("register gesture for %s", interactive.ID()) 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{}} } reg.handler = interaction.Handler r.clicks[interactive.ID()] = reg reg.click.Add(gtx.Ops) } } } 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 { log.Printf("register gesture for %s", interactive.ID()) 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{}} } reg.handler = interaction.Handler r.clicks[interactive.ID()] = reg } // 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.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 int) { 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 m := op.Record(gtx.Ops) var glyphs [32]text.Glyph line := glyphs[:0] lineCount := 0 // Capture per-glyph layout data for cursor positioning and navigation. var layout GlyphLayout byteOffset := 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.Scale())) layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale.Scale())) layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale.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 { r.drawLine(gtx, line, reg.X, y, col) line = line[:0] if g.Flags&text.FlagLineBreak != 0 { lineCount++ } } } if len(line) > 0 { r.drawLine(gtx, line, reg.X, y, col) lineCount++ } 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}) 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] } } 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() }