Introduce distinct Dp/Px types to prevent coordinate mixing at compile time

- Add ui.Dp and ui.Px as distinct named types
- Logic layer works exclusively in Dp (positions, sizes, regions)
- Renderer converts Dp→Px at Gio interop boundaries
- Capture gtx.Constraints once at start of Draw() for consistent positioning
- main.go converts app.ConfigEvent pixels to Dp before passing to logic layer
- Update documentation with new coordinate system architecture
This commit is contained in:
Greg Pomerantz 2026-05-09 18:40:47 -04:00
parent f563a515e6
commit b8c6878f46
9 changed files with 315 additions and 223 deletions

View File

@ -34,12 +34,13 @@ func run(w *app.Window) error {
shaper := text.NewShaper(text.WithCollection(gofont.Collection())) shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
renderer := ui.New(ui.Theme{FontSize: 14}, shaper) renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
// Initial screen size // Track window dimensions in pixels (from ConfigEvent) and DP (for logic layer)
screenWidth := unit.Dp(390) var pixelW, pixelH int
screenHeight := unit.Dp(844) var dpW, dpH ui.Dp
var scale float32 = 1.0 // updated on first FrameEvent
// Create logic instance // Create logic instance with initial DP size
logic := editor.NewLogic(screenWidth, screenHeight) logic := editor.NewLogic(dpW, dpH)
// Shared state protected by mutex // Shared state protected by mutex
var mu sync.Mutex var mu sync.Mutex
@ -57,18 +58,37 @@ func run(w *app.Window) error {
case app.DestroyEvent: case app.DestroyEvent:
return e.Err return e.Err
case app.ConfigEvent: 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{ logic.ConfigChan() <- editor.ConfigEvent{
Width: unit.Dp(e.Config.Size.X), Width: dpW,
Height: unit.Dp(e.Config.Size.Y), Height: dpH,
} }
case app.FrameEvent: 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 // Acquire mutex, read frame, draw, release mutex
mu.Lock() mu.Lock()
currentElems := elems currentElems := elems
mu.Unlock() mu.Unlock()
gtx := app.NewContext(&ops, e)
renderer.Draw(gtx, currentElems) renderer.Draw(gtx, currentElems)
e.Frame(&ops) e.Frame(&ops)
} }

View File

@ -23,15 +23,44 @@ Consistent with Gioui and standard 2D graphics:
### 2.2 Units ### 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 | | Unit | Go Type | Use |
|---|---|---| |---|---|---|
| **Dp** (device-independent pixels) | `unit.Dp` (`float32`) | Element positions, sizes, spacing | | **Dp** (device-independent pixels) | `ui.Dp` (alias of `unit.Dp`) | Element positions, sizes, spacing in the logic/layout layer |
| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference) | | **Px** (physical pixels) | `ui.Px` (alias of `int`) | Gio interop only (`gtx.Constraints`, `gtx.Dp()`, etc.) |
| **Px** (raw device pixels) | `int` | Never used in the logic layer; the renderer converts Dp→Px using `Metric.PxPerDp` | | **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 ### 2.3 Element Interface

View File

@ -571,41 +571,61 @@ visualGap = (line2Baseline - line1Baseline) - (ascent1 + ascent2)
With `LineHeightScale = 1.0`: `visualGap ≈ 0` (lines touch) With `LineHeightScale = 1.0`: `visualGap ≈ 0` (lines touch)
With `LineHeightScale = 1.2`: `visualGap ≈ 0.2 * lineHeight` (20% padding) 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 main.go Logic/Layout layer Renderer
bottomBarHeightPx := int(24 * scale) ───────── ───────────────── ─────────
marginPx := int(10 * scale) app.ConfigEvent EditorLayout(ui.Dp) gtx.Constraints (Px)
drawYPx := gtx.Constraints.Max.Y - bottomBarHeightPx - marginPx → pixels → regions in Dp → converts Dp→Px
drawY := unit.Dp(float64(drawYPx) / float64(scale)) → 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 ```go
regXPx := int(float32(reg.X) * scale) func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) {
windowW := gtx.Constraints.Max.X reg := bb.Region() // Region in Dp
rightXPx := regXPx + int(float32(reg.W)*scale)
if rightXPx > windowW { // Convert region to pixels for positioning
rightXPx = windowW 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 barW := rightXPx - regXPx
// Now use regXPx and barW for all X positions
cursorXPx := regXPx + int(8*scale) // Position at bottom of visible window (in pixels)
byteXPx := regXPx + barW/2 - int(60*scale) drawYPx := Px(constraints.Max.Y) - r.toPx(Dp(24)) - r.toPx(Dp(10))
wordWrapXPx := regXPx + barW - int(80*scale) 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)))
}
``` ```
**Key insights**: ### Common Pitfalls
- `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 - **Don't use `gtx.Constraints` in render functions**: They're modified by clips. Use the captured `WindowConstraints` snapshot.
- Always convert DP to pixels using `gtx.Metric.PxPerDp` when mixing with pixel values - **Don't mix Dp and Px**: The compiler will catch it. Use explicit conversions.
- Always clamp right edge to `gtx.Constraints.Max.X` - **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 ## 10. Summary
@ -616,8 +636,9 @@ wordWrapXPx := regXPx + barW - int(80*scale)
- **Baseline spacing** = `lineHeight * LineHeightScale` (default 1.2) - **Baseline spacing** = `lineHeight * LineHeightScale` (default 1.2)
- **For tight multi-line layouts**, use `LineHeightScale: 1.0` or set explicit `LineHeight` - **For tight multi-line layouts**, use `LineHeightScale: 1.0` or set explicit `LineHeight`
- **Visual gap** between lines = `baselineSpacing - (ascent1 + ascent2)` - **Visual gap** between lines = `baselineSpacing - (ascent1 + ascent2)`
- **gtx.Constraints.Max.X/Y** are in **physical pixels**, not DP. Convert using `gtx.Metric.PxPerDp`. - **Logic layer works exclusively in Dp**. Renderer converts Dp→Px at Gio boundaries.
- **When drawing at the bottom of the window**, use `gtx.Constraints.Max.Y - elementHeight - margin` to clamp to visible area. - **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 - **Avoid** using `material.Label()` when you need precise glyph positions
- **Reuse** glyph data for measurement, hit testing, and rendering - **Reuse** glyph data for measurement, hit testing, and rendering
- **Follow** Gio's `paintGlyph` as the reference implementation - **Follow** Gio's `paintGlyph` as the reference implementation

View File

@ -1,15 +1,14 @@
package editor package editor
import ( import (
"gioui.org/unit"
"pad/internal/ui" "pad/internal/ui"
) )
// ConfigEvent represents a window configuration change (resize, orientation). // ConfigEvent represents a window configuration change (resize, orientation).
// Width and Height are in device-independent pixels (Dp).
type ConfigEvent struct { type ConfigEvent struct {
Width unit.Dp Width ui.Dp
Height unit.Dp Height ui.Dp
} }
// InputEvent represents a user input event routed to an element. // InputEvent represents a user input event routed to an element.
@ -34,7 +33,8 @@ type Logic struct {
} }
// NewLogic creates a new Logic instance. // 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{ return &Logic{
state: NewState(screenWidth, screenHeight), state: NewState(screenWidth, screenHeight),
configChan: make(chan ConfigEvent), configChan: make(chan ConfigEvent),

View File

@ -1,20 +1,20 @@
package editor package editor
import ( import (
"gioui.org/unit"
"pad/internal/ui" "pad/internal/ui"
) )
// State holds all application state owned by the logic goroutine. // State holds all application state owned by the logic goroutine.
// All dimensions are in device-independent pixels (Dp).
type State struct { type State struct {
ScreenWidth unit.Dp ScreenWidth ui.Dp
ScreenHeight unit.Dp ScreenHeight ui.Dp
Elems []ui.Element Elems []ui.Element
} }
// NewState creates a new State with initial editor layout. // 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{ return &State{
ScreenWidth: screenWidth, ScreenWidth: screenWidth,
ScreenHeight: screenHeight, ScreenHeight: screenHeight,
@ -23,15 +23,14 @@ func NewState(screenWidth, screenHeight unit.Dp) *State {
} }
// EditorLayout computes regions for the editor page. // EditorLayout computes regions for the editor page.
// It takes screen dimensions and returns []Element. // It takes screen dimensions in Dp and returns []Element with regions in Dp.
func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element { // 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 to avoid clipping on macOS round corners
margin := unit.Dp(10) margin := ui.Dp(10)
// Compute StatusBar region // Compute StatusBar region
// Line 1: filename (24 DP) statusBarHeight := ui.Dp(48)
// Line 2: icons (24 DP)
statusBarHeight := unit.Dp(48)
statusBarRegion := ui.Region{ statusBarRegion := ui.Region{
X: margin, X: margin,
Y: margin, Y: margin,
@ -40,7 +39,7 @@ func EditorLayout(screenWidth, screenHeight unit.Dp) []ui.Element {
} }
// Compute BottomBar region (fixed height, at bottom of screen) // Compute BottomBar region (fixed height, at bottom of screen)
bottomBarHeight := unit.Dp(24) bottomBarHeight := ui.Dp(24)
bottomBarY := screenHeight - margin - bottomBarHeight bottomBarY := screenHeight - margin - bottomBarHeight
bottomBarRegion := ui.Region{ bottomBarRegion := ui.Region{
X: margin, X: margin,

View File

@ -3,9 +3,10 @@ package ui
import "gioui.org/unit" import "gioui.org/unit"
// Region defines a screen area in device-independent pixels (Dp). // Region defines a screen area in device-independent pixels (Dp).
// All element positions and sizes use Dp for device independence.
type Region struct { type Region struct {
X, Y unit.Dp X, Y Dp
W, H unit.Dp W, H Dp
} }
// Element is the base interface for all UI elements. // Element is the base interface for all UI elements.
@ -43,9 +44,10 @@ type InputEvent struct {
} }
// ConfigEvent represents a window configuration change (resize, orientation). // ConfigEvent represents a window configuration change (resize, orientation).
// Width and Height are in device-independent pixels (Dp).
type ConfigEvent struct { type ConfigEvent struct {
Width unit.Dp Width Dp
Height unit.Dp Height Dp
} }
// Label displays static text. // Label displays static text.
@ -83,10 +85,10 @@ type TextField struct {
Placeholder string Placeholder string
Focused bool Focused bool
Multiline bool Multiline bool
ScrollOffset unit.Dp ScrollOffset Dp
VisibleLines []Line VisibleLines []Line
WordWrap bool WordWrap bool
WrapWidth unit.Dp WrapWidth Dp
} }
func (tf TextField) Region() Region { return tf.region } 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 } func (s Spacer) ID() string { return s.id }
// NewSpacer creates a visible Spacer element. // NewSpacer creates a visible Spacer element.
func NewSpacer(height unit.Dp) Spacer { func NewSpacer(height Dp) Spacer {
return Spacer{ return Spacer{
region: Region{H: height}, region: Region{H: height},
visible: true, visible: true,
@ -350,5 +352,5 @@ type Color struct {
// Theme holds styling defaults for the UI. // Theme holds styling defaults for the UI.
type Theme struct { type Theme struct {
FontSize unit.Sp FontSize unit.Sp // Gio's shaper requires unit.Sp for font sizes
} }

View File

@ -1,61 +1,16 @@
package ui package ui
import "gioui.org/unit"
const ( const (
// Standard dimensions in Dp // Standard dimensions in Dp
statusBarLineHeight = unit.Dp(24) statusBarLineHeight = Dp(24)
statusBarFilenameLine = unit.Dp(24) statusBarFilenameLine = Dp(24)
statusBarIconsLine = unit.Dp(24) statusBarIconsLine = Dp(24)
bottomBarHeight = unit.Dp(24) bottomBarHeight = Dp(24)
iconSize = unit.Dp(24) iconSize = Dp(24)
iconGap = unit.Dp(36) iconGap = Dp(36)
padding = unit.Dp(8) padding = Dp(8)
buttonPadding = unit.Dp(8) buttonPadding = Dp(8)
) )
// EditorLayout computes regions for the editor page. // NOTE: EditorLayout is defined in internal/editor/state.go.
// It takes screen dimensions and returns []Element. // This file contains layout constants only.
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}
}

View File

@ -1,7 +1,6 @@
package ui package ui
import ( import (
"fmt"
"image" "image"
"image/color" "image/color"
@ -20,38 +19,65 @@ import (
type Renderer struct { type Renderer struct {
theme Theme theme Theme
shp *text.Shaper shp *text.Shaper
scale float32 // pixels per DP, from gtx.Metric.PxPerDp
} }
// 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) *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). // 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) { 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 { for _, e := range elems {
fmt.Printf("[DEBUG Draw] elem type=%T visible=%v\n", e, e.Visible())
if !e.Visible() { if !e.Visible() {
continue continue
} }
switch v := e.(type) { switch v := e.(type) {
case Label: case Label:
r.drawLabel(gtx, v) r.drawLabel(gtx, v, initialConstraints)
case ListView: case ListView:
r.drawListView(gtx, v) r.drawListView(gtx, v, initialConstraints)
case StatusBar: case StatusBar:
r.drawStatusBar(gtx, v) r.drawStatusBar(gtx, v, initialConstraints)
case BottomBar: case BottomBar:
r.drawBottomBar(gtx, v) r.drawBottomBar(gtx, v, initialConstraints)
case Button: case Button:
r.drawButton(gtx, v) r.drawButton(gtx, v, initialConstraints)
default: default:
// unknown element type, skip // 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() reg := l.Region()
th := material.NewTheme() th := material.NewTheme()
th.Shaper = r.shp th.Shaper = r.shp
@ -60,34 +86,34 @@ func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
size = r.theme.FontSize size = r.theme.FontSize
} }
// Position label at region origin // Convert region to pixels for Gio interop
gtx.Constraints.Min.X = int(reg.X) gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(reg.Y) gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
material.Label(th, size, l.Text).Layout(gtx) 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() reg := lv.Region()
th := material.NewTheme() th := material.NewTheme()
th.Shaper = r.shp th.Shaper = r.shp
// Position list view at region origin // Convert region to pixels for Gio interop
gtx.Constraints.Min.X = int(reg.X) gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(reg.Y) gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
for _, item := range lv.Items { for _, item := range lv.Items {
material.Label(th, r.theme.FontSize, item.Text).Layout(gtx) 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() reg := sb.Region()
th := material.NewTheme() th := material.NewTheme()
th.Shaper = r.shp th.Shaper = r.shp
// Clip to status bar region // Clip to status bar region (convert Dp to pixels)
c := clip.Rect{ c := clip.Rect{
Min: image.Point{X: int(reg.X), Y: int(reg.Y)}, Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)}, Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops) }.Push(gtx.Ops)
// Line 1: Filename (truncated with ellipsis if needed) // 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 // Truncate filename only if it doesn't fit
displayFilename := sb.Filename 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 // Layout ellipsis once and get its width
th.Shaper.LayoutString(text.Parameters{ th.Shaper.LayoutString(text.Parameters{
@ -116,101 +142,89 @@ func (r *Renderer) drawStatusBar(gtx layout.Context, sb StatusBar) {
spaceForText := availableWidth - ellipsisWidth spaceForText := availableWidth - ellipsisWidth
// Layout filename, measure widths, and draw in a single pass // 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. // Line 2: Icons — positioned 20DP below filename baseline.
// This matches Gio's default line spacing (ascent+descent with LineHeightScale=1.0). iconsLineY := filenameLineY + Dp(20)
iconsLineY := filenameLineY + unit.Dp(20)
// Left side: Cut, Copy, Paste icons (always visible) // Left side: Cut, Copy, Paste icons (always visible)
leftX := reg.X + unit.Dp(8) leftX := reg.X + Dp(8)
iconY := iconsLineY iconY := iconsLineY
r.drawText(gtx, th.Shaper, "✂", r.theme.FontSize, leftX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) 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}) 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}) 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) // 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 { 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}) 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}) r.drawText(gtx, th.Shaper, "🔍", r.theme.FontSize, rightX, iconY, color.NRGBA{R: 0, G: 0, B: 0, A: 255})
c.Pop() 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() 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)) // Use captured constraints for positioning — they are in pixels and
fmt.Printf("[DEBUG drawBottomBar] constraints=(%d, %d, %d, %d)\n", // represent the actual window bounds. Never use gtx.Constraints here
gtx.Constraints.Min.X, gtx.Constraints.Min.Y, // because clips modify them.
gtx.Constraints.Max.X, gtx.Constraints.Max.Y) windowW := constraints.Max.X
windowH := constraints.Max.Y
// Clamp BottomBar Y to visible window area. // Convert region to pixels for positioning
// The region Y may be outside the visible area (e.g. 3308 DP when window is 1688px). regXPx := r.toPx(reg.X)
// gtx.Constraints.Max.Y is in physical pixels. Convert to DP using gtx.Metric.PxPerDp. regWPx := r.toPx(reg.W)
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))
// 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 // Clamp right edge to window width
rightXPx := regXPx + int(float32(reg.W)*scale) rightXPx := regXPx + regWPx
if rightXPx > windowW { if rightXPx > Px(windowW) {
rightXPx = windowW rightXPx = Px(windowW)
} }
barW := rightXPx - regXPx 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 // Left: Cursor position
cursorXPx := regXPx + int(8*scale) cursorXPx := regXPx + r.toPx(Dp(8))
cursorXDp := unit.Dp(float64(cursorXPx) / float64(scale)) cursorXDp := r.toDp(cursorXPx)
fmt.Printf("[DEBUG drawBottomBar] cursor=(%d, %d) text=%q\n", cursorXPx, drawYPx, bb.CursorPos)
r.drawText(gtx, r.shp, bb.CursorPos, r.theme.FontSize, cursorXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) 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 // Center: Byte position
byteXPx := regXPx + barW/2 - int(60*scale) byteXPx := regXPx + barW/2 - r.toPx(Dp(60))
byteXDp := unit.Dp(float64(byteXPx) / float64(scale)) byteXDp := r.toDp(byteXPx)
fmt.Printf("[DEBUG drawBottomBar] byte=(%d, %d) text=%q\n", byteXPx, drawYPx, bb.BytePos)
r.drawText(gtx, r.shp, bb.BytePos, r.theme.FontSize, byteXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) 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 // Right: Word wrap button
wordWrapXPx := regXPx + barW - int(80*scale) wordWrapXPx := regXPx + barW - r.toPx(Dp(80))
wordWrapXDp := unit.Dp(float64(wordWrapXPx) / float64(scale)) wordWrapXDp := r.toDp(wordWrapXPx)
fmt.Printf("[DEBUG drawBottomBar] wordwrap=(%d, %d) text=%q\n", wordWrapXPx, drawYPx, "W:"+boolToString(bb.WordWrap))
r.drawText(gtx, r.shp, "W:"+boolToString(bb.WordWrap), r.theme.FontSize, wordWrapXDp, drawY, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) 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() reg := btn.Region()
th := material.NewTheme() th := material.NewTheme()
th.Shaper = r.shp th.Shaper = r.shp
// Position button at region origin // Convert region to pixels for Gio interop
gtx.Constraints.Min.X = int(reg.X) gtx.Constraints.Min.X = int(r.toPx(reg.X))
gtx.Constraints.Min.Y = int(reg.Y) gtx.Constraints.Min.Y = int(r.toPx(reg.Y))
material.Label(th, r.theme.FontSize, btn.Text).Layout(gtx) 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 { func boolToString(b bool) string {
if b { if b {
return "On" 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. // 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. // Layout text with width constraints so the shaper doesn't wrap every character.
shp.LayoutString(text.Parameters{ shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), 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: // drawText draws text using the same approach as Gio's textView:
// layout text, iterate glyphs, buffer into lines, and draw using shaper.Shape(). // 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. // Layout text with width constraints so the shaper doesn't wrap every character.
shp.LayoutString(text.Parameters{ shp.LayoutString(text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)), 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(). // 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 // Match Gio's textView: record into a macro for clipping
m := op.Record(gtx.Ops) m := op.Record(gtx.Ops)
var glyphs [32]text.Glyph 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() { for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() {
line = append(line, g) line = append(line, g)
if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 { 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] line = line[:0]
} }
} }
if len(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 := m.Stop()
call.Add(gtx.Ops) 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(). // 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). // 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 { if len(line) == 0 {
return 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: // at the desired document position. Matches Gio's paintGlyph:
// lineOff = (glyph.X, glyph.Y) - viewport.Min // lineOff = (glyph.X, glyph.Y) - viewport.Min
// op.Affine(f32.Affine2D{}.Offset(lineOff)) // op.Affine(f32.Affine2D{}.Offset(lineOff))
offX := float32(gtx.Dp(x)) + float32(first.X)/64.0 offX := float32(x) + float32(first.X)/64.0
offY := float32(gtx.Dp(y)) + float32(first.Y) offY := float32(y) + float32(first.Y)
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 glyphs // Draw vector glyphs
@ -327,26 +345,9 @@ func (r *Renderer) drawLine(gtx layout.Context, shp *text.Shaper, line []text.Gl
t.Pop() t.Pop()
} }
// charWidths returns the cumulative width after each rune in str. // WindowConstraints tracks the initial window constraints captured at the start of Draw.
func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp) []int { // All elements are positioned relative to these bounds to ensure consistency.
// Match Gio's textView: PxPerEm = font size in device pixels type WindowConstraints struct {
// Must set MinWidth/MaxWidth/MaxLines or shaper wraps every character. Min image.Point
shp.LayoutString(text.Parameters{ Max image.Point
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
}

65
internal/ui/unit.go Normal file
View File

@ -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),
}
}