diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 5c634bb..4bfdf96 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -34,12 +34,13 @@ func run(w *app.Window) error { shaper := text.NewShaper(text.WithCollection(gofont.Collection())) renderer := ui.New(ui.Theme{FontSize: 14}, shaper) - // Initial screen size - screenWidth := unit.Dp(390) - screenHeight := unit.Dp(844) + // Track window dimensions in pixels (from ConfigEvent) and DP (for logic layer) + var pixelW, pixelH int + var dpW, dpH ui.Dp + var scale float32 = 1.0 // updated on first FrameEvent - // Create logic instance - logic := editor.NewLogic(screenWidth, screenHeight) + // Create logic instance with initial DP size + logic := editor.NewLogic(dpW, dpH) // Shared state protected by mutex var mu sync.Mutex @@ -57,18 +58,37 @@ func run(w *app.Window) error { case app.DestroyEvent: return e.Err case app.ConfigEvent: - // Send config event to logic goroutine + // Store pixel dimensions from ConfigEvent + pixelW = e.Config.Size.X + pixelH = e.Config.Size.Y + // Convert to DP using current scale (default 1.0 until first FrameEvent) + 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: unit.Dp(e.Config.Size.X), - Height: unit.Dp(e.Config.Size.Y), + Width: dpW, + Height: dpH, } case app.FrameEvent: + // Update scale from the frame context + gtx := app.NewContext(&ops, e) + scale = gtx.Metric.PxPerDp + + // Reconvert pixel dimensions to DP with actual scale + dpW = ui.ToDp(ui.Px(pixelW), scale) + dpH = ui.ToDp(ui.Px(pixelH), scale) + + // Send updated config to logic goroutine + logic.ConfigChan() <- editor.ConfigEvent{ + Width: dpW, + Height: dpH, + } + // Acquire mutex, read frame, draw, release mutex mu.Lock() currentElems := elems mu.Unlock() - gtx := app.NewContext(&ops, e) renderer.Draw(gtx, currentElems) e.Frame(&ops) } diff --git a/doc/element_model.md b/doc/element_model.md index 2e7db59..a5e0710 100644 --- a/doc/element_model.md +++ b/doc/element_model.md @@ -23,15 +23,44 @@ Consistent with Gioui and standard 2D graphics: ### 2.2 Units -Consistent with Gioui's `unit` package: +The project uses distinct Go types to prevent accidental mixing of coordinate units at compile time: | Unit | Go Type | Use | |---|---|---| -| **Dp** (device-independent pixels) | `unit.Dp` (`float32`) | Element positions, sizes, spacing | -| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference) | -| **Px** (raw device pixels) | `int` | Never used in the logic layer; the renderer converts Dp→Px using `Metric.PxPerDp` | +| **Dp** (device-independent pixels) | `ui.Dp` (alias of `unit.Dp`) | Element positions, sizes, spacing in the logic/layout layer | +| **Px** (physical pixels) | `ui.Px` (alias of `int`) | Gio interop only (`gtx.Constraints`, `gtx.Dp()`, etc.) | +| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference; Gio's shaper requires `unit.Sp`) | -The logic layer works exclusively in `Dp` (positions/sizes) and `Sp` (fonts). The renderer converts to raw pixels using the `Metric` provided by Gioui's transaction context. +### Type Safety + +`ui.Dp` and `ui.Px` are distinct named types. The compiler prevents accidental mixing: + +```go +var pos ui.Dp = ui.Dp(100) +var winW ui.Px = ui.Px(780) + +// Compile error: invalid operation: pos + winW (mismatched types ui.Dp and ui.Px) +// Must use explicit conversion: +converted := ui.ToPx(pos, scale) // Dp → Px +``` + +### Conversion Functions + +```go +// Convert Dp to Px using the scale factor (pixels per DP) +func ToPx(dp Dp, scale float32) Px + +// Convert Px to Dp using the scale factor +func ToDp(px Px, scale float32) float32 +``` + +### Architecture + +- **Logic/Layout layer**: Works exclusively in `Dp`. Element regions, positions, and sizes are all in `Dp`. +- **Renderer**: Converts `Dp` → `Px` at Gio interop boundaries using `gtx.Metric.PxPerDp`. +- **main.go**: Converts `app.ConfigEvent` pixel dimensions to `Dp` before passing to the logic layer. + +The renderer captures `gtx.Constraints` once at the start of `Draw()` (before clips modify them) and passes this snapshot to all render functions for consistent positioning. ### 2.3 Element Interface diff --git a/doc/shaper_usage.md b/doc/shaper_usage.md index ad87634..bfcba9f 100644 --- a/doc/shaper_usage.md +++ b/doc/shaper_usage.md @@ -571,41 +571,61 @@ visualGap = (line2Baseline - line1Baseline) - (ascent1 + ascent2) With `LineHeightScale = 1.0`: `visualGap ≈ 0` (lines touch) With `LineHeightScale = 1.2`: `visualGap ≈ 0.2 * lineHeight` (20% padding) -## 9.5 Drawing Elements Inside the Visible Window +## 9.5 Coordinate System and Type Safety -When drawing elements like the BottomBar, the region coordinates computed from `screenHeight` may be outside the visible window area. This happens because `app.ConfigEvent` reports the **physical pixel size** of the window, which can be much larger than the requested DP size (e.g., macOS reports 1560×3376 pixels when we requested 390×844 DP). +The project uses distinct Go types (`ui.Dp` and `ui.Px`) to prevent accidental mixing of coordinate units at compile time. -**Y positioning**: Use `gtx.Constraints.Max.Y` (in physical pixels) to clamp the draw position to the visible area: +### Architecture -```go -scale := gtx.Metric.PxPerDp -bottomBarHeightPx := int(24 * scale) -marginPx := int(10 * scale) -drawYPx := gtx.Constraints.Max.Y - bottomBarHeightPx - marginPx -drawY := unit.Dp(float64(drawYPx) / float64(scale)) +``` +main.go Logic/Layout layer Renderer +───────── ───────────────── ───────── +app.ConfigEvent EditorLayout(ui.Dp) gtx.Constraints (Px) + → pixels → regions in Dp → converts Dp→Px + → convert to Dp at boundaries ``` -**X positioning**: `gtx.Constraints` are modified by previous widgets (e.g., `Min.X` changes after StatusBar draws). Use the element's own region for X positioning, clamped to `gtx.Constraints.Max.X`: +### Key Rules + +1. **Logic layer works exclusively in Dp**: `EditorLayout` accepts `ui.Dp` dimensions and returns regions in `ui.Dp`. No pixel conversions in the layout layer. + +2. **Renderer converts at boundaries**: The renderer captures `gtx.Constraints` once at the start of `Draw()` (before clips modify them), then passes this snapshot to all render functions. + +3. **Explicit conversions only**: Use `ui.ToPx(dp, scale)` and `ui.ToDp(px, scale)` for conversions. Never write `int(reg.X * scale)` — the type system prevents this. + +### BottomBar Positioning Example ```go -regXPx := int(float32(reg.X) * scale) -windowW := gtx.Constraints.Max.X -rightXPx := regXPx + int(float32(reg.W)*scale) -if rightXPx > windowW { - rightXPx = windowW +func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) { + reg := bb.Region() // Region in Dp + + // Convert region to pixels for positioning + regXPx := r.toPx(reg.X) + regWPx := r.toPx(reg.W) + + // Clamp right edge to window width + rightXPx := regXPx + regWPx + if rightXPx > Px(constraints.Max.X) { + rightXPx = Px(constraints.Max.X) + } + barW := rightXPx - regXPx + + // Position at bottom of visible window (in pixels) + drawYPx := Px(constraints.Max.Y) - r.toPx(Dp(24)) - r.toPx(Dp(10)) + drawY := r.toDp(drawYPx) + + // All X positions use pixel arithmetic, converted back to Dp for text rendering + cursorXDp := r.toDp(regXPx + r.toPx(Dp(8))) + byteXDp := r.toDp(regXPx + barW/2 - r.toPx(Dp(60))) + wordWrapXDp := r.toDp(regXPx + barW - r.toPx(Dp(80))) } -barW := rightXPx - regXPx -// Now use regXPx and barW for all X positions -cursorXPx := regXPx + int(8*scale) -byteXPx := regXPx + barW/2 - int(60*scale) -wordWrapXPx := regXPx + barW - int(80*scale) ``` -**Key insights**: -- `gtx.Constraints.Max.Y` is in **physical pixels** — use for Y positioning -- `gtx.Constraints.Min.X` is **modified by previous widgets** — don't use for X positioning -- Always convert DP to pixels using `gtx.Metric.PxPerDp` when mixing with pixel values -- Always clamp right edge to `gtx.Constraints.Max.X` +### Common Pitfalls + +- **Don't use `gtx.Constraints` in render functions**: They're modified by clips. Use the captured `WindowConstraints` snapshot. +- **Don't mix Dp and Px**: The compiler will catch it. Use explicit conversions. +- **Don't pass pixels as Dp to EditorLayout**: `main.go` must convert `app.ConfigEvent` pixels to Dp before passing to the logic layer. ## 10. Summary @@ -616,8 +636,9 @@ wordWrapXPx := regXPx + barW - int(80*scale) - **Baseline spacing** = `lineHeight * LineHeightScale` (default 1.2) - **For tight multi-line layouts**, use `LineHeightScale: 1.0` or set explicit `LineHeight` - **Visual gap** between lines = `baselineSpacing - (ascent1 + ascent2)` -- **gtx.Constraints.Max.X/Y** are in **physical pixels**, not DP. Convert using `gtx.Metric.PxPerDp`. -- **When drawing at the bottom of the window**, use `gtx.Constraints.Max.Y - elementHeight - margin` to clamp to visible area. +- **Logic layer works exclusively in Dp**. Renderer converts Dp→Px at Gio boundaries. +- **Capture `gtx.Constraints` once at start of `Draw()`** — clips modify them. +- **Use explicit conversions** (`ui.ToPx`, `ui.ToDp`) — never mix Dp and Px directly. - **Avoid** using `material.Label()` when you need precise glyph positions - **Reuse** glyph data for measurement, hit testing, and rendering - **Follow** Gio's `paintGlyph` as the reference implementation diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 6ebdfb5..7ad170d 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -1,15 +1,14 @@ package editor import ( - "gioui.org/unit" - "pad/internal/ui" ) // ConfigEvent represents a window configuration change (resize, orientation). +// Width and Height are in device-independent pixels (Dp). type ConfigEvent struct { - Width unit.Dp - Height unit.Dp + Width ui.Dp + Height ui.Dp } // InputEvent represents a user input event routed to an element. @@ -34,7 +33,8 @@ type Logic struct { } // NewLogic creates a new Logic instance. -func NewLogic(screenWidth, screenHeight unit.Dp) *Logic { +// screenWidth and screenHeight are in device-independent pixels (Dp). +func NewLogic(screenWidth, screenHeight ui.Dp) *Logic { return &Logic{ state: NewState(screenWidth, screenHeight), configChan: make(chan ConfigEvent), diff --git a/internal/editor/state.go b/internal/editor/state.go index 9b0c5a4..ac68ada 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -1,20 +1,20 @@ package editor import ( - "gioui.org/unit" - "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 unit.Dp - ScreenHeight unit.Dp + ScreenWidth ui.Dp + ScreenHeight ui.Dp Elems []ui.Element } // NewState creates a new State with initial editor layout. -func NewState(screenWidth, screenHeight unit.Dp) *State { +// screenWidth and screenHeight are in device-independent pixels (Dp). +func NewState(screenWidth, screenHeight ui.Dp) *State { return &State{ ScreenWidth: screenWidth, ScreenHeight: screenHeight, @@ -23,15 +23,14 @@ func NewState(screenWidth, screenHeight unit.Dp) *State { } // EditorLayout computes regions for the editor page. -// It takes screen dimensions and returns []Element. -func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element { +// It takes screen dimensions in Dp and returns []Element with regions in Dp. +// This function works exclusively in Dp — no pixel conversions. +func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element { // Margin to avoid clipping on macOS round corners - margin := unit.Dp(10) + margin := ui.Dp(10) // Compute StatusBar region - // Line 1: filename (24 DP) - // Line 2: icons (24 DP) - statusBarHeight := unit.Dp(48) + statusBarHeight := ui.Dp(48) statusBarRegion := ui.Region{ X: margin, Y: margin, @@ -40,7 +39,7 @@ func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element { } // Compute BottomBar region (fixed height, at bottom of screen) - bottomBarHeight := unit.Dp(24) + bottomBarHeight := ui.Dp(24) bottomBarY := screenHeight - margin - bottomBarHeight bottomBarRegion := ui.Region{ X: margin, diff --git a/internal/ui/element.go b/internal/ui/element.go index b3c35a3..a229bcb 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -3,9 +3,10 @@ package ui import "gioui.org/unit" // Region defines a screen area in device-independent pixels (Dp). +// All element positions and sizes use Dp for device independence. type Region struct { - X, Y unit.Dp - W, H unit.Dp + X, Y Dp + W, H Dp } // Element is the base interface for all UI elements. @@ -43,9 +44,10 @@ type InputEvent struct { } // ConfigEvent represents a window configuration change (resize, orientation). +// Width and Height are in device-independent pixels (Dp). type ConfigEvent struct { - Width unit.Dp - Height unit.Dp + Width Dp + Height Dp } // Label displays static text. @@ -83,10 +85,10 @@ type TextField struct { Placeholder string Focused bool Multiline bool - ScrollOffset unit.Dp + ScrollOffset Dp VisibleLines []Line WordWrap bool - WrapWidth unit.Dp + WrapWidth Dp } func (tf TextField) Region() Region { return tf.region } @@ -336,7 +338,7 @@ func (s Spacer) Visible() bool { return s.visible } func (s Spacer) ID() string { return s.id } // NewSpacer creates a visible Spacer element. -func NewSpacer(height unit.Dp) Spacer { +func NewSpacer(height Dp) Spacer { return Spacer{ region: Region{H: height}, visible: true, @@ -350,5 +352,5 @@ type Color struct { // Theme holds styling defaults for the UI. type Theme struct { - FontSize unit.Sp + FontSize unit.Sp // Gio's shaper requires unit.Sp for font sizes } diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 0013757..18ab683 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -1,61 +1,16 @@ package ui -import "gioui.org/unit" - const ( // Standard dimensions in Dp - statusBarLineHeight = unit.Dp(24) - statusBarFilenameLine = unit.Dp(24) - statusBarIconsLine = unit.Dp(24) - bottomBarHeight = unit.Dp(24) - iconSize = unit.Dp(24) - iconGap = unit.Dp(36) - padding = unit.Dp(8) - buttonPadding = unit.Dp(8) + statusBarLineHeight = Dp(24) + statusBarFilenameLine = Dp(24) + statusBarIconsLine = Dp(24) + bottomBarHeight = Dp(24) + iconSize = Dp(24) + iconGap = Dp(36) + padding = Dp(8) + buttonPadding = Dp(8) ) -// EditorLayout computes regions for the editor page. -// It takes screen dimensions and returns []Element. -func EditorLayout(screenWidth, screenHeight unit.Dp) []Element { - // Compute StatusBar region - // Line 1: filename (24 DP) - // Line 2: icons (24 DP) - statusBarHeight := statusBarFilenameLine + statusBarIconsLine - statusBarRegion := Region{ - X: 0, - Y: 0, - W: screenWidth, - H: statusBarHeight, - } - - // Compute BottomBar region (fixed height, at bottom of screen) - bottomBarY := screenHeight - bottomBarHeight - bottomBarRegion := Region{ - X: 0, - Y: bottomBarY, - W: screenWidth, - H: bottomBarHeight, - } - - // Create StatusBar with mocked filename - statusBar := NewStatusBar( - statusBarRegion, - "very_long_filename_that_does_not_fit_on_a_single_line.txt", - false, // FilenameExp - true, // CutCopy - false, // Copy - true, // Paste - false, // ConflictIcon - true, // Search - ) - - // Create BottomBar with mocked data - bottomBar := NewBottomBar( - bottomBarRegion, - "Ln 47, Col 12", - "1024 / 50000", - true, // WordWrap - ) - - return []Element{statusBar, bottomBar} -} +// NOTE: EditorLayout is defined in internal/editor/state.go. +// This file contains layout constants only. diff --git a/internal/ui/render.go b/internal/ui/render.go index 80e57a6..cbb4815 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -1,7 +1,6 @@ package ui import ( - "fmt" "image" "image/color" @@ -20,38 +19,65 @@ import ( type Renderer struct { theme Theme shp *text.Shaper + scale float32 // pixels per DP, from gtx.Metric.PxPerDp } // New creates a new Renderer. func New(th Theme, shp *text.Shaper) *Renderer { - return &Renderer{theme: th, shp: shp} + return &Renderer{theme: th, shp: shp, scale: 1.0} +} + +// setScale updates the renderer's scale factor from a layout.Context. +func (r *Renderer) setScale(gtx layout.Context) { + r.scale = gtx.Metric.PxPerDp +} + +// toPx converts Dp to physical pixels using the renderer's scale. +func (r *Renderer) toPx(dp Dp) Px { + return ToPx(dp, r.scale) +} + +// toDp converts physical pixels to Dp using the renderer's scale. +func (r *Renderer) toDp(px Px) Dp { + return ToDp(px, r.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. func (r *Renderer) Draw(gtx layout.Context, elems []Element) { + // Update scale from the frame context + r.setScale(gtx) + + // Capture initial constraints once — before any clips modify them. + // These are in physical pixels and serve as the window bounds for all positioning. + initialConstraints := WindowConstraints{ + Min: gtx.Constraints.Min, + Max: gtx.Constraints.Max, + } + for _, e := range elems { - fmt.Printf("[DEBUG Draw] elem type=%T visible=%v\n", e, e.Visible()) if !e.Visible() { continue } switch v := e.(type) { case Label: - r.drawLabel(gtx, v) + r.drawLabel(gtx, v, initialConstraints) case ListView: - r.drawListView(gtx, v) + r.drawListView(gtx, v, initialConstraints) case StatusBar: - r.drawStatusBar(gtx, v) + r.drawStatusBar(gtx, v, initialConstraints) case BottomBar: - r.drawBottomBar(gtx, v) + r.drawBottomBar(gtx, v, initialConstraints) case Button: - r.drawButton(gtx, v) + r.drawButton(gtx, v, initialConstraints) default: // unknown element type, skip } } } -func (r *Renderer) drawLabel(gtx layout.Context, l Label) { +func (r *Renderer) drawLabel(gtx layout.Context, l Label, _ WindowConstraints) { reg := l.Region() th := material.NewTheme() th.Shaper = r.shp @@ -60,34 +86,34 @@ func (r *Renderer) drawLabel(gtx layout.Context, l Label) { size = r.theme.FontSize } - // Position label at region origin - gtx.Constraints.Min.X = int(reg.X) - gtx.Constraints.Min.Y = int(reg.Y) + // Convert region to pixels for Gio interop + gtx.Constraints.Min.X = int(r.toPx(reg.X)) + gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) material.Label(th, size, l.Text).Layout(gtx) } -func (r *Renderer) drawListView(gtx layout.Context, lv ListView) { +func (r *Renderer) drawListView(gtx layout.Context, lv ListView, _ WindowConstraints) { reg := lv.Region() th := material.NewTheme() th.Shaper = r.shp - // Position list view at region origin - gtx.Constraints.Min.X = int(reg.X) - gtx.Constraints.Min.Y = int(reg.Y) + // Convert region to pixels for Gio interop + gtx.Constraints.Min.X = int(r.toPx(reg.X)) + gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) for _, item := range lv.Items { material.Label(th, r.theme.FontSize, item.Text).Layout(gtx) } } -func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) { +func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar, _ WindowConstraints) { reg := sb.Region() th := material.NewTheme() th.Shaper = r.shp - // Clip to status bar region + // Clip to status bar region (convert Dp to pixels) c := clip.Rect{ - Min: image.Point{X: int(reg.X), Y: int(reg.Y)}, - Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)}, + 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) // Line 1: Filename (truncated with ellipsis if needed) @@ -95,7 +121,7 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) { // Truncate filename only if it doesn't fit displayFilename := sb.Filename - availableWidth := int(reg.W - unit.Dp(8) - unit.Dp(40)) // left margin + right icons in DP + availableWidth := int(r.toPx(reg.W - Dp(8) - Dp(40))) // left margin + right icons in DP // Layout ellipsis once and get its width th.Shaper.LayoutString(text.Parameters{ @@ -116,101 +142,89 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) { spaceForText := availableWidth - ellipsisWidth // Layout filename, measure widths, and draw in a single pass - r.drawTruncatedText(gtx, th.Shaper, displayFilename, r.theme.FontSize, reg.X+unit.Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + r.drawTruncatedText(gtx, th.Shaper, displayFilename, r.theme.FontSize, reg.X+Dp(8), filenameLineY, availableWidth, spaceForText, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) // Line 2: Icons — positioned 20DP below filename baseline. - // This matches Gio's default line spacing (ascent+descent with LineHeightScale=1.0). - iconsLineY := filenameLineY + unit.Dp(20) + iconsLineY := filenameLineY + Dp(20) // Left side: Cut, Copy, Paste icons (always visible) - leftX := reg.X + unit.Dp(8) + leftX := reg.X + Dp(8) iconY := iconsLineY r.drawText(gtx, th.Shaper, "✂", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) - leftX += unit.Dp(36) + leftX += Dp(36) r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) - leftX += unit.Dp(36) + leftX += Dp(36) r.drawText(gtx, th.Shaper, "⎘", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) - leftX += unit.Dp(12) + leftX += Dp(12) // Right side: Conflict (conditional), Search (always visible) - rightX := reg.X + reg.W - unit.Dp(8) + rightX := reg.X + reg.W - Dp(8) if sb.ConflictIcon { - rightX -= unit.Dp(36) + rightX -= Dp(36) r.drawText(gtx, th.Shaper, "⚠", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) } - rightX -= unit.Dp(36) + rightX -= Dp(36) r.drawText(gtx, th.Shaper, "🔍", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) c.Pop() } -func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar) { +func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) { reg := bb.Region() - fmt.Printf("[DEBUG drawBottomBar] reg=(%d, %d, %d, %d)\n", gtx.Dp(reg.X), gtx.Dp(reg.Y), gtx.Dp(reg.W), gtx.Dp(reg.H)) - fmt.Printf("[DEBUG drawBottomBar] constraints=(%d, %d, %d, %d)\n", - gtx.Constraints.Min.X, gtx.Constraints.Min.Y, - gtx.Constraints.Max.X, gtx.Constraints.Max.Y) + // Use captured constraints for positioning — they are in pixels and + // represent the actual window bounds. Never use gtx.Constraints here + // because clips modify them. + windowW := constraints.Max.X + windowH := constraints.Max.Y - // Clamp BottomBar Y to visible window area. - // The region Y may be outside the visible area (e.g. 3308 DP when window is 1688px). - // gtx.Constraints.Max.Y is in physical pixels. Convert to DP using gtx.Metric.PxPerDp. - scale := gtx.Metric.PxPerDp - bottomBarHeightPx := int(24 * scale) - marginPx := int(10 * scale) - drawYPx := gtx.Constraints.Max.Y - bottomBarHeightPx - marginPx - drawY := unit.Dp(float64(drawYPx) / float64(scale)) - fmt.Printf("[DEBUG drawBottomBar] drawY=%d (clamped from %d)\n", gtx.Dp(drawY), gtx.Dp(reg.Y)) + // Convert region to pixels for positioning + regXPx := r.toPx(reg.X) + regWPx := r.toPx(reg.W) - // Use the BottomBar region for X positioning, clamped to visible window width. - // The region width (1520 DP = 3040px) exceeds the window width (780px). - regXPx := int(float32(reg.X) * scale) - windowW := gtx.Constraints.Max.X // Clamp right edge to window width - rightXPx := regXPx + int(float32(reg.W)*scale) - if rightXPx > windowW { - rightXPx = windowW + rightXPx := regXPx + regWPx + if rightXPx > Px(windowW) { + rightXPx = Px(windowW) } barW := rightXPx - regXPx + // Position BottomBar at bottom of visible window (in pixels) + bottomBarHeightPx := r.toPx(Dp(24)) + marginPx := r.toPx(Dp(10)) + drawYPx := Px(windowH) - bottomBarHeightPx - marginPx + + // Convert draw position back to Dp for text rendering + drawY := r.toDp(drawYPx) + // Left: Cursor position - cursorXPx := regXPx + int(8*scale) - cursorXDp := unit.Dp(float64(cursorXPx) / float64(scale)) - fmt.Printf("[DEBUG drawBottomBar] cursor=(%d, %d) text=%q\n", cursorXPx, drawYPx, bb.CursorPos) + cursorXPx := regXPx + r.toPx(Dp(8)) + cursorXDp := r.toDp(cursorXPx) r.drawText(gtx, r.shp, bb.CursorPos, r.theme.FontSize, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) // Center: Byte position - byteXPx := regXPx + barW/2 - int(60*scale) - byteXDp := unit.Dp(float64(byteXPx) / float64(scale)) - fmt.Printf("[DEBUG drawBottomBar] byte=(%d, %d) text=%q\n", byteXPx, drawYPx, bb.BytePos) + byteXPx := regXPx + barW/2 - r.toPx(Dp(60)) + byteXDp := r.toDp(byteXPx) r.drawText(gtx, r.shp, bb.BytePos, r.theme.FontSize, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) // Right: Word wrap button - wordWrapXPx := regXPx + barW - int(80*scale) - wordWrapXDp := unit.Dp(float64(wordWrapXPx) / float64(scale)) - fmt.Printf("[DEBUG drawBottomBar] wordwrap=(%d, %d) text=%q\n", wordWrapXPx, drawYPx, "W:"+boolToString(bb.WordWrap)) + wordWrapXPx := regXPx + barW - r.toPx(Dp(80)) + wordWrapXDp := r.toDp(wordWrapXPx) r.drawText(gtx, r.shp, "W:"+boolToString(bb.WordWrap), r.theme.FontSize, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) } -func (r *Renderer) drawButton(gtx layout.Context, btn Button) { +func (r *Renderer) drawButton(gtx layout.Context, btn Button, _ WindowConstraints) { reg := btn.Region() th := material.NewTheme() th.Shaper = r.shp - // Position button at region origin - gtx.Constraints.Min.X = int(reg.X) - gtx.Constraints.Min.Y = int(reg.Y) + // Convert region to pixels for Gio interop + gtx.Constraints.Min.X = int(r.toPx(reg.X)) + gtx.Constraints.Min.Y = int(r.toPx(reg.Y)) material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx) } -func (r *Renderer) drawIconButton(gtx layout.Context, th *material.Theme, icon string, x, y unit.Dp) { - // Position icon button at (x, y) - gtx.Constraints.Min.X = int(x) - gtx.Constraints.Min.Y = int(y) - material.Label(th, r.theme.FontSize, icon).Layout(gtx) -} - func boolToString(b bool) string { if b { return "On" @@ -219,7 +233,7 @@ func boolToString(b bool) string { } // drawTruncatedText layouts text, measures widths, truncates if needed, and draws in a single pass. -func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, availableWidth, spaceForText int, col color.NRGBA) { +func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, availableWidth, spaceForText int, col color.NRGBA) { // Layout text with width constraints so the shaper doesn't wrap every character. shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), @@ -265,7 +279,7 @@ func (r *Renderer) drawTruncatedText(gtx layout.Context, shp *text.Shaper, str s // drawText draws text using the same approach as Gio's textView: // layout text, iterate glyphs, buffer into lines, and draw using shaper.Shape(). -func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y unit.Dp, col color.NRGBA) { +func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp, x, y Dp, col color.NRGBA) { // Layout text with width constraints so the shaper doesn't wrap every character. shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)), @@ -277,7 +291,11 @@ func (r *Renderer) drawText(gtx layout.Context, shp *text.Shaper, str string, si } // drawLineText draws already-laid-out glyphs using shaper.Shape() and shaper.Bitmaps(). -func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y unit.Dp, col color.NRGBA) { +func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y Dp, col color.NRGBA) { + // Convert Dp to pixels for glyph positioning + xPx := r.toPx(x) + yPx := r.toPx(y) + // Match Gio's textView: record into a macro for clipping m := op.Record(gtx.Ops) var glyphs [32]text.Glyph @@ -285,12 +303,12 @@ func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y unit. for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() { line = append(line, g) if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 { - r.drawLine(gtx, shp, line, x, y, col) + r.drawLine(gtx, shp, line, xPx, yPx, col) line = line[:0] } } if len(line) > 0 { - r.drawLine(gtx, shp, line, x, y, col) + r.drawLine(gtx, shp, line, xPx, yPx, col) } call := m.Stop() call.Add(gtx.Ops) @@ -298,7 +316,7 @@ func (r *Renderer) drawLineText(gtx layout.Context, shp *text.Shaper, x, y unit. // drawLine draws a line of glyphs using shaper.Shape() and shaper.Bitmaps(). // Matches Gio's paintGlyph exactly: offset by (x + first.X, y + first.Y). -func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y unit.Dp, col color.NRGBA) { +func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Glyph, x, y Px, col color.NRGBA) { if len(line) == 0 { return } @@ -308,8 +326,8 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl // at the desired document position. Matches Gio's paintGlyph: // lineOff = (glyph.X, glyph.Y) - viewport.Min // op.Affine(f32.Affine2D{}.Offset(lineOff)) - offX := float32(gtx.Dp(x)) + float32(first.X)/64.0 - offY := float32(gtx.Dp(y)) + float32(first.Y) + offX := float32(x) + float32(first.X)/64.0 + offY := float32(y) + float32(first.Y) t := op.Affine(f32.Affine2D{}.Offset(f32.Pt(offX, offY))).Push(gtx.Ops) // Draw vector glyphs @@ -327,26 +345,9 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl t.Pop() } -// charWidths returns the cumulative width after each rune in str. -func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int { - // Match Gio's textView: PxPerEm = font size in device pixels - // Must set MinWidth/MaxWidth/MaxLines or shaper wraps every character. - shp.LayoutString(text.Parameters{ - PxPerEm: fixed.I(gtx.Sp(size)), - MinWidth: 0, - MaxWidth: 1000, - MaxLines: 1, - }, str) - var widths []int - var cumWidth fixed.Int26_6 = 0 - for { - g, ok := shp.NextGlyph() - if !ok { - break - } - cumWidth += g.Advance - widths = append(widths, int(cumWidth>>6)) - } - return widths +// WindowConstraints tracks the initial window constraints captured at the start of Draw. +// All elements are positioned relative to these bounds to ensure consistency. +type WindowConstraints struct { + Min image.Point + Max image.Point } - diff --git a/internal/ui/unit.go b/internal/ui/unit.go new file mode 100644 index 0000000..96b2dc0 --- /dev/null +++ b/internal/ui/unit.go @@ -0,0 +1,65 @@ +package ui + +import "gioui.org/unit" + +// Dp represents device-independent pixels. Use for all element positions, +// sizes, and spacing in the logic/layout layer. +type Dp unit.Dp + +// Px represents physical device pixels. Use only when interfacing with +// Gio's layout.Context (gtx.Constraints, gtx.Dp(), etc.). +type Px int + +// ToDp converts physical pixels to device-independent pixels using the +// given scale factor (pixels per DP). Typically from gtx.Metric.PxPerDp. +func ToDp(px Px, scale float32) Dp { + return Dp(float32(px) / scale) +} + +// ToPx converts device-independent pixels to physical pixels using the +// given scale factor (pixels per DP). +func ToPx(dp Dp, scale float32) Px { + return Px(float32(dp) * scale) +} + +// PxPerDp returns the scale factor: how many physical pixels per DP. +// Use this to convert between Dp and Px. +func PxPerDp(scale float32) float32 { return scale } + +// --- Gio interop helpers --- + +// DpToPx converts a gioui unit.Dp to our Px using the scale factor. +func DpToPx(d unit.Dp, scale float32) Px { + return Px(float32(d) * scale) +} + +// PxToDp converts our Px to a gioui unit.Dp using the scale factor. +func PxToDp(p Px, scale float32) unit.Dp { + return unit.Dp(float32(p) / scale) +} + +// RegionPx is a region in physical pixels. Used for Gio interop only. +type RegionPx struct { + X, Y Px + W, H Px +} + +// ToDp converts a RegionPx to a Region (Dp) using the scale factor. +func (r RegionPx) ToDp(scale float32) Region { + return Region{ + X: ToDp(r.X, scale), + Y: ToDp(r.Y, scale), + W: ToDp(r.W, scale), + H: ToDp(r.H, scale), + } +} + +// FromDp converts a Region (Dp) to a RegionPx using the scale factor. +func FromDp(r Region, scale float32) RegionPx { + return RegionPx{ + X: ToPx(r.X, scale), + Y: ToPx(r.Y, scale), + W: ToPx(r.W, scale), + H: ToPx(r.H, scale), + } +}