Updates to rendering pipeline and documentation.

This commit is contained in:
Greg Pomerantz 2026-05-25 17:40:35 -04:00
parent 22c1d5bed1
commit 50e9ca5b34
9 changed files with 299 additions and 219 deletions

View File

@ -68,7 +68,7 @@ A dedicated goroutine that bridges the logic goroutine and the main goroutine. R
- **Frame consumption**: reads from `frameChan` in a tight loop
- **Frame storage**: acquires the mutex, stores the received frame
- **Frame invalidation**: calls `w.Invalidate()` while holding the mutex to request Gioui to invoke a `FrameEvent`
- **Frame invalidation**: calls `w.Invalidate()` while holding the mutex to request Gio to invoke a `FrameEvent`
- **Lock release**: releases the mutex after invalidation
This goroutine exists so that the logic goroutine can send frames without blocking. The receiver is always ready to consume, and the mutex hold is minimal.
@ -182,7 +182,7 @@ Every user-facing action has a **synchronous state decision** (what the UI shows
| Step | Path | Operation |
|---|---|---|
| User presses backspace | Sync | Check if last undo entry matches; if so pop it, else start delete chain. Retreat cursor, recompute visible lines, produce frame |
| User presses backspace | Sync | Check if last undo entry matches; if so pop it, else start delete chain. Move cursor backward, recompute visible lines, produce frame |
| After frame | Async | Same as text editing (debounced file write, undo persist) |
### 4.3 Undo

View File

@ -2,14 +2,16 @@
## 1. Overview
Pad's logic layer produces a slice of positioned element structs. The renderer consumes the slice, draws each element, and routes input events back to the logic layer. Elements are plain Go structs — no framework types, no interfaces required for testing.
Pad's logic layer produces a slice of positioned elements. Each element knows how to draw itself: the `Element` interface includes a `Draw` method that delegates low-level rendering to a `*Renderer`. Elements are Go structs with both structural data (text, alignment, etc.) and behavioral code (their `Draw` implementation).
```
Logic: (State, Event) → ([]Element, Commands)
Render: []Element → pixels
Test: assert on []Element directly
Logic: (State, Event) → []Element
Draw: Element.Draw(gtx, r) → r.drawText(), r.drawBg(), ...
Test: assert on []Element directly (no renderer needed)
```
The Renderer provides low-level primitives (`drawText`, `drawBg`, `drawPng`) that elements call during their `Draw` method. It handles Dp→Px conversion and Gio shaper interop. The Renderer's `Draw(gtx, elems)` iterates the slice and calls each element's `Draw` method.
## 2. Core Types
### 2.1 Coordinate System
@ -60,38 +62,58 @@ func ToDp(px Px, scale float32) float32
- **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.
The Renderer holds a `ScaleProvider` interface (backed by `*editor.State`) that provides the current scale factor. It converts Dp↔Px on demand via `r.toPx()` / `r.toDp()`. The main window clip is applied at the top of `Renderer.Draw()` to bound all drawing to the window area.
### 2.3 Element Interface
All UI elements implement the `Element` interface, which exposes the region and visibility:
All UI elements implement the `Element` interface. Elements know their region and visibility, and **each element draws itself** by delegating to the `*Renderer`:
```go
type Element interface {
Region() Region
Visible() bool
Draw(gtx layout.Context, r *Renderer)
}
```
Concrete elements are plain Go structs with unexported fields for `region`, `visible`, and `id`. Constructors (`NewLabel`, `NewListView`, etc.) set these fields, keeping the API clean and preventing external mutation.
Concrete elements are Go structs with unexported fields for `region`, `visible`, and `id`. Constructors (`NewLabel`, `NewContainer`, `NewIcon`, etc.) set these fields, keeping the API clean and preventing external mutation.
```go
type Region struct {
X, Y unit.Dp
W, H unit.Dp
X, Y Dp // ui.Dp, not unit.Dp
W, H Dp
}
type Label struct { /* unexported region, visible, id + exported Text, Align, ... */ }
func NewLabel(text string, fontSize unit.Sp, region Region) Label
func NewLabel(text string, fontSize unit.Sp, region Region, align TextAlign) Label
func (l Label) Region() Region
func (l Label) Visible() bool
func (l Label) Draw(gtx layout.Context, r *Renderer) { r.drawText(...) }
func (l Label) ID() string
```
The renderer accepts `[]Element` and dispatches via type switches. This keeps the logic layer testable — tests assert on concrete element values without any framework or interface indirection in the test code.
The Renderer's `Draw(gtx, elems)` iterates the element slice, skips invisible elements, and calls each element's `Draw` method. `Container` elements additionally clip to their bounds and offset their children. This keeps the logic layer testable — tests assert on concrete element values without any framework or interface indirection in the test code.
## 3. Element Catalog
### 3.0 Container
Composite element that holds child elements within a clipped region with an optional background. Children's regions are relative to the Container's origin.
```go
type Container struct {
/* region, visible, id — unexported */
background Color
children []Element
}
func NewContainer(region Region, bg Color, children []Element) Container
func (c Container) Draw(gtx layout.Context, r *Renderer)
```
`Container.Draw()` draws its background (if any). The Renderer's `drawElement` handles clipping to the container's bounds and offsetting children before recursing into them. Children are drawn in slice order.
Used for: StatusBar, BottomBar, and any grouped UI region with a shared background and containment clipping.
### 3.1 Static Text
```go
@ -109,6 +131,24 @@ type TextAlign int // Start, Center, End
Used for: headers, status bar text, file sizes, line numbers.
### 3.1b Icon
Displays a named icon image (loaded from embedded PNGs). The icon **automatically scales to fill its region** when `Size` is 0, using the region's `W` and `H` as the target dimensions.
```go
type Icon struct {
/* region, visible, id — unexported */
Name string // icon name, e.g. "cut", "copy", "paste"
Size Dp // 0 = auto-scale to region W×H
}
func NewIcon(name string, region Region, size Dp) Icon
func (i Icon) Draw(gtx layout.Context, r *Renderer)
```
Icons are loaded from the embedded filesystem (`icons/*.png`). The `Renderer` caches them in a map and renders via `r.drawPng()`. When `Size` is 0 (the default), `Icon.Draw` passes `region.W` and `region.H` as the target dimensions, and `r.drawPng()` applies an affine scale transform so the source image fills that space. The `Size` field can be set to a non-zero value to override the region-based sizing.
Used for: action icons in the StatusBar (cut, copy, paste).
### 3.2 Interactive Text
```go
@ -214,6 +254,8 @@ type Button struct {
Enabled bool
Primary bool // true = emphasized style (e.g., filled background)
}
func NewButton(text string, enabled bool, primary bool, region Region) Button
func (b Button) Draw(gtx layout.Context, r *Renderer)
```
Used for: merge resolution (ours/theirs/both), dismiss, apply.
@ -237,62 +279,64 @@ type HunkResolution int // Unresolved, KeepOurs, KeepTheirs, MergeBoth
Used for: conflict resolution UI. The logic layer produces one `MergeHunk` element plus navigation buttons.
### 3.9 Status Bar (Top Bar)
### 3.9 Status Bar (Top Bar) — Composition
The status bar is **not a standalone element type**. It is composed from `Container`, `Label`, and `Icon` elements:
```go
type StatusBar struct {
/* region, visible, id — unexported */
Filename string // e.g., "notes.txt" (truncated with ellipsis if too long)
FilenameExp bool // true = show full filename (multi-line), false = truncated
CutCopy bool // true = show cut icon in left slot
Copy bool // true = show copy icon in left slot (next to cut)
Paste bool // true = show paste icon in left slot (next to copy)
ConflictIcon bool // true = show conflict warning icon (right side)
Search bool // true = show search icon (right side, next to conflict)
// EditorLayout builds the status bar as:
Container{
region: Region{X: margin, Y: margin, W: screenWidth - margin*2, H: 52},
background: Color{R: 230, G: 230, B: 230, A: 255},
children: []Element{
// Row 1: filename
NewLabel("filename.txt", 14, Region{X: 0, Y: 2, W: statusBarW, H: 20}, AlignStart),
// Row 2: cut, copy, paste icons (Size=0 → auto-scale to region W×H)
NewIcon("cut", Region{X: 0, Y: 28, W: 24, H: 24}, 0),
NewIcon("copy", Region{X: 48, Y: 28, W: 24, H: 24}, 0),
NewIcon("paste", Region{X: 96, Y: 28, W: 24, H: 24}, 0),
},
}
```
The status bar appears at the top of the editor page. It has a **two-line layout**:
- **Line 1**: Filename (truncated with ellipsis if too long). Tapping the ellipsis toggles between truncated and full multi-line view.
- **Line 2**: Action icons (Cut, Copy, Paste) on the left + conflict icon + search icon on the right.
- **Line 2**: Action icons (Cut, Copy, Paste) on the left. Icons are `24×24` DP each with `Size=0`, so they auto-scale to fill their regions.
**Fixed icon slots** (24×24 DP each, fixed positions):
- **Cut icon**: leftmost action icon
- **Copy icon**: second from left (36 DP from Cut icon)
- **Paste icon**: third from left (36 DP from Copy icon)
- **Conflict icon**: right side (36 DP from Search icon)
- **Search icon**: rightmost (36 DP from Conflict icon)
The Container clips its children to its bounds and provides a shared background. Children use regions relative to the Container's origin — the Renderer applies `op.Offset` automatically when drawing Container children.
Icons appear/disappear without reflowing other elements. The StatusBar's `Region.H` is computed dynamically based on whether the filename is truncated (2 lines) or expanded (3+ lines).
When a sync conflict is detected for the active file, a conflict `Icon` appears. Tapping it navigates to the merge resolution page.
When a sync conflict is detected for the active file, `ConflictIcon` is set to true. Tapping the icon navigates to the merge resolution page. The icon persists across process death — if a conflict file exists for the active file on restore, the icon appears.
### 3.10 Bottom Bar — Composition
### 3.10 Bottom Bar
The bottom bar is **not a standalone element type**. It is composed from `Container` and `Label` elements:
```go
type BottomBar struct {
/* region, visible, id — unexported */
CursorPos string // e.g., "Ln 10, Col 5"
BytePos string // e.g., "1024 / 50000 bytes"
WordWrap bool // true = word wrap is enabled (button: tap to toggle)
// EditorLayout builds the bottom bar as:
Container{
region: Region{X: margin, Y: screenHeight - margin - 24, W: screenWidth - margin*2, H: 24},
background: Color{R: 230, G: 230, B: 230, A: 255},
children: []Element{
NewLabel("Ln 47, Col 12", 12, Region{X: 0, Y: 2, W: barW, H: 20}, AlignStart),
NewLabel("1024 / 50000", 12, Region{X: 0, Y: 2, W: barW, H: 20}, AlignCenter),
NewLabel("Wrap: On", 12, Region{X: 0, Y: 2, W: barW, H: 20}, AlignEnd),
},
}
```
The bottom bar appears at the bottom of the editor page. It displays cursor position, byte position, and word wrap status. It is always visible.
The bottom bar appears at the bottom of the editor page. It displays cursor position, byte position, and word wrap status. It is always visible with a fixed height of 24 DP.
**Layout**:
```
┌──────────────────────────────────────────────────────────────┐
│ Ln 10, Col 5 1024 / 50000 [Word Wrap: On]
│ Ln 10, Col 5 1024 / 50000 Wrap: On
└──────────────────────────────────────────────────────────────┘
```
- **Left**: Cursor position (line number, column number)
- **Center**: Byte position (current byte / total file bytes)
- **Right**: Word wrap button (tap to toggle On/Off)
The BottomBar's `Region.H` is fixed (24 DP). It sits below the editor content.
- **Right**: Word wrap status (tap to toggle On/Off)
### 3.11 Toast / Notification
@ -300,13 +344,14 @@ The BottomBar's `Region.H` is fixed (24 DP). It sits below the editor content.
type Toast struct {
/* region, visible, id — unexported */
Text string
Timeout time.Duration // auto-dismiss after this duration
Timeout int // auto-dismiss after this many milliseconds
}
func NewToast(region Region, text string, timeout int) Toast
```
Used for: "undo skipped (text changed)", "file saved", "conflict detected".
### 3.11 Spacer
### 3.12 Spacer
```go
type Spacer struct {
@ -317,7 +362,7 @@ type Spacer struct {
## 4. Layout Model
The logic layer performs a layout pass before emitting elements. Each element's `Region` is already computed. The renderer draws elements in slice order (back-to-front).
The logic layer performs a layout pass before emitting elements. Each element's `Region` is already computed. Elements draw themselves: the Renderer iterates the slice and calls `Element.Draw(gtx, r)` for each visible element. `Container` elements additionally clip to their bounds and offset their children.
### Layout Pass
@ -327,20 +372,18 @@ The logic layer performs a layout pass before emitting elements. Each element's
3. Emit: []Element with filled Region fields
```
The layout pass is pure: `(State, ScreenSize) → []Element`. It is testable.
The layout pass is pure: `(ScreenSize) → []Element`. It is testable.
Example for the browser page (values in Dp):
Currently implemented: `EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element` in `internal/editor/state.go`. It produces:
```
Screen: 390 × 844 Dp
Label (header) → Region{X:0, Y:0, W:390, H:48}
TextField (search) → Region{X:8, Y:48, W:330, H:40}
AlphaIndex → Region{X:354, Y:48, W:28, H:716}
ListView → Region{X:8, Y:96, W:346, H:716}
Container (StatusBar) → Region{X:10, Y:10, W:370, H:52}
Container (BottomBar) → Region{X:10, Y:810, W:370, H:24}
```
The logic layer knows the screen dimensions (from the renderer on init) and computes regions accordingly.
The logic layer knows the screen dimensions (converted from raw pixels to Dp in `main.go`) and computes regions accordingly.
### Scrolling
@ -409,28 +452,33 @@ Elements that don't specify explicit colors/sizes use theme defaults. The theme
```
[]Element{
StatusBar{
Filename: "notes.txt", FilenameExp: false,
CutCopy: true, Copy: false, Paste: true, ConflictIcon: false, Search: false,
// StatusBar: Container with Labels and Icons
Container{
background: gray,
children: []Element{
NewLabel("notes.txt", 14, ..., AlignStart), // filename
NewIcon("cut", Region{X:0, Y:28, W:24, H:24}, 0), // cut (auto-scale)
NewIcon("copy", Region{X:48, Y:28, W:24, H:24}, 0), // copy (auto-scale)
NewIcon("paste", Region{X:96, Y:28, W:24, H:24}, 0), // paste (auto-scale)
},
},
// (future) TextField{Multiline: true, ...},
// (future) Cursor{Line: 5, Column: 12, Selection: &Selection{...}},
// BottomBar: Container with Labels
Container{
background: gray,
children: []Element{
NewLabel("Ln 47, Col 12", 12, ..., AlignStart),
NewLabel("1024 / 50000", 12, ..., AlignCenter),
NewLabel("Wrap: On", 12, ..., AlignEnd),
},
},
SearchBar{Query: "foo", Match: 1, Total: 3}, // search (when active)
TextField{Multiline: true, VisibleLines: [...], ScrollOffset: 420, WordWrap: true},
Cursor{Line: 5, Column: 12, Selection: &Selection{...}},
BottomBar{CursorPos: "Ln 47, Col 12", BytePos: "1024 / 50000", WordWrap: true},
}
```
The search bar is only present when the user has activated search. When visible, it sits between the StatusBar and the text area. Typing in the search bar moves the cursor to the current match. Up/down arrows cycle through matches.
StatusBar and BottomBar are compositions of `Container`, `Label`, and `Icon` — not standalone element types. The `EditorLayout` function in `internal/editor/state.go` builds these compositions. Icon elements use `Size=0` so they auto-scale to fill their `24×24` DP regions.
The StatusBar contains:
- **Filename** on line 1 (truncated with ellipsis if too long)
- **Action icons** (Cut, Copy, Paste) on line 2, left side
- **Conflict icon** and **Search icon** on line 2, right side
The BottomBar contains:
- **Cursor position** on the left (e.g., "Ln 47, Col 12")
- **Byte position** in the center (e.g., "1024 / 50000")
- **Word wrap button** on the right (tap to toggle On/Off)
Future pages will add `TextField` (editor content), `SearchBar`, `Cursor`, and `Button` elements.
### 8.3 Merge Page
@ -451,35 +499,19 @@ The BottomBar contains:
Tests import the logic layer, call layout functions, and assert on the resulting `[]Element`. Since `Element` is an interface, tests type-assert to concrete types:
```go
func TestBrowserFiltersBySearch(t *testing.T) {
state := BrowserState{Entries: allFiles, Search: "foo"}
elems := BrowserLayout(state, screen)
func TestEditorLayout(t *testing.T) {
elems := EditorLayout(ui.Dp(390), ui.Dp(844))
list, ok := elems[2].(ListView)
// StatusBar is a Container
status, ok := elems[0].(Container)
assert.True(t, ok)
assert.Equal(t, 3, len(list.Items))
assert.Equal(t, "foo.txt", list.Items[0].Text)
}
assert.Equal(t, ui.Dp(52), status.Region().H)
assert.Equal(t, 3, len(status.children))
func TestEditorCursorPosition(t *testing.T) {
state := EditorState{Cursor: 1024, Buffer: buffer}
elems := EditorLayout(state, screen)
cursor, ok := elems[1].(Cursor)
// BottomBar is a Container
bottom, ok := elems[1].(Container)
assert.True(t, ok)
assert.Equal(t, 42, cursor.Line)
assert.Equal(t, 10, cursor.Column)
}
func TestMergeHunkDisplay(t *testing.T) {
state := MergeState{CurrentHunk: 2, Hunks: hunks}
elems := MergeLayout(state, screen)
hunk, ok := elems[2].(MergeHunk)
assert.True(t, ok)
assert.Equal(t, 3, hunk.HunkNumber)
assert.Equal(t, 7, hunk.TotalHunks)
assert.Equal(t, Unresolved, hunk.Resolution)
assert.Equal(t, ui.Dp(24), bottom.Region().H)
}
```

View File

@ -163,9 +163,34 @@ func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp)
**Key points**:
- `g.Advance` is in `fixed.Int26_6` format (16.6 fixed-point)
- Convert to int by shifting right by 6 bits: `int(cumWidth>>6)`
- The cumulative width is in device pixels
- The cumulative width is in **device pixels** (not Dp!)
- **Always** set `MinWidth`, `MaxWidth`, `MaxLines` or widths will be wrong
### 4.3 Measuring Text Width for Alignment
When you need the total text width for centering or right-alignment, sum the advances and convert to Dp:
```go
var totalAdvance fixed.Int26_6
for g, ok := shp.NextGlyph(); ok; g, ok = shp.NextGlyph() {
totalAdvance += g.Advance
}
// totalAdvance>>6 is in DEVICE PIXELS — must divide by scale to get Dp
textW_Dp := ui.Dp(float32(totalAdvance>>6) / scale.Scale())
```
**Critical**: `totalAdvance>>6` gives device pixels, but UI regions (`reg.W`, `reg.X`) are in Dp. If you use the raw value as Dp, centering will appear roughly correct (off by factor of 2, halved by division in center formula) but right-alignment will have a visible gap. Always divide by the scale factor to convert device pixels → Dp before using with region coordinates.
**Wrong** (works for AlignStart, breaks AlignCenter/AlignEnd):
```go
textW := ui.Dp(totalAdvance>>6) // treats device pixels as Dp!
```
**Correct**:
```go
textW := ui.Dp(float32(totalAdvance>>6) / r.scale.Scale()) // convert to Dp
```
### 4.3 Getting Per-Character Widths
For hit testing (mapping UI coordinates → byte offset), we need per-character cumulative widths:
@ -452,12 +477,9 @@ For the TextField element, we'll use the same approach:
- Selection rendering
- IME bridge sync
### 8.3 BottomBar
### 8.3 StatusBar / BottomBar
The BottomBar uses `op.Offset` + `material.Label()` for simple text that doesn't need precise positioning. This is acceptable because:
- The text is short and fixed
- We don't need per-character widths for hit testing
- The performance impact is negligible
The StatusBar and BottomBar are `Container` compositions of `Label` and `Icon` elements. Their text is rendered via the Renderer's `drawText``drawLineText``drawLine` pipeline (same as all other text). Truncation may require a double-layout (measure then draw truncated), which is acceptable because the strings are short.
## 9. Debugging Tips
@ -578,54 +600,53 @@ The project uses distinct Go types (`ui.Dp` and `ui.Px`) to prevent accidental m
### Architecture
```
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
main.go State/Logic layer Renderer
───────── ───────────────── ────────
app.ConfigEvent EditorLayout(ui.Dp) r.toPx(dp)
PixelWidth/Px → regions in Dp r.toDp(px)
→ stored in State
ScaleEvent
→ stored in State
```
### 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.
2. **main.go converts pixels to Dp**: Raw pixel dimensions from `app.ConfigEvent` are stored in `State.PixelWidth`/`State.PixelHeight`. The `State.layout()` method converts them to Dp using the current scale before calling `EditorLayout`.
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.
3. **Renderer converts on demand**: The `Renderer` holds a `ScaleProvider` interface (backed by `*editor.State`) and converts Dp↔Px via `r.toPx(dp)` / `r.toDp(px)` at Gio interop boundaries.
### BottomBar Positioning Example
4. **Explicit conversions only**: Use `ui.ToPx(dp, scale)` and `ui.ToDp(px, scale)` for conversions. The `Dp` and `Px` types are distinct named types — the compiler prevents mixing them directly.
### StatusBar Composition Example
The StatusBar is built as a `Container` with child `Label` and `Icon` elements:
```go
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)))
}
// In EditorLayout (state.go):
statusBar := ui.NewContainer(
ui.Region{X: margin, Y: margin, W: screenWidth - margin*2, H: ui.Dp(52)},
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewLabel("filename.txt", 14,
ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)},
ui.AlignStart),
// Icons: Size=0 → auto-scale to fill region W×H
ui.NewIcon("cut", ui.Region{X: 0, Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
},
)
```
The Container's region is in screen-space Dp. Child regions are relative to the Container's origin (the Renderer applies `op.Offset` for children). The Renderer converts all Dp values to pixels via `r.toPx()` when submitting Gio ops. Icon elements use `Size=0` so the renderer auto-scales them to fill their `24×24` DP regions via an affine transform.
### 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.
- **Don't mix Dp and Px**: The compiler will catch it. Use explicit `ui.ToPx` / `ui.ToDp` conversions.
- **Don't use `gtx.Constraints` in element Draw methods**: They're modified by clips. The Renderer's `toPx`/`toDp` methods use the scale from `State`, not constraints.
- **Container children use relative regions**: Child element regions are relative to the Container's origin, not screen space. The Renderer applies `op.Offset` automatically when drawing Container children.
## 9.6 Background Drawing with Clips
@ -659,13 +680,10 @@ call.Add(gtx.Ops) // When replayed, clip is pushed but never popped!
- **Always** use `fixed.I(gtx.Sp(size))` for `PxPerEm`
- **Always** set `MinWidth`, `MaxWidth`, `MaxLines` in `LayoutString` parameters
- **Always** offset by `(x + first.X, y + first.Y)` in `drawLine`
- **Always** wrap glyph drawing in `op.Record`/`m.Stop()` for clipping
- **Always** wrap glyph drawing in `op.Record`/`m.Stop()` for clipping safety
- **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)`
- **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
- **Logic layer works exclusively in Dp**. `State.layout()` converts pixels→Dp. Renderer converts Dp→Px via `r.toPx()`.
- **Explicit conversions** (`ui.ToPx`, `ui.ToDp`) — `Dp` and `Px` are distinct named types.
- **Follow** Gio's `paintGlyph` as the reference implementation for text rendering

View File

@ -315,11 +315,11 @@ The StatusBar (top bar) has **fixed icon slots** for cut, copy, and paste. Icons
```
**Fixed slot positions** (in Dp, from the edges):
- **Cut icon**: leftmost action icon (24×24 DP, fixed position)
- **Copy icon**: second from left (24×24 DP, fixed position, 36 DP from Cut icon)
- **Paste icon**: third from left (24×24 DP, fixed position, 36 DP from Copy icon)
- **Conflict icon**: right side (24×24 DP, fixed position, 36 DP from Search icon)
- **Search icon**: rightmost (24×24 DP, fixed position)
- **Cut icon**: leftmost action icon (24×24 DP, X: 0)
- **Copy icon**: second from left (24×24 DP, X: 48)
- **Paste icon**: third from left (24×24 DP, X: 96)
- **Conflict icon**: right side (24×24 DP, future)
- **Search icon**: rightmost (24×24 DP, future)
- **Filename**: top line, truncated with ellipsis if too long
**Visibility rules**:
@ -332,7 +332,7 @@ The StatusBar (top bar) has **fixed icon slots** for cut, copy, and paste. Icons
| **Conflict** | sync conflict detected for active file |
| **Search** | Always visible (toggle search bar) |
Icons are rendered as `Button` elements with fixed sizes. The StatusBar's `Region` height is computed dynamically based on whether the filename line is visible (2 lines when filename is shown, 1 line when in merge/search mode).
Icons are rendered as `Icon` elements with `Size=0`, so they auto-scale to fill their `24×24` DP regions via an affine transform in `r.drawPng()`. The StatusBar's `Region` height is computed dynamically based on whether the filename line is visible (2 lines when filename is shown, 1 line when in merge/search mode).
### 5.6.3 Cut Operation

