Pad/internal/ui/render.go
Greg Pomerantz 22c1d5bed1 fix: correct text layout and rendering pipeline
- Fix render.go: replace broken material.Label with shaper-based text
  rendering (LayoutString + Shape + glyph iteration) per Gio's paintGlyph
- Fix render.go: only apply clip rects at container boundaries; leaf
  elements have zero-size regions that were clipping all text out
- Fix render.go: call r.drawElement recursively for container children
  so nested containers work and clips are applied correctly
- Fix render.go: add drawLineText/drawLine helpers matching Gio's
  paintGlyph offset calculation (x + first.X, y + first.Y)
- Fix element.go: Button.Draw now uses r.drawText instead of duplicate
  broken material.Label code; Label defaults to black when color is unset
- Fix state.go: container children use relative coordinates instead of
  mixed screen-space/container-relative positions
- Fix main.go: ConfigEvent carries raw pixel dimensions only; ScaleEvent
  carries scale only; layout is computed once per frame using both,
  eliminating the infinite Invalidate() loop
2026-05-18 21:03:22 -04:00

217 lines
6.7 KiB
Go

package ui
import (
"bytes"
"embed"
"fmt"
"image"
"image/color"
_ "image/png"
"gioui.org/f32"
"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
}
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
shp *text.Shaper
scale ScaleProvider
icons map[string]image.Image
}
// 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)}
r.loadIcons()
return r
}
// loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() {
data, err := iconFS.ReadFile("icons/cut.png")
if err != nil {
return
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return
}
r.icons["cut"] = 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) {
fmt.Println("[DEBUG Draw] elems:", len(elems), "scale:", 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)
fmt.Println("[DEBUG Draw] window clip:", image.Point{X: 0, Y: 0}, image.Point{X: winW, Y: winH})
for i, e := range elems {
fmt.Printf("[DEBUG Draw] elem[%d]: %T visible=%v\n", i, e, e.Visible())
if !e.Visible() {
continue
}
r.drawElement(gtx, e)
}
clipRect.Pop()
}
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
reg := e.Region()
fmt.Printf("[DEBUG drawElement] %T region={X=%.0f Y=%.0f W=%.0f H=%.0f}\n", e, reg.X, reg.Y, reg.W, reg.H)
if container, ok := e.(Container); ok {
// Clip to container bounds, draw background, then offset children
fmt.Printf("[DEBUG drawElement] container children=%d originPx=(%d,%d)\n", len(container.children), int(r.toPx(reg.X)), int(r.toPx(reg.Y)))
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 i, child := range container.children {
fmt.Printf("[DEBUG drawElement] child[%d]: %T region={X=%.0f Y=%.0f}\n", i, child, child.Region().X, child.Region().Y)
r.drawElement(gtx, child)
}
offset.Pop()
clipRect.Pop()
fmt.Println("[DEBUG drawElement] clip popped")
} 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) {
fmt.Printf("[DEBUG drawBg] region={X=%.0f Y=%.0f W=%.0f H=%.0f} col={%d,%d,%d,%d}\n", reg.X, reg.Y, reg.W, reg.H, col.R, col.G, col.B, col.A)
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()
fmt.Println("[DEBUG drawBg] bg drawn")
}
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, col Color) {
fmt.Printf("[DEBUG drawText] str=%q size=%.0f region={X=%.0f Y=%.0f} col={%d,%d,%d,%d}\n", str, float32(size), reg.X, reg.Y, col.R, col.G, col.B, col.A)
if str == "" {
return
}
// Layout text with width constraints (per layout_rendering.md)
r.shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: maxInt32,
MaxLines: 1,
}, str)
r.drawLineText(gtx, reg.X, reg.Y, col)
fmt.Println("[DEBUG drawText] text drawn")
}
// 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) {
fmt.Printf("[DEBUG drawPng] icon=%v region={X=%.0f Y=%.0f} size={W=%.0f H=%.0f}\n", img != nil, reg.X, reg.Y, width, height)
if img == nil {
fmt.Println("[DEBUG drawPng] icon nil, skipping")
return
}
xPx := int(r.toPx(reg.X))
yPx := int(r.toPx(reg.Y))
wPx := int(r.toPx(width))
hPx := int(r.toPx(height))
_ = image.Rect(xPx, yPx, xPx+wPx, yPx+hPx)
stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
paint.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
fmt.Println("[DEBUG drawPng] png drawn")
}