fix: remove duplicate scale storage, read from State via ScaleProvider interface

- Remove Renderer.scale field and SetScale method
- Add ScaleProvider interface to avoid editor/ui import cycle
- Add Scale() method to State, rename Scale field to scale
- Renderer reads scale through r.scale.Scale()
- Fix icons.go blank import preventing embed.FS usage
- Update main.go to pass State as ScaleProvider to Renderer
This commit is contained in:
Greg Pomerantz 2026-05-17 10:59:29 -04:00
parent fa43bf2402
commit 93a7a0a0b2
8 changed files with 84 additions and 102 deletions

View File

@ -32,18 +32,19 @@ func run(w *app.Window) error {
var ops op.Ops var ops op.Ops
shaper := text.NewShaper(text.WithCollection(gofont.Collection())) shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
// Initial DP size from app.Size() — used before we know the scale factor // Initial DP size from app.Size() — used before we know the scale factor
initialDPW := ui.Dp(390) initialDPW := ui.Dp(390)
initialDPH := ui.Dp(844) initialDPH := ui.Dp(844)
// Track pixel dimensions from ConfigEvent (for resize handling)
var pixelW, pixelH int
// Create logic instance with initial DP size and scale=1.0 // Create logic instance with initial DP size and scale=1.0
logic := editor.NewLogic(initialDPW, initialDPH) logic := editor.NewLogic(initialDPW, initialDPH)
renderer := ui.New(ui.Theme{FontSize: 14}, shaper, logic.State())
// Track pixel dimensions from ConfigEvent (for resize handling)
var pixelW, pixelH int
// Shared state protected by mutex // Shared state protected by mutex
var mu sync.Mutex var mu sync.Mutex
var elems []ui.Element var elems []ui.Element
@ -53,6 +54,7 @@ func run(w *app.Window) error {
// Start frame receiver goroutine // Start frame receiver goroutine
go frameReceiver(w, &mu, &elems, logic.FrameChan()) go frameReceiver(w, &mu, &elems, logic.FrameChan())
scale := float32(1.0)
// Main event loop // Main event loop
for { for {
@ -63,9 +65,9 @@ func run(w *app.Window) error {
// Store pixel dimensions from ConfigEvent // Store pixel dimensions from ConfigEvent
pixelW = e.Config.Size.X pixelW = e.Config.Size.X
pixelH = e.Config.Size.Y pixelH = e.Config.Size.Y
// Convert to DP using current scale (starts at 1.0) // Convert to DP using current scale
dpW := ui.ToDp(ui.Px(pixelW), 1.0) dpW := ui.ToDp(ui.Px(pixelW), scale)
dpH := ui.ToDp(ui.Px(pixelH), 1.0) dpH := ui.ToDp(ui.Px(pixelH), scale)
// Send config event to logic goroutine (in DP) // Send config event to logic goroutine (in DP)
logic.ConfigChan() <- editor.ConfigEvent{ logic.ConfigChan() <- editor.ConfigEvent{
Width: dpW, Width: dpW,
@ -75,28 +77,21 @@ func run(w *app.Window) error {
gtx := app.NewContext(&ops, e) gtx := app.NewContext(&ops, e)
// Get scale from State — this is updated by ScaleEvent // Get scale from State — this is updated by ScaleEvent
newScale := logic.Scale() newScale := gtx.Metric.PxPerDp
// Convert pixel dimensions to DP using actual scale if newScale != scale {
dpW := ui.ToDp(ui.Px(pixelW), newScale) // Send updated scale to logic goroutine
dpH := ui.ToDp(ui.Px(pixelH), newScale) logic.ConfigChan() <- editor.ScaleEvent{newScale}
scale = newScale
// Send updated config to logic goroutine
logic.ConfigChan() <- editor.ConfigEvent{
Width: dpW,
Height: dpH,
} }
// Update renderer with current scale from State
renderer.SetScale(newScale)
// Acquire mutex, read frame, draw, release mutex // Acquire mutex, read frame, draw, release mutex
mu.Lock() mu.Lock()
currentElems := elems currentElems := elems
mu.Unlock()
renderer.Draw(gtx, currentElems) renderer.Draw(gtx, currentElems)
e.Frame(&ops) e.Frame(&ops)
mu.Unlock()
} }
} }
} }

6
go.mod
View File

@ -2,13 +2,15 @@ module pad
go 1.24.2 go 1.24.2
require gioui.org v0.9.0 require (
gioui.org v0.9.0
golang.org/x/image v0.26.0
)
require ( require (
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.3.0 // indirect github.com/go-text/typesetting v0.3.0 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0 // indirect
) )

View File

@ -98,9 +98,7 @@ func (l *Logic) ScreenSize() (ui.Dp, ui.Dp) {
// Scale returns the current scale factor (pixels per DP). // Scale returns the current scale factor (pixels per DP).
func (l *Logic) Scale() float32 { func (l *Logic) Scale() float32 {
l.mu.Lock() return l.state.Scale()
defer l.mu.Unlock()
return l.state.Scale
} }
// Run runs the logic goroutine loop. // Run runs the logic goroutine loop.

View File

@ -9,7 +9,7 @@ import (
type State struct { type State struct {
ScreenWidth ui.Dp ScreenWidth ui.Dp
ScreenHeight ui.Dp ScreenHeight ui.Dp
Scale float32 // pixels per DP, default 1.0 scale float32 // pixels per DP, default 1.0
Elems []ui.Element Elems []ui.Element
} }
@ -20,17 +20,22 @@ func NewState(screenWidth, screenHeight ui.Dp) *State {
return &State{ return &State{
ScreenWidth: screenWidth, ScreenWidth: screenWidth,
ScreenHeight: screenHeight, ScreenHeight: screenHeight,
Scale: 1.0, scale: 1.0,
Elems: EditorLayout(screenWidth, screenHeight), Elems: EditorLayout(screenWidth, screenHeight),
} }
} }
// SetScale updates the scale factor and recomputes layout. // SetScale updates the scale factor and recomputes layout.
func (s *State) SetScale(scale float32) { func (s *State) SetScale(scale float32) {
s.Scale = scale s.scale = scale
s.Elems = EditorLayout(s.ScreenWidth, s.ScreenHeight) s.Elems = EditorLayout(s.ScreenWidth, s.ScreenHeight)
} }
// Scale returns the current scale factor (pixels per DP).
func (s *State) Scale() float32 {
return s.scale
}
// EditorLayout computes regions for the editor page. // EditorLayout computes regions for the editor page.
// It takes screen dimensions in Dp and returns []Element with regions in Dp. // It takes screen dimensions in Dp and returns []Element with regions in Dp.
// This function works exclusively in Dp — no pixel conversions. // This function works exclusively in Dp — no pixel conversions.

View File

@ -3,7 +3,7 @@
package icons package icons
import ( import (
_ "embed" "embed"
) )
//go:embed copy.svg cut.svg paste.svg //go:embed copy.svg cut.svg paste.svg

View File

@ -25,13 +25,18 @@ var iconFS embed.FS
type Renderer struct { type Renderer struct {
theme Theme theme Theme
shp *text.Shaper shp *text.Shaper
scale float32 // pixels per DP, from State.Scale scale ScaleProvider
icons map[string]image.Image icons map[string]image.Image
} }
// ScaleProvider provides access to the current scale factor.
type ScaleProvider interface {
Scale() float32
}
// New creates a new Renderer. // New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer { func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
r := &Renderer{theme: th, shp: shp, scale: 1.0, icons: make(map[string]image.Image)} r := &Renderer{theme: th, shp: shp, scale: scale, icons: make(map[string]image.Image)}
r.loadIcons() r.loadIcons()
return r return r
} }
@ -49,25 +54,19 @@ func (r *Renderer) loadIcons() {
r.icons["cut"] = img r.icons["cut"] = img
} }
// SetScale updates the renderer's scale factor from State.Scale. // toPx converts Dp to physical pixels using State's scale.
func (r *Renderer) SetScale(scale float32) {
r.scale = scale
}
// toPx converts Dp to physical pixels using the renderer's scale.
func (r *Renderer) toPx(dp Dp) Px { func (r *Renderer) toPx(dp Dp) Px {
return ToPx(dp, r.scale) return ToPx(dp, r.scale.Scale())
} }
// toDp converts physical pixels to Dp using the renderer's scale. // toDp converts physical pixels to Dp using State's scale.
func (r *Renderer) toDp(px Px) Dp { func (r *Renderer) toDp(px Px) Dp {
return ToDp(px, r.scale) return ToDp(px, r.scale.Scale())
} }
// Draw iterates elements and draws each in slice order (back-to-front). // Draw iterates elements and draws each in slice order (back-to-front).
// It captures the initial constraints once at the start, then passes them // It captures the initial constraints once at the start, then passes them
// to each render function for consistent positioning. // to each render function for consistent positioning.
// The scale is set externally via SetScale() from State.Scale.
func (r *Renderer) Draw(gtx layout.Context, elems []Element) { func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
// Capture initial constraints once — before any clips modify them. // Capture initial constraints once — before any clips modify them.
// These are in physical pixels and serve as the window bounds for all positioning. // These are in physical pixels and serve as the window bounds for all positioning.
@ -262,7 +261,6 @@ func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints W
// Use captured constraints for positioning // Use captured constraints for positioning
windowW := constraints.Max.X windowW := constraints.Max.X
windowH := constraints.Max.Y
regXPx := r.toPx(reg.X) regXPx := r.toPx(reg.X)
regWPx := r.toPx(reg.W) regWPx := r.toPx(reg.W)
@ -275,24 +273,26 @@ func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints W
bottomBarHeightPx := r.toPx(Dp(24)) bottomBarHeightPx := r.toPx(Dp(24))
marginPx := r.toPx(Dp(10)) marginPx := r.toPx(Dp(10))
drawYPx := Px(windowH) - bottomBarHeightPx - marginPx drawYPx := Px(constraints.Max.Y) - bottomBarHeightPx - marginPx
drawY := r.toDp(drawYPx) drawY := r.toDp(drawYPx)
// ── Layout: shape all items ── col := color.NRGBA{R: 0, G: 0, B: 0, A: 255}
// Item 1: cursor position (left-aligned) — shape, position, draw
cursorShaped := r.ShapeText(gtx, bb.CursorPos, r.theme.FontSize) cursorShaped := r.ShapeText(gtx, bb.CursorPos, r.theme.FontSize)
cursorXDp := r.toDp(regXPx + r.toPx(Dp(8)))
r.DrawShapedText(gtx, cursorShaped, cursorXDp, drawY, col)
// Item 2: byte position (centered) — shape, position, draw
bytePosShaped := r.ShapeText(gtx, bb.BytePos, r.theme.FontSize) bytePosShaped := r.ShapeText(gtx, bb.BytePos, r.theme.FontSize)
byteXDp := r.toDp(regXPx + barW/2 - Px(bytePosShaped.Width/2))
r.DrawShapedText(gtx, bytePosShaped, byteXDp, drawY, col)
// Item 3: wrap (right-aligned) — shape, position, draw
wrapText := "Wrap:" + boolToString(bb.WordWrap) wrapText := "Wrap:" + boolToString(bb.WordWrap)
wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize) wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize)
wrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width))
// ── Position based on alignment ── r.DrawShapedText(gtx, wrapShaped, wrapXDp, drawY, col)
cursorXDp := r.toDp(regXPx + r.toPx(Dp(8)))
byteXDp := r.toDp(regXPx + barW/2 - Px(cursorShaped.Width/2))
wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width))
// ── Render ──
r.DrawShapedText(gtx, cursorShaped, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
r.DrawShapedText(gtx, bytePosShaped, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
r.DrawShapedText(gtx, wrapShaped, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
} }
func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) { func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) {

View File

@ -13,20 +13,18 @@ import (
"golang.org/x/image/math/fixed" "golang.org/x/image/math/fixed"
) )
// ShapedText holds layout results from a single ShapeText call. // ShapedText holds the result from ShapeText: width for positioning,
// The PathSpec and BitmapsCall can be drawn without further shaper access. // plus the path data ready to draw. Must be drawn before the next ShapeText call.
type ShapedText struct { type ShapedText struct {
Width int // total width in pixels Width int // total width in pixels
PathSpec clip.PathSpec // vector glyph path, ready to draw PathSpec clip.PathSpec // glyph path data
BitmapsCall op.CallOp // bitmap glyph call (empty if none) Bitmaps op.CallOp // bitmap glyphs (may be empty)
FirstX fixed.Int26_6 // first glyph X offset FirstX fixed.Int26_6 // first glyph X offset
FirstY int32 // first glyph Y offset FirstY int32 // first glyph Y offset
Text string
FontSize unit.Sp
} }
// ShapeText layouts text, measures width, and returns a ShapedText // ShapeText layouts text and returns width + path data for drawing.
// with an immutable path ready for drawing. The shaper is called once. // The returned ShapedText must be drawn before the next ShapeText call.
func (r *Renderer) ShapeText(gtx layout.Context, str string, size unit.Sp) ShapedText { func (r *Renderer) ShapeText(gtx layout.Context, str string, size unit.Sp) ShapedText {
r.shp.LayoutString(text.Parameters{ r.shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), PxPerEm: fixed.I(gtx.Sp(size)),
@ -38,71 +36,55 @@ func (r *Renderer) ShapeText(gtx layout.Context, str string, size unit.Sp) Shape
// Collect glyphs and measure width // Collect glyphs and measure width
var glyphs []text.Glyph var glyphs []text.Glyph
var cumWidth fixed.Int26_6 var cumWidth fixed.Int26_6
var firstX fixed.Int26_6
var firstY int32
for { for {
g, ok := r.shp.NextGlyph() g, ok := r.shp.NextGlyph()
if !ok { if !ok {
break break
} }
if len(glyphs) == 0 {
firstX = g.X
firstY = g.Y
}
cumWidth += g.Advance cumWidth += g.Advance
glyphs = append(glyphs, g) glyphs = append(glyphs, g)
} }
// Build lines and collect the last line's path spec // capture path spec
var lastSpec clip.PathSpec var spec clip.PathSpec
var lastBitmaps op.CallOp var bitmaps op.CallOp
var firstX fixed.Int26_6
var firstY int32 spec = r.shp.Shape(glyphs)
var line []text.Glyph bitmaps = r.shp.Bitmaps(glyphs)
for _, g := range glyphs {
if len(line) == 0 {
firstX = g.X
firstY = g.Y
}
line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 {
lastSpec = r.shp.Shape(line)
lastBitmaps = r.shp.Bitmaps(line)
line = line[:0]
}
}
if len(line) > 0 {
lastSpec = r.shp.Shape(line)
lastBitmaps = r.shp.Bitmaps(line)
}
return ShapedText{ return ShapedText{
Width: int(cumWidth >> 6), Width: int(cumWidth >> 6),
PathSpec: lastSpec, PathSpec: spec,
BitmapsCall: lastBitmaps, Bitmaps: bitmaps,
FirstX: firstX, FirstX: firstX,
FirstY: firstY, FirstY: firstY,
Text: str,
FontSize: size,
} }
} }
// DrawShapedText draws a pre-shaped text at the given position. // DrawShapedText draws a ShapedText at the given position.
// x, y are in Dp. Uses the stored path spec — zero shaper calls.
func (r *Renderer) DrawShapedText(gtx layout.Context, st ShapedText, x, y Dp, col color.NRGBA) { func (r *Renderer) DrawShapedText(gtx layout.Context, st ShapedText, x, y Dp, col color.NRGBA) {
xPx := r.toPx(x) xPx := r.toPx(x)
yPx := r.toPx(y) yPx := r.toPx(y)
m := op.Record(gtx.Ops) m := op.Record(gtx.Ops)
// Compute offset: position + first glyph offset
offX := float32(xPx) + float32(st.FirstX)/64.0 offX := float32(xPx) + float32(st.FirstX)/64.0
offY := float32(yPx) + float32(st.FirstY) offY := float32(yPx) + float32(st.FirstY)
t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops) t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops)
// Draw vector path
outline := clip.Outline{Path: st.PathSpec}.Op().Push(gtx.Ops) outline := clip.Outline{Path: st.PathSpec}.Op().Push(gtx.Ops)
paint.ColorOp{Color: col}.Add(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
outline.Pop() outline.Pop()
// Draw bitmap glyphs if st.Bitmaps != (op.CallOp{}) {
if st.BitmapsCall != (op.CallOp{}) { st.Bitmaps.Add(gtx.Ops)
st.BitmapsCall.Add(gtx.Ops)
} }
t.Pop() t.Pop()

BIN
pad

Binary file not shown.