View File

@ -1,7 +1,6 @@
package editor
import (
"fmt"
"pad/internal/ui"
)
@ -49,17 +48,17 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
// Row 1: filename centered (relative to container)
ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: statusBarW/2 - ui.Dp(40), Y: ui.Dp(2)}),
// Row 2: cut icon left (relative to container)
ui.NewIcon("cut", ui.Region{X: ui.Dp(8), Y: ui.Dp(28)}, ui.Dp(16)),
// Row 2: status icons right (relative to container)
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: statusBarW - ui.Dp(70), Y: ui.Dp(28)}),
// Row 1: filename
ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart),
// Row 2: cut, copy, paste icons
ui.NewIcon("cut", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
},
)
// --- Bottom bar ---
bottomBarHeight := ui.Dp(24)
bottomBarHeight := ui.BottomBarHeight
bottomBarY := screenHeight - margin - bottomBarHeight
bottomBarRegion := ui.Region{
X: margin, Y: bottomBarY,
@ -71,12 +70,11 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: ui.Dp(8), Y: ui.Dp(2)}),
ui.NewLabel("1024 / 50000", 12, ui.Region{X: bottomBarW/2 - ui.Dp(40), Y: ui.Dp(2)}),
ui.NewLabel("Wrap: On", 12, ui.Region{X: bottomBarW - ui.Dp(60), Y: ui.Dp(2)}),
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignStart),
ui.NewLabel("1024 / 50000", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignCenter),
ui.NewLabel("Wrap: On", 12, ui.Region{X: 0, Y: ui.Dp(2), W: bottomBarW, H: ui.Dp(20)}, ui.AlignEnd),
},
)
fmt.Println("[DEBUG EditorLayout] created", len([]ui.Element{statusBar, bottomBar}), "containers")
return []ui.Element{statusBar, bottomBar}
}

View File

@ -1,7 +1,6 @@
package ui
import (
"fmt"
"gioui.org/layout"
"gioui.org/unit"
)
@ -42,7 +41,6 @@ func (c Container) Draw(gtx layout.Context, r *Renderer) {
// NewContainer creates a Container with the given region, background, and children.
func NewContainer(region Region, bg Color, children []Element) Container {
fmt.Printf("[DEBUG NewContainer] region={X=%.0f Y=%.0f W=%.0f H=%.0f} children=%d\n", region.X, region.Y, region.W, region.H, len(children))
return Container{
region: region,
visible: true,
@ -68,22 +66,22 @@ type Label struct {
func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible }
func (l Label) Draw(gtx layout.Context, r *Renderer) {
fmt.Printf("[DEBUG Label.Draw] text=%q region={X=%.0f Y=%.0f}\n", l.Text, l.region.X, l.region.Y)
col := l.Color
if col == (Color{}) {
col = Color{R: 0, G: 0, B: 0, A: 255}
}
r.drawText(gtx, l.Text, l.FontSize, l.region, col)
r.drawText(gtx, l.Text, l.FontSize, l.region, l.Align, col)
}
func (l Label) ID() string { return l.id }
// NewLabel creates a visible Label element.
func NewLabel(text string, fontSize unit.Sp, region Region) Label {
func NewLabel(text string, fontSize unit.Sp, region Region, align TextAlign) Label {
return Label{
region: region,
visible: true,
Text: text,
FontSize: fontSize,
Align: align,
}
}
@ -99,16 +97,16 @@ type Icon struct {
func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible }
func (i Icon) Draw(gtx layout.Context, r *Renderer) {
fmt.Printf("[DEBUG Icon.Draw] name=%q icon=%v region={X=%.0f Y=%.0f}\n", i.Name, r.icon(i.Name) != nil, i.region.X, i.region.Y)
img := r.icon(i.Name)
if img == nil {
return
}
size := i.Size
if size == 0 {
size = Dp(16)
// Auto-scale: if Size is 0, use region dimensions so the icon fills its region
w, h := Dp(0), Dp(0)
if i.Size == 0 {
w, h = i.region.W, i.region.H
}
r.drawPng(gtx, img, i.region, size, i.Size)
r.drawPng(gtx, img, i.region, w, h)
}
func (i Icon) ID() string { return i.id }
@ -204,7 +202,7 @@ func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible }
func (b Button) Draw(gtx layout.Context, r *Renderer) {
col := Color{R: 0, G: 0, B: 0, A: 255}
r.drawText(gtx, b.Text, r.theme.FontSize, b.region, col)
r.drawText(gtx, b.Text, r.theme.FontSize, b.region, AlignStart, col)
}
func (b Button) ID() string { return b.id }

View File

@ -2,14 +2,14 @@ package ui
const (
// Standard dimensions in Dp
statusBarLineHeight = Dp(24)
statusBarFilenameLine = Dp(24)
statusBarIconsLine = Dp(24)
bottomBarHeight = Dp(24)
iconSize = Dp(24)
iconGap = Dp(36)
padding = Dp(8)
buttonPadding = 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)
)
// NOTE: EditorLayout is defined in internal/editor/state.go.

View File

@ -3,7 +3,6 @@ package ui
import (
"bytes"
"embed"
"fmt"
"image"
"image/color"
_ "image/png"
@ -46,15 +45,17 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
// loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() {
data, err := iconFS.ReadFile("icons/cut.png")
for _, name := range []string{"cut", "copy", "paste"} {
data, err := iconFS.ReadFile("icons/" + name + ".png")
if err != nil {
return
continue
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return
continue
}
r.icons[name] = img
}
r.icons["cut"] = img
}
// icon returns a loaded icon image by name, or nil if not found.
@ -74,8 +75,6 @@ func (r *Renderer) toDp(px Px) Dp {
// Draw iterates elements and draws each in slice order (back-to-front).
func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
fmt.Println("[DEBUG Draw] elems:", len(elems), "scale:", r.scale.Scale())
// Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min.
winW := gtx.Constraints.Max.X
winH := gtx.Constraints.Max.Y
@ -83,10 +82,8 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
Min: image.Point{X: 0, Y: 0},
Max: image.Point{X: winW, Y: winH},
}.Push(gtx.Ops)
fmt.Println("[DEBUG Draw] window clip:", image.Point{X: 0, Y: 0}, image.Point{X: winW, Y: winH})
for i, e := range elems {
fmt.Printf("[DEBUG Draw] elem[%d]: %T visible=%v\n", i, e, e.Visible())
for _, e := range elems {
if !e.Visible() {
continue
}
@ -98,24 +95,20 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
reg := e.Region()
fmt.Printf("[DEBUG drawElement] %T region={X=%.0f Y=%.0f W=%.0f H=%.0f}\n", e, reg.X, reg.Y, reg.W, reg.H)
if container, ok := e.(Container); ok {
// Clip to container bounds, draw background, then offset children
fmt.Printf("[DEBUG drawElement] container children=%d originPx=(%d,%d)\n", len(container.children), int(r.toPx(reg.X)), int(r.toPx(reg.Y)))
clipRect := clip.Rect{
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)
e.Draw(gtx, r) // draw container background
offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops)
for i, child := range container.children {
fmt.Printf("[DEBUG drawElement] child[%d]: %T region={X=%.0f Y=%.0f}\n", i, child, child.Region().X, child.Region().Y)
for _, child := range container.children {
r.drawElement(gtx, child)
}
offset.Pop()
clipRect.Pop()
fmt.Println("[DEBUG drawElement] clip popped")
} else {
// Leaf element: no self-clip (region may be zero-sized; clip is handled by parent container)
e.Draw(gtx, r)
@ -123,7 +116,6 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
}
func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) {
fmt.Printf("[DEBUG drawBg] region={X=%.0f Y=%.0f W=%.0f H=%.0f} col={%d,%d,%d,%d}\n", reg.X, reg.Y, reg.W, reg.H, col.R, col.G, col.B, col.A)
bgClip := clip.Rect{
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))},
@ -131,23 +123,49 @@ func (r *Renderer) drawBg(gtx layout.Context, reg Region, col Color) {
paint.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop()
fmt.Println("[DEBUG drawBg] bg drawn")
}
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, col Color) {
fmt.Printf("[DEBUG drawText] str=%q size=%.0f region={X=%.0f Y=%.0f} col={%d,%d,%d,%d}\n", str, float32(size), reg.X, reg.Y, col.R, col.G, col.B, col.A)
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, align TextAlign, col Color) {
if str == "" {
return
}
// Layout text with width constraints (per layout_rendering.md)
r.shp.LayoutString(text.Parameters{
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(size)),
MinWidth: 0,
MaxWidth: maxInt32,
MaxLines: 1,
}, str)
}
if align == AlignStart {
// No measurement needed — draw at reg.X directly
r.shp.LayoutString(params, str)
r.drawLineText(gtx, reg.X, reg.Y, col)
fmt.Println("[DEBUG drawText] text drawn")
} else {
// Phase 1: measure text width via glyph iteration (consumes iterator)
r.shp.LayoutString(params, str)
var totalAdvance fixed.Int26_6
for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
totalAdvance += g.Advance
}
// totalAdvance>>6 is in device pixels (shaper outputs device pixels).
// Divide by scale to convert to Dp so it matches region coordinate space.
textW := Dp(float32(totalAdvance>>6) / r.scale.Scale())
// Compute aligned X position
var drawX Dp
switch align {
case AlignCenter:
drawX = reg.X + (reg.W - textW) / 2
case AlignEnd:
drawX = reg.X + reg.W - textW
default:
drawX = reg.X
}
// Phase 2: layout again (iterator consumed) and draw
r.shp.LayoutString(params, str)
r.drawLineText(gtx, drawX, reg.Y, col)
}
}
// drawLineText iterates laid-out glyphs and draws each line using op.Record for clipping safety.
@ -198,19 +216,35 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
}
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
fmt.Printf("[DEBUG drawPng] icon=%v region={X=%.0f Y=%.0f} size={W=%.0f H=%.0f}\n", img != nil, reg.X, reg.Y, width, height)
if img == nil {
fmt.Println("[DEBUG drawPng] icon nil, skipping")
return
}
// Auto-size: if width or height is 0, use the region dimensions
w := width
h := height
if w == 0 && h == 0 {
w, h = reg.W, reg.H
} else if w == 0 {
w = h
} else if h == 0 {
h = w
}
xPx := int(r.toPx(reg.X))
yPx := int(r.toPx(reg.Y))
wPx := int(r.toPx(width))
hPx := int(r.toPx(height))
_ = image.Rect(xPx, yPx, xPx+wPx, yPx+hPx)
stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
wPx := int(r.toPx(w))
hPx := int(r.toPx(h))
origW := img.Bounds().Dx()
origH := img.Bounds().Dy()
if origW == 0 || origH == 0 {
return
}
sx := float32(wPx) / float32(origW)
sy := float32(hPx) / float32(origH)
// Position, then scale so the image fills the target size
offset := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops)
scale := op.Affine(f32.Affine2D{}.Scale(f32.Pt(0, 0), f32.Pt(sx, sy))).Push(gtx.Ops)
paint.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
fmt.Println("[DEBUG drawPng] png drawn")
scale.Pop()
offset.Pop()
}

BIN
pad

Binary file not shown.