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
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
initialDPW := ui.Dp(390)
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
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
var mu sync.Mutex
var elems []ui.Element
@ -53,6 +54,7 @@ func run(w *app.Window) error {
// Start frame receiver goroutine
go frameReceiver(w, &mu, &elems, logic.FrameChan())
scale := float32(1.0)
// Main event loop
for {
@ -63,9 +65,9 @@ func run(w *app.Window) error {
// Store pixel dimensions from ConfigEvent
pixelW = e.Config.Size.X
pixelH = e.Config.Size.Y
// Convert to DP using current scale (starts at 1.0)
dpW := ui.ToDp(ui.Px(pixelW), 1.0)
dpH := ui.ToDp(ui.Px(pixelH), 1.0)
// Convert to DP using current scale
dpW := ui.ToDp(ui.Px(pixelW), scale)
dpH := ui.ToDp(ui.Px(pixelH), scale)
// Send config event to logic goroutine (in DP)
logic.ConfigChan() <- editor.ConfigEvent{
Width: dpW,
@ -75,28 +77,21 @@ func run(w *app.Window) error {
gtx := app.NewContext(&ops, e)
// Get scale from State — this is updated by ScaleEvent
newScale := logic.Scale()
newScale := gtx.Metric.PxPerDp
// Convert pixel dimensions to DP using actual scale
dpW := ui.ToDp(ui.Px(pixelW), newScale)
dpH := ui.ToDp(ui.Px(pixelH), newScale)
// Send updated config to logic goroutine
logic.ConfigChan() <- editor.ConfigEvent{
Width: dpW,
Height: dpH,
if newScale != scale {
// Send updated scale to logic goroutine
logic.ConfigChan() <- editor.ScaleEvent{newScale}
scale = newScale
}
// Update renderer with current scale from State
renderer.SetScale(newScale)
// Acquire mutex, read frame, draw, release mutex
mu.Lock()
currentElems := elems
mu.Unlock()
renderer.Draw(gtx, currentElems)
e.Frame(&ops)
mu.Unlock()
}
}
}

6
go.mod
View File

@ -2,13 +2,15 @@ module pad
go 1.24.2
require gioui.org v0.9.0
require (
gioui.org v0.9.0
golang.org/x/image v0.26.0
)
require (
gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.3.0 // 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/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).
func (l *Logic) Scale() float32 {
l.mu.Lock()
defer l.mu.Unlock()
return l.state.Scale
return l.state.Scale()
}
// Run runs the logic goroutine loop.

View File

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

View File

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

View File

@ -25,13 +25,18 @@ var iconFS embed.FS
type Renderer struct {
theme Theme
shp *text.Shaper
scale float32 // pixels per DP, from State.Scale
scale ScaleProvider
icons map[string]image.Image
}
// ScaleProvider provides access to the current scale factor.
type ScaleProvider interface {
Scale() float32
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer {
r := &Renderer{theme: th, shp: shp, scale: 1.0, icons: make(map[string]image.Image)}
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
}
@ -49,25 +54,19 @@ func (r *Renderer) loadIcons() {
r.icons["cut"] = img
}
// SetScale updates the renderer's scale factor from State.Scale.
func (r *Renderer) SetScale(scale float32) {
r.scale = scale
}
// toPx converts Dp to physical pixels using the renderer's scale.
// toPx converts Dp to physical pixels using State's scale.
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 {
return ToDp(px, r.scale)
return ToDp(px, r.scale.Scale())
}
// Draw iterates elements and draws each in slice order (back-to-front).
// It captures the initial constraints once at the start, then passes them
// 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) {
// Capture initial constraints once — before any clips modify them.
// 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
windowW := constraints.Max.X
windowH := constraints.Max.Y
regXPx := r.toPx(reg.X)
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))
marginPx := r.toPx(Dp(10))
drawYPx := Px(windowH) - bottomBarHeightPx - marginPx
drawYPx := Px(constraints.Max.Y) - bottomBarHeightPx - marginPx
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)
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)
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)
wrapShaped := r.ShapeText(gtx, wrapText, r.theme.FontSize)
// ── Position based on alignment ──
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})
wrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(8)) - Px(wrapShaped.Width))
r.DrawShapedText(gtx, wrapShaped, wrapXDp, drawY, col)
}
func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) {

View File

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

BIN
pad

Binary file not shown.