package ui import ( "bytes" "embed" "image" "image/color" _ "image/png" "gioui.org/f32" "gioui.org/gesture" "gioui.org/io/input" "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) } // 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 } // 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), } r.loadIcons() return r } // loadIcons loads PNG icons from the embedded filesystem. func (r *Renderer) loadIcons() { for _, name := range []string{"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. // Only Tap is supported for now. func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context, region Region) { if interaction.Gesture != Tap { return } reg, ok := r.clicks[id] if !ok { reg = clickReg{click: &gesture.Click{}} r.clicks[id] = reg } // click.Add() is now called inside drawText, within the text clip reg.handler = interaction.Handler r.clicks[id] = reg } // CheckGestures checks all registered gestures and returns any events. func (r *Renderer) CheckGestures(q input.Source) []InputEvent { var events []InputEvent for _, reg := range r.clicks { evt, ok := reg.click.Update(q) if ok && evt.Kind == gesture.KindClick { events = append(events, InputEvent{ Handler: reg.handler, Data: evt, }) } } return events } func (r *Renderer) drawElement(gtx layout.Context, e Element) { // Register interactions before drawing if interactive, ok := e.(Interactive); ok { for _, interaction := range interactive.Interactions() { r.registerInteraction(interactive.ID(), interaction, gtx, e.Region()) } } 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 { // Leaf element: no self-clip (region may be zero-sized; clip is handled by parent container) 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, register click inside the clip 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) if cr, ok := r.clicks[id]; ok && cr.click != nil { cr.click.Add(gtx.Ops) } // Layout again (iterator consumed) and draw r.shp.LayoutString(params, str) r.drawLineText(gtx, drawX, reg.Y, col) textClip.Pop() } // drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety. 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() } 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() }