Updates to rendering pipeline and documentation.
This commit is contained in:
parent
22c1d5bed1
commit
50e9ca5b34
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
doc/touch.md
12
doc/touch.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
if err != nil {
|
||||
return
|
||||
for _, name := range []string{"cut", "copy", "paste"} {
|
||||
data, err := iconFS.ReadFile("icons/" + name + ".png")
|
||||
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.
|
||||
|
|
@ -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{
|
||||
PxPerEm: fixed.I(gtx.Sp(size)),
|
||||
MinWidth: 0,
|
||||
MaxWidth: maxInt32,
|
||||
MaxLines: 1,
|
||||
}, str)
|
||||
r.drawLineText(gtx, reg.X, reg.Y, col)
|
||||
fmt.Println("[DEBUG drawText] text drawn")
|
||||
params := text.Parameters{
|
||||
PxPerEm: fixed.I(gtx.Sp(size)),
|
||||
MinWidth: 0,
|
||||
MaxWidth: maxInt32,
|
||||
MaxLines: 1,
|
||||
}
|
||||
|
||||
if align == AlignStart {
|
||||
// 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.
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user