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 consumption**: reads from `frameChan` in a tight loop
- **Frame storage**: acquires the mutex, stores the received frame - **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 - **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. 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 | | 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) | | After frame | Async | Same as text editing (debounced file write, undo persist) |
### 4.3 Undo ### 4.3 Undo

View File

@ -2,14 +2,16 @@
## 1. Overview ## 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) Logic: (State, Event) → []Element
Render: []Element → pixels Draw: Element.Draw(gtx, r) → r.drawText(), r.drawBg(), ...
Test: assert on []Element directly 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. Core Types
### 2.1 Coordinate System ### 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`. - **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. - **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 ### 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 ```go
type Element interface { type Element interface {
Region() Region Region() Region
Visible() bool 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 ```go
type Region struct { type Region struct {
X, Y unit.Dp X, Y Dp // ui.Dp, not unit.Dp
W, H unit.Dp W, H Dp
} }
type Label struct { /* unexported region, visible, id + exported Text, Align, ... */ } 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) Region() Region
func (l Label) Visible() bool func (l Label) Visible() bool
func (l Label) Draw(gtx layout.Context, r *Renderer) { r.drawText(...) }
func (l Label) ID() string 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. 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 ### 3.1 Static Text
```go ```go
@ -109,6 +131,24 @@ type TextAlign int // Start, Center, End
Used for: headers, status bar text, file sizes, line numbers. 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 ### 3.2 Interactive Text
```go ```go
@ -214,6 +254,8 @@ type Button struct {
Enabled bool Enabled bool
Primary bool // true = emphasized style (e.g., filled background) 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. 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. 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 ```go
type StatusBar struct { // EditorLayout builds the status bar as:
/* region, visible, id — unexported */ Container{
Filename string // e.g., "notes.txt" (truncated with ellipsis if too long) region: Region{X: margin, Y: margin, W: screenWidth - margin*2, H: 52},
FilenameExp bool // true = show full filename (multi-line), false = truncated background: Color{R: 230, G: 230, B: 230, A: 255},
CutCopy bool // true = show cut icon in left slot children: []Element{
Copy bool // true = show copy icon in left slot (next to cut) // Row 1: filename
Paste bool // true = show paste icon in left slot (next to copy) NewLabel("filename.txt", 14, Region{X: 0, Y: 2, W: statusBarW, H: 20}, AlignStart),
ConflictIcon bool // true = show conflict warning icon (right side) // Row 2: cut, copy, paste icons (Size=0 → auto-scale to region W×H)
Search bool // true = show search icon (right side, next to conflict) 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**: 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 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): 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.
- **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)
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 ```go
type BottomBar struct { // EditorLayout builds the bottom bar as:
/* region, visible, id — unexported */ Container{
CursorPos string // e.g., "Ln 10, Col 5" region: Region{X: margin, Y: screenHeight - margin - 24, W: screenWidth - margin*2, H: 24},
BytePos string // e.g., "1024 / 50000 bytes" background: Color{R: 230, G: 230, B: 230, A: 255},
WordWrap bool // true = word wrap is enabled (button: tap to toggle) 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**: **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) - **Left**: Cursor position (line number, column number)
- **Center**: Byte position (current byte / total file bytes) - **Center**: Byte position (current byte / total file bytes)
- **Right**: Word wrap button (tap to toggle On/Off) - **Right**: Word wrap status (tap to toggle On/Off)
The BottomBar's `Region.H` is fixed (24 DP). It sits below the editor content.
### 3.11 Toast / Notification ### 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 { type Toast struct {
/* region, visible, id — unexported */ /* region, visible, id — unexported */
Text string 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". Used for: "undo skipped (text changed)", "file saved", "conflict detected".
### 3.11 Spacer ### 3.12 Spacer
```go ```go
type Spacer struct { type Spacer struct {
@ -317,7 +362,7 @@ type Spacer struct {
## 4. Layout Model ## 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 ### 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 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 Screen: 390 × 844 Dp
Label (header) → Region{X:0, Y:0, W:390, H:48} Container (StatusBar) → Region{X:10, Y:10, W:370, H:52}
TextField (search) → Region{X:8, Y:48, W:330, H:40} Container (BottomBar) → Region{X:10, Y:810, W:370, H:24}
AlphaIndex → Region{X:354, Y:48, W:28, H:716}
ListView → Region{X:8, Y:96, W:346, H:716}
``` ```
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 ### Scrolling
@ -409,28 +452,33 @@ Elements that don't specify explicit colors/sizes use theme defaults. The theme
``` ```
[]Element{ []Element{
StatusBar{ // StatusBar: Container with Labels and Icons
Filename: "notes.txt", FilenameExp: false, Container{
CutCopy: true, Copy: false, Paste: true, ConflictIcon: false, Search: false, 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: Future pages will add `TextField` (editor content), `SearchBar`, `Cursor`, and `Button` elements.
- **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)
### 8.3 Merge Page ### 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: 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 ```go
func TestBrowserFiltersBySearch(t *testing.T) { func TestEditorLayout(t *testing.T) {
state := BrowserState{Entries: allFiles, Search: "foo"} elems := EditorLayout(ui.Dp(390), ui.Dp(844))
elems := BrowserLayout(state, screen)
list, ok := elems[2].(ListView) // StatusBar is a Container
status, ok := elems[0].(Container)
assert.True(t, ok) assert.True(t, ok)
assert.Equal(t, 3, len(list.Items)) assert.Equal(t, ui.Dp(52), status.Region().H)
assert.Equal(t, "foo.txt", list.Items[0].Text) assert.Equal(t, 3, len(status.children))
}
func TestEditorCursorPosition(t *testing.T) { // BottomBar is a Container
state := EditorState{Cursor: 1024, Buffer: buffer} bottom, ok := elems[1].(Container)
elems := EditorLayout(state, screen)
cursor, ok := elems[1].(Cursor)
assert.True(t, ok) assert.True(t, ok)
assert.Equal(t, 42, cursor.Line) assert.Equal(t, ui.Dp(24), bottom.Region().H)
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)
} }
``` ```

View File

@ -163,9 +163,34 @@ func charWidths(gtx layout.Context, shp *text.Shaper, str string, size unit.Sp)
**Key points**: **Key points**:
- `g.Advance` is in `fixed.Int26_6` format (16.6 fixed-point) - `g.Advance` is in `fixed.Int26_6` format (16.6 fixed-point)
- Convert to int by shifting right by 6 bits: `int(cumWidth>>6)` - 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 - **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 ### 4.3 Getting Per-Character Widths
For hit testing (mapping UI coordinates → byte offset), we need per-character cumulative 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 - Selection rendering
- IME bridge sync - 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 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.
- The text is short and fixed
- We don't need per-character widths for hit testing
- The performance impact is negligible
## 9. Debugging Tips ## 9. Debugging Tips
@ -578,54 +600,53 @@ The project uses distinct Go types (`ui.Dp` and `ui.Px`) to prevent accidental m
### Architecture ### Architecture
``` ```
main.go Logic/Layout layer Renderer main.go State/Logic layer Renderer
───────── ───────────────── ───────── ───────── ───────────────── ────────
app.ConfigEvent EditorLayout(ui.Dp) gtx.Constraints (Px) app.ConfigEvent EditorLayout(ui.Dp) r.toPx(dp)
→ pixels → regions in Dp → converts Dp→Px PixelWidth/Px → regions in Dp r.toDp(px)
→ convert to Dp at boundaries → stored in State
ScaleEvent
→ stored in State
``` ```
### Key Rules ### 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. 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 ```go
func (r *Renderer) drawBottomBar(gtx layout.Context, bb BottomBar, constraints WindowConstraints) { // In EditorLayout (state.go):
reg := bb.Region() // Region in Dp statusBar := ui.NewContainer(
ui.Region{X: margin, Y: margin, W: screenWidth - margin*2, H: ui.Dp(52)},
// Convert region to pixels for positioning ui.Color{R: 230, G: 230, B: 230, A: 255},
regXPx := r.toPx(reg.X) []ui.Element{
regWPx := r.toPx(reg.W) ui.NewLabel("filename.txt", 14,
ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)},
// Clamp right edge to window width ui.AlignStart),
rightXPx := regXPx + regWPx // Icons: Size=0 → auto-scale to fill region W×H
if rightXPx > Px(constraints.Max.X) { ui.NewIcon("cut", ui.Region{X: 0, Y: ui.Dp(28), W: ui.Dp(24), H: ui.Dp(24)}, 0),
rightXPx = Px(constraints.Max.X) 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),
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)))
}
``` ```
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 ### 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 `ui.ToPx` / `ui.ToDp` conversions.
- **Don't mix Dp and Px**: The compiler will catch it. Use explicit 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.
- **Don't pass pixels as Dp to EditorLayout**: `main.go` must convert `app.ConfigEvent` pixels to Dp before passing to the logic layer. - **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 ## 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** use `fixed.I(gtx.Sp(size))` for `PxPerEm`
- **Always** set `MinWidth`, `MaxWidth`, `MaxLines` in `LayoutString` parameters - **Always** set `MinWidth`, `MaxWidth`, `MaxLines` in `LayoutString` parameters
- **Always** offset by `(x + first.X, y + first.Y)` in `drawLine` - **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) - **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)`
- **Logic layer works exclusively in Dp**. Renderer converts Dp→Px at Gio boundaries. - **Logic layer works exclusively in Dp**. `State.layout()` converts pixels→Dp. Renderer converts Dp→Px via `r.toPx()`.
- **Capture `gtx.Constraints` once at start of `Draw()`** — clips modify them. - **Explicit conversions** (`ui.ToPx`, `ui.ToDp`) — `Dp` and `Px` are distinct named types.
- **Use explicit conversions** (`ui.ToPx`, `ui.ToDp`) — never mix Dp and Px directly. - **Follow** Gio's `paintGlyph` as the reference implementation for text rendering
- **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

@ -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): **Fixed slot positions** (in Dp, from the edges):
- **Cut icon**: leftmost action icon (24×24 DP, fixed position) - **Cut icon**: leftmost action icon (24×24 DP, X: 0)
- **Copy icon**: second from left (24×24 DP, fixed position, 36 DP from Cut icon) - **Copy icon**: second from left (24×24 DP, X: 48)
- **Paste icon**: third from left (24×24 DP, fixed position, 36 DP from Copy icon) - **Paste icon**: third from left (24×24 DP, X: 96)
- **Conflict icon**: right side (24×24 DP, fixed position, 36 DP from Search icon) - **Conflict icon**: right side (24×24 DP, future)
- **Search icon**: rightmost (24×24 DP, fixed position) - **Search icon**: rightmost (24×24 DP, future)
- **Filename**: top line, truncated with ellipsis if too long - **Filename**: top line, truncated with ellipsis if too long
**Visibility rules**: **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 | | **Conflict** | sync conflict detected for active file |
| **Search** | Always visible (toggle search bar) | | **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 ### 5.6.3 Cut Operation

View File

@ -1,7 +1,6 @@
package editor package editor
import ( import (
"fmt"
"pad/internal/ui" "pad/internal/ui"
) )
@ -49,17 +48,17 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
statusBarRegion, statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{ []ui.Element{
// Row 1: filename centered (relative to container) // Row 1: filename
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)}), 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 icon left (relative to container) // Row 2: cut, copy, paste icons
ui.NewIcon("cut", ui.Region{X: ui.Dp(8), Y: ui.Dp(28)}, ui.Dp(16)), ui.NewIcon("cut", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
// Row 2: status icons right (relative to container) ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: statusBarW - ui.Dp(70), Y: ui.Dp(28)}), ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0),
}, },
) )
// --- Bottom bar --- // --- Bottom bar ---
bottomBarHeight := ui.Dp(24) bottomBarHeight := ui.BottomBarHeight
bottomBarY := screenHeight - margin - bottomBarHeight bottomBarY := screenHeight - margin - bottomBarHeight
bottomBarRegion := ui.Region{ bottomBarRegion := ui.Region{
X: margin, Y: bottomBarY, X: margin, Y: bottomBarY,
@ -71,12 +70,11 @@ func EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element {
bottomBarRegion, bottomBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{ []ui.Element{
ui.NewLabel("Ln 47, Col 12", 12, ui.Region{X: ui.Dp(8), 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: bottomBarW/2 - ui.Dp(40), Y: ui.Dp(2)}), 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: bottomBarW - ui.Dp(60), Y: ui.Dp(2)}), 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} return []ui.Element{statusBar, bottomBar}
} }

View File

@ -1,7 +1,6 @@
package ui package ui
import ( import (
"fmt"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/unit" "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. // NewContainer creates a Container with the given region, background, and children.
func NewContainer(region Region, bg Color, children []Element) Container { 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{ return Container{
region: region, region: region,
visible: true, visible: true,
@ -68,22 +66,22 @@ type Label struct {
func (l Label) Region() Region { return l.region } func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible } func (l Label) Visible() bool { return l.visible }
func (l Label) Draw(gtx layout.Context, r *Renderer) { 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 col := l.Color
if col == (Color{}) { if col == (Color{}) {
col = Color{R: 0, G: 0, B: 0, A: 255} 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 } func (l Label) ID() string { return l.id }
// NewLabel creates a visible Label element. // 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{ return Label{
region: region, region: region,
visible: true, visible: true,
Text: text, Text: text,
FontSize: fontSize, FontSize: fontSize,
Align: align,
} }
} }
@ -99,16 +97,16 @@ type Icon struct {
func (i Icon) Region() Region { return i.region } func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible } func (i Icon) Visible() bool { return i.visible }
func (i Icon) Draw(gtx layout.Context, r *Renderer) { 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) img := r.icon(i.Name)
if img == nil { if img == nil {
return return
} }
size := i.Size // Auto-scale: if Size is 0, use region dimensions so the icon fills its region
if size == 0 { w, h := Dp(0), Dp(0)
size = Dp(16) 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 } 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) Visible() bool { return b.visible }
func (b Button) Draw(gtx layout.Context, r *Renderer) { func (b Button) Draw(gtx layout.Context, r *Renderer) {
col := Color{R: 0, G: 0, B: 0, A: 255} 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 } func (b Button) ID() string { return b.id }

View File

@ -2,14 +2,14 @@ package ui
const ( const (
// Standard dimensions in Dp // Standard dimensions in Dp
statusBarLineHeight = Dp(24) StatusBarLineHeight = Dp(24)
statusBarFilenameLine = Dp(24) StatusBarFilenameLine = Dp(24)
statusBarIconsLine = Dp(24) StatusBarIconsLine = Dp(24)
bottomBarHeight = Dp(24) BottomBarHeight = Dp(24)
iconSize = Dp(24) IconSize = Dp(24)
iconGap = Dp(36) IconGap = Dp(36)
padding = Dp(8) Padding = Dp(8)
buttonPadding = Dp(8) ButtonPadding = Dp(8)
) )
// NOTE: EditorLayout is defined in internal/editor/state.go. // NOTE: EditorLayout is defined in internal/editor/state.go.

View File

@ -3,7 +3,6 @@ package ui
import ( import (
"bytes" "bytes"
"embed" "embed"
"fmt"
"image" "image"
"image/color" "image/color"
_ "image/png" _ "image/png"
@ -46,15 +45,17 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
// loadIcons loads PNG icons from the embedded filesystem. // loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() { func (r *Renderer) loadIcons() {
data, err := iconFS.ReadFile("icons/cut.png") for _, name := range []string{"cut", "copy", "paste"} {
if err != nil { data, err := iconFS.ReadFile("icons/" + name + ".png")
return if err != nil {
continue
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
continue
}
r.icons[name] = img
} }
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return
}
r.icons["cut"] = img
} }
// icon returns a loaded icon image by name, or nil if not found. // 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). // Draw iterates elements and draws each in slice order (back-to-front).
func (r *Renderer) Draw(gtx layout.Context, elems []Element) { 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. // Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min.
winW := gtx.Constraints.Max.X winW := gtx.Constraints.Max.X
winH := gtx.Constraints.Max.Y 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}, Min: image.Point{X: 0, Y: 0},
Max: image.Point{X: winW, Y: winH}, Max: image.Point{X: winW, Y: winH},
}.Push(gtx.Ops) }.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 { for _, e := range elems {
fmt.Printf("[DEBUG Draw] elem[%d]: %T visible=%v\n", i, e, e.Visible())
if !e.Visible() { if !e.Visible() {
continue continue
} }
@ -98,24 +95,20 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
func (r *Renderer) drawElement(gtx layout.Context, e Element) { func (r *Renderer) drawElement(gtx layout.Context, e Element) {
reg := e.Region() 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 { if container, ok := e.(Container); ok {
// Clip to container bounds, draw background, then offset children // 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{ clipRect := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))}, 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))}, 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)
e.Draw(gtx, r) // draw container background 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) offset := op.Offset(image.Pt(int(r.toPx(reg.X)), int(r.toPx(reg.Y)))).Push(gtx.Ops)
for i, child := range container.children { for _, 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)
r.drawElement(gtx, child) r.drawElement(gtx, child)
} }
offset.Pop() offset.Pop()
clipRect.Pop() clipRect.Pop()
fmt.Println("[DEBUG drawElement] clip popped")
} else { } else {
// Leaf element: no self-clip (region may be zero-sized; clip is handled by parent container) // Leaf element: no self-clip (region may be zero-sized; clip is handled by parent container)
e.Draw(gtx, r) 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) { 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{ bgClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))}, 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))}, 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.ColorOp{Color: color.NRGBA{R: col.R, G: col.G, B: col.B, A: col.A}}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
bgClip.Pop() bgClip.Pop()
fmt.Println("[DEBUG drawBg] bg drawn")
} }
func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, col Color) { func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Region, align TextAlign, 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)
if str == "" { if str == "" {
return return
} }
// Layout text with width constraints (per layout_rendering.md) params := text.Parameters{
r.shp.LayoutString(text.Parameters{ PxPerEm: fixed.I(gtx.Sp(size)),
PxPerEm: fixed.I(gtx.Sp(size)), MinWidth: 0,
MinWidth: 0, MaxWidth: maxInt32,
MaxWidth: maxInt32, MaxLines: 1,
MaxLines: 1, }
}, str)
r.drawLineText(gtx, reg.X, reg.Y, col) if align == AlignStart {
fmt.Println("[DEBUG drawText] text drawn") // No measurement needed — draw at reg.X directly
r.shp.LayoutString(params, str)
r.drawLineText(gtx, reg.X, reg.Y, col)
} 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. // 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) { 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 { if img == nil {
fmt.Println("[DEBUG drawPng] icon nil, skipping")
return 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)) xPx := int(r.toPx(reg.X))
yPx := int(r.toPx(reg.Y)) yPx := int(r.toPx(reg.Y))
wPx := int(r.toPx(width)) wPx := int(r.toPx(w))
hPx := int(r.toPx(height)) hPx := int(r.toPx(h))
_ = image.Rect(xPx, yPx, xPx+wPx, yPx+hPx) origW := img.Bounds().Dx()
stack := op.Offset(image.Pt(xPx, yPx)).Push(gtx.Ops) 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.NewImageOp(img).Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
stack.Pop() scale.Pop()
fmt.Println("[DEBUG drawPng] png drawn") offset.Pop()
} }

BIN
pad

Binary file not shown.