diff --git a/cmd/pad/main.go b/cmd/pad/main.go index db1e1aa..94a6c18 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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() } } } diff --git a/go.mod b/go.mod index c36064c..b7d59be 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/internal/editor/logic.go b/internal/editor/logic.go index f334bf0..4caed14 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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. diff --git a/internal/editor/state.go b/internal/editor/state.go index 2d5f3dd..bb8e8a1 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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. diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index 3cbd05f..4c8c643 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -3,7 +3,7 @@ package icons import ( - _ "embed" + "embed" ) //go:embed copy.svg cut.svg paste.svg diff --git a/internal/ui/render.go b/internal/ui/render.go index 98c4a26..9b639c5 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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) { diff --git a/internal/ui/shaper.go b/internal/ui/shaper.go index 15ed22f..5d5da17 100644 --- a/internal/ui/shaper.go +++ b/internal/ui/shaper.go @@ -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() diff --git a/pad b/pad index 6e8bf2e..1f3c017 100755 Binary files a/pad and b/pad differ