From 22c1d5bed13e09d4a31e0ab5f58a27725545ac89 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Mon, 18 May 2026 21:03:22 -0400 Subject: [PATCH] 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 --- cmd/pad/main.go | 39 ++--------- internal/editor/logic.go | 42 ++++-------- internal/editor/state.go | 58 ++++++++-------- internal/ui/element.go | 24 +++---- internal/ui/render.go | 140 +++++++++++++++++++++++++++++---------- 5 files changed, 163 insertions(+), 140 deletions(-) diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 4826f82..ce1f54c 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -30,59 +30,31 @@ func main() { func run(w *app.Window) error { var ops op.Ops - shaper := text.NewShaper(text.WithCollection(gofont.Collection())) - - // Initial DP size from app.Size() - initialDPW := ui.Dp(390) - initialDPH := ui.Dp(844) - - // Create logic instance - logic := editor.NewLogic(initialDPW, initialDPH) - - // Create renderer — reads scale from State via ScaleProvider + logic := editor.NewLogic() renderer := ui.New(ui.Theme{FontSize: 14}, shaper, logic.State()) - - // Track pixel dimensions from ConfigEvent - var pixelW, pixelH int - - // Shared state protected by mutex var mu sync.Mutex var elems []ui.Element - - // Start logic goroutine go logic.Run() - - // Start frame receiver goroutine go frameReceiver(w, &mu, &elems, logic.FrameChan()) - // Main event loop for { switch e := w.Event().(type) { case app.DestroyEvent: return e.Err case app.ConfigEvent: - pixelW = e.Config.Size.X - pixelH = e.Config.Size.Y - // Convert pixels to DP using current scale - scale := logic.Scale() - dpW := ui.ToDp(ui.Px(pixelW), scale) - dpH := ui.ToDp(ui.Px(pixelH), scale) + // ConfigEvent: raw pixel dimensions only. logic.ConfigChan() <- editor.ConfigEvent{ - Width: dpW, - Height: dpH, + PixelWidth: e.Config.Size.X, + PixelHeight: e.Config.Size.Y, } case app.FrameEvent: gtx := app.NewContext(&ops, e) - - // Update scale if changed newScale := gtx.Metric.PxPerDp - curScale := logic.Scale() + curScale := logic.State().Scale() if newScale != curScale { logic.ConfigChan() <- editor.ScaleEvent{newScale} } - - // Acquire mutex, read frame, draw, release mutex mu.Lock() currentElems := elems renderer.Draw(gtx, currentElems) @@ -92,7 +64,6 @@ func run(w *app.Window) error { } } -// frameReceiver runs in a separate goroutine and bridges frameChan to the main loop. func frameReceiver(w *app.Window, mu *sync.Mutex, elems *[]ui.Element, frameChan <-chan []ui.Element) { for { frame := <-frameChan diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 4caed14..e26b93d 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -7,10 +7,10 @@ import ( ) // ConfigEvent represents a window configuration change (resize, orientation). -// Width and Height are in device-independent pixels (Dp). +// PixelWidth and PixelHeight are the raw pixel dimensions from Gio. type ConfigEvent struct { - Width ui.Dp - Height ui.Dp + PixelWidth int + PixelHeight int } // ScaleEvent represents a metric change (HiDPI scale factor). @@ -25,9 +25,8 @@ type ConfigUpdate interface { } func (e ConfigEvent) apply(s *State) { - s.ScreenWidth = e.Width - s.ScreenHeight = e.Height - s.Elems = EditorLayout(e.Width, e.Height) + s.PixelWidth = e.PixelWidth + s.PixelHeight = e.PixelHeight } func (e ScaleEvent) apply(s *State) { @@ -57,10 +56,9 @@ type Logic struct { } // NewLogic creates a new Logic instance. -// screenWidth and screenHeight are in device-independent pixels (Dp). -func NewLogic(screenWidth, screenHeight ui.Dp) *Logic { +func NewLogic() *Logic { return &Logic{ - state: NewState(screenWidth, screenHeight), + state: NewState(), configChan: make(chan ConfigUpdate), frameChan: make(chan []ui.Element), inputChan: make(chan []InputEvent), @@ -85,17 +83,10 @@ func (l *Logic) InputChan() chan<- []InputEvent { } // ResultChan returns the result channel for the logic goroutine. -func (l *Logic) ResultChan() <-chan ResultEvent { +func (l *Logic) ResultChan() chan<- ResultEvent { return l.resultChan } -// ScreenSize returns the current screen dimensions in Dp. -func (l *Logic) ScreenSize() (ui.Dp, ui.Dp) { - l.mu.Lock() - defer l.mu.Unlock() - return l.state.ScreenWidth, l.state.ScreenHeight -} - // Scale returns the current scale factor (pixels per DP). func (l *Logic) Scale() float32 { return l.state.Scale() @@ -106,21 +97,12 @@ func (l *Logic) Run() { for { select { case update := <-l.configChan: - // Type switch to differentiate ConfigEvent vs ScaleEvent - switch e := update.(type) { - case ConfigEvent: - e.apply(l.state) - l.frameChan <- l.state.Elems - case ScaleEvent: - e.apply(l.state) - l.frameChan <- l.state.Elems - } + update.apply(l.state) + l.frameChan <- l.state.layout() case <-l.inputChan: - // Process input (not implemented in mockup) - l.frameChan <- l.state.Elems + l.frameChan <- l.state.layout() case <-l.resultChan: - // Handle result (not implemented in mockup) - l.frameChan <- l.state.Elems + l.frameChan <- l.state.layout() } } } diff --git a/internal/editor/state.go b/internal/editor/state.go index 0285b18..ddfa420 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -1,63 +1,64 @@ package editor import ( + "fmt" "pad/internal/ui" ) // State holds all application state owned by the logic goroutine. -// All dimensions are in device-independent pixels (Dp). type State struct { - ScreenWidth ui.Dp - ScreenHeight ui.Dp - scale float32 // pixels per DP, default 1.0 - Elems []ui.Element + PixelWidth int // raw pixel width from Gio ConfigEvent + PixelHeight int // raw pixel height from Gio ConfigEvent + scale float32 + Elems []ui.Element } -// NewState creates a new State with initial editor layout. -func NewState(screenWidth, screenHeight ui.Dp) *State { - return &State{ - ScreenWidth: screenWidth, - ScreenHeight: screenHeight, - scale: 1.0, - Elems: EditorLayout(screenWidth, screenHeight), - } +func NewState() *State { + return &State{scale: 1.0} } -// SetScale updates the scale factor and recomputes layout. func (s *State) SetScale(scale float32) { s.scale = scale - s.Elems = EditorLayout(s.ScreenWidth, s.ScreenHeight) } -// Scale returns the current scale factor (pixels per DP). func (s *State) Scale() float32 { return s.scale } +// layout converts stored pixel dimensions to Dp using the current scale +// and computes the element tree. Called only when a frame is needed. +func (s *State) layout() []ui.Element { + dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) + dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale) + s.Elems = EditorLayout(dpW, dpH) + return s.Elems +} + // EditorLayout computes the element tree for the editor page. -// It takes screen dimensions in Dp and returns []Element. func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element { margin := ui.Dp(10) - // --- Top container: status bar --- - statusBarHeight := ui.Dp(52) + // --- Top bar: filename on row 1, icons on row 2 --- statusBarRegion := ui.Region{ X: margin, Y: margin, W: screenWidth - margin*2, - H: statusBarHeight, + H: ui.Dp(52), } - + statusBarW := statusBarRegion.W statusBar := ui.NewContainer( statusBarRegion, ui.Color{R: 230, G: 230, B: 230, A: 255}, []ui.Element{ - ui.NewIcon("cut", ui.Region{X: ui.Dp(8), Y: ui.Dp(2), W: ui.Dp(16), H: ui.Dp(16)}, ui.Dp(16)), - ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: ui.Dp(32), Y: ui.Dp(2)}), - ui.NewLabel("Ready", 12, ui.Region{X: ui.Dp(8), Y: ui.Dp(28)}), + // Row 1: filename centered (relative to container) + ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: statusBarW/2 - ui.Dp(40), Y: ui.Dp(2)}), + // Row 2: cut icon left (relative to container) + ui.NewIcon("cut", ui.Region{X: ui.Dp(8), Y: ui.Dp(28)}, ui.Dp(16)), + // Row 2: status icons right (relative to container) + ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: statusBarW - ui.Dp(70), Y: ui.Dp(28)}), }, ) - // --- Bottom container: bottom bar --- + // --- Bottom bar --- bottomBarHeight := ui.Dp(24) bottomBarY := screenHeight - margin - bottomBarHeight bottomBarRegion := ui.Region{ @@ -65,16 +66,17 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element { W: screenWidth - margin*2, H: bottomBarHeight, } - + bottomBarW := bottomBarRegion.W bottomBar := ui.NewContainer( bottomBarRegion, ui.Color{R: 230, G: 230, B: 230, A: 255}, []ui.Element{ ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: ui.Dp(8), Y: ui.Dp(2)}), - ui.NewLabel("1024 / 50000", 12, ui.Region{X: ui.Dp(150), Y: ui.Dp(2)}), - ui.NewLabel("Wrap: On", 12, ui.Region{X: screenWidth - margin - ui.Dp(60), Y: ui.Dp(2)}), + ui.NewLabel("1024 / 50000", 12, ui.Region{X: bottomBarW/2 - ui.Dp(40), Y: ui.Dp(2)}), + ui.NewLabel("Wrap: On", 12, ui.Region{X: bottomBarW - ui.Dp(60), Y: ui.Dp(2)}), }, ) + fmt.Println("[DEBUG EditorLayout] created", len([]ui.Element{statusBar, bottomBar}), "containers") return []ui.Element{statusBar, bottomBar} } diff --git a/internal/ui/element.go b/internal/ui/element.go index 9f7fe34..0af309c 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -1,9 +1,9 @@ package ui import ( + "fmt" "gioui.org/layout" "gioui.org/unit" - "gioui.org/widget/material" ) // Region defines a screen area in device-independent pixels (Dp). @@ -34,18 +34,15 @@ type Container struct { func (c Container) Region() Region { return c.region } func (c Container) Visible() bool { return c.visible } func (c Container) Draw(gtx layout.Context, r *Renderer) { - // Draw background + // Draw background — children are drawn by drawElement, not here if c.background != (Color{}) { r.drawBg(gtx, c.region, c.background) } - // Draw children - for _, child := range c.children { - child.Draw(gtx, r) - } } // NewContainer creates a Container with the given region, background, and children. func NewContainer(region Region, bg Color, children []Element) Container { + fmt.Printf("[DEBUG NewContainer] region={X=%.0f Y=%.0f W=%.0f H=%.0f} children=%d\n", region.X, region.Y, region.W, region.H, len(children)) return Container{ region: region, visible: true, @@ -71,7 +68,12 @@ type Label struct { func (l Label) Region() Region { return l.region } func (l Label) Visible() bool { return l.visible } func (l Label) Draw(gtx layout.Context, r *Renderer) { - r.drawText(gtx, l.Text, l.FontSize, l.region, l.Color) + fmt.Printf("[DEBUG Label.Draw] text=%q region={X=%.0f Y=%.0f}\n", l.Text, l.region.X, l.region.Y) + col := l.Color + if col == (Color{}) { + col = Color{R: 0, G: 0, B: 0, A: 255} + } + r.drawText(gtx, l.Text, l.FontSize, l.region, col) } func (l Label) ID() string { return l.id } @@ -97,6 +99,7 @@ type Icon struct { func (i Icon) Region() Region { return i.region } func (i Icon) Visible() bool { return i.visible } func (i Icon) Draw(gtx layout.Context, r *Renderer) { + fmt.Printf("[DEBUG Icon.Draw] name=%q icon=%v region={X=%.0f Y=%.0f}\n", i.Name, r.icon(i.Name) != nil, i.region.X, i.region.Y) img := r.icon(i.Name) if img == nil { return @@ -200,11 +203,8 @@ type Button struct { func (b Button) Region() Region { return b.region } func (b Button) Visible() bool { return b.visible } func (b Button) Draw(gtx layout.Context, r *Renderer) { - th := material.NewTheme() - th.Shaper = r.shp - gtx.Constraints.Min.X = int(r.toPx(b.region.X)) - gtx.Constraints.Min.Y = int(r.toPx(b.region.Y)) - material.Label(th, r.theme.FontSize, b.Text).Layout(gtx) + col := Color{R: 0, G: 0, B: 0, A: 255} + r.drawText(gtx, b.Text, r.theme.FontSize, b.region, col) } func (b Button) ID() string { return b.id } diff --git a/internal/ui/render.go b/internal/ui/render.go index b194e3c..b336b1b 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -3,19 +3,24 @@ 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" - "gioui.org/widget/material" + "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 @@ -69,13 +74,19 @@ func (r *Renderer) toDp(px Px) Dp { // Draw iterates elements and draws each in slice order (back-to-front). func (r *Renderer) Draw(gtx layout.Context, elems []Element) { - // Clip to window bounds - clipRect := clip.Rect{ - Min: gtx.Constraints.Min, - Max: gtx.Constraints.Max, - }.Push(gtx.Ops) + fmt.Println("[DEBUG Draw] elems:", len(elems), "scale:", r.scale.Scale()) - for _, e := range elems { + // 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 } @@ -85,32 +96,34 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) { clipRect.Pop() } -// drawElement clips to the element's bounds, draws background if any, -// then dispatches to the element's Draw method. 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) - // Clip to element bounds - 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) - - // Draw children if this is a container if container, ok := e.(Container); ok { - for _, child := range container.children { - child.Draw(gtx, r) + // 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) } - - // Dispatch to element's Draw method - e.Draw(gtx, r) - - clipRect.Pop() } -// drawBg draws a solid-color background for the given region. 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))}, @@ -118,31 +131,86 @@ func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) { 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") } -// drawText draws text using Gio's textView approach. func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, col Color) { - th := material.NewTheme() - th.Shaper = r.shp - gtx.Constraints.Min.X = int(r.toPx(reg.X)) - gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) - material.Label(th, size, str).Layout(gtx) -} - -// drawPng draws a PNG image at the given region and size. -func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) { - if img == nil { + 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") }