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()))
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)
}

View File

@ -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

View File

@ -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
// 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)
// 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)))
}
```
**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

View File

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

View File

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

View File

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

View File

@ -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.

View File

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