Pad/doc/element_model.md
Greg Pomerantz dfc112f5d4 Update specs to match Element interface implementation
- Element is an interface with Region() and Visible() methods
- Concrete types have unexported region/visible/id fields
- Constructors (NewLabel, NewListView) replace embedded Element struct
- Theme simplified to FontSize only
- Removed OnSelect/OnPress callback IDs from element types
- Added internal/ui/ to architecture in spec.md

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-07 18:19:54 -04:00

380 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Element Model Specification
## 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.
```
Logic: (State, Event) → ([]Element, Commands)
Render: []Element → pixels
Test: assert on []Element directly
```
## 2. Core Types
### 2.1 Coordinate System
Consistent with Gioui and standard 2D graphics:
- **Origin (0, 0)**: top-left corner of the application surface
- **Y-axis**: increases downward
- **X-axis**: increases rightward
- **Screen orientation**: the application surface rotates with the device (handled by the OS / Gioui surface). The logic layer receives the current surface dimensions and recomputes layouts accordingly.
### 2.2 Units
Consistent with Gioui's `unit` package:
| Unit | Go Type | Use |
|---|---|---|
| **Dp** (device-independent pixels) | `unit.Dp` (`float32`) | Element positions, sizes, spacing |
| **Sp** (scaled pixels) | `unit.Sp` (`float32`) | Font sizes (respects user text scaling preference) |
| **Px** (raw device pixels) | `int` | Never used in the logic layer; the renderer converts Dp→Px using `Metric.PxPerDp` |
The logic layer works exclusively in `Dp` (positions/sizes) and `Sp` (fonts). The renderer converts to raw pixels using the `Metric` provided by Gioui's transaction context.
### 2.3 Element Interface
All UI elements implement the `Element` interface, which exposes the region and visibility:
```go
type Element interface {
Region() Region
Visible() bool
}
```
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.
```go
type Region struct {
X, Y unit.Dp
W, H unit.Dp
}
type Label struct { /* unexported region, visible, id + exported Text, Align, ... */ }
func NewLabel(text string, fontSize unit.Sp, region Region) Label
func (l Label) Region() Region
func (l Label) Visible() bool
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.
## 3. Element Catalog
### 3.1 Static Text
```go
type Label struct {
/* region, visible, id — unexported */
Text string
Align TextAlign // start, center, end
FontSize unit.Sp // 0 = theme default
Color Color // 0 = theme default
Bold bool
}
type TextAlign int // Start, Center, End
```
Used for: headers, status bar text, file sizes, line numbers.
### 3.2 Interactive Text
```go
type TextField struct {
/* region, visible, id — unexported */
Value string
Placeholder string
Focused bool // true = show cursor, accept keyboard input
Multiline bool // true = full-height text area (editor)
ScrollOffset unit.Dp // vertical scroll position (Dp)
VisibleLines []Line // for multiline: the lines to render
}
type Line struct {
Text string
LineNumber int // 1-indexed, for display
}
```
Used for: search bar (`Multiline: false`), editor buffer (`Multiline: true`).
The editor's `TextField` only contains lines visible in the current viewport. The logic layer determines which lines to include based on scroll offset and viewport height.
### 3.3 Cursor
```go
type Cursor struct {
/* region, visible, id — unexported */
Line int // line number (0-indexed into VisibleLines)
Column int // character offset within the line
Blinking bool // current blink state
Selection *Selection
}
type Selection struct {
StartLine, StartCol int
EndLine, EndCol int
}
```
Rendered as an overlay inside a `TextField`. The logic layer computes cursor position from byte offset.
### 3.4 Lists
```go
type ListView struct {
/* region, visible, id — unexported */
Items []ListItem
ScrollOffset int // index of the first visible item
Selected int // index of selected item (-1 = none)
}
type ListItem struct {
Text string
Subtext string // optional secondary text (e.g., file size, date)
Selected bool // highlighted
}
```
Used for: directory browser. Only contains items for the current viewport.
### 3.5 Alphabet Index
```go
type AlphaIndex struct {
/* region, visible, id — unexported */
Letters []string // visible letters (e.g., ["A", "B", "C", ...])
ActiveLetter string // currently pressed letter (for highlighting)
}
```
Used for: quick navigation in the directory browser.
### 3.6 Buttons
```go
type Button struct {
/* region, visible, id — unexported */
Text string
Enabled bool
Primary bool // true = emphasized style (e.g., filled background)
}
```
Used for: merge resolution (ours/theirs/both), dismiss, apply.
### 3.7 Merge Hunk
```go
type MergeHunk struct {
/* region, visible, id — unexported */
HunkNumber int // N of M
TotalHunks int
LineRange string // display text, e.g., "Lines 142148"
ContextLines []string // unchanged lines (shared)
OurLines []string // our version of the changed region
TheirLines []string // their version of the changed region
Resolution HunkResolution
}
type HunkResolution int // Unresolved, KeepOurs, KeepTheirs, MergeBoth
```
Used for: conflict resolution UI. The logic layer produces one `MergeHunk` element plus navigation buttons.
### 3.8 Status Bar
```go
type StatusBar struct {
/* region, visible, id — unexported */
Left string // e.g., "Ln 10, Col 5"
Right string // e.g., "1024 / 50000 bytes"
}
```
### 3.9 Toast / Notification
```go
type Toast struct {
/* region, visible, id — unexported */
Text string
Timeout time.Duration // auto-dismiss after this duration
}
```
Used for: "undo skipped (text changed)", "file saved", "conflict detected".
### 3.10 Spacer
```go
type Spacer struct {
/* region, visible, id — unexported */
// Region.H defines the spacer height
}
```
## 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).
### Layout Pass
```
1. Given: screen width, screen height, state
2. Compute: region for each element
3. Emit: []Element with filled Region fields
```
The layout pass is pure: `(State, ScreenSize) → []Element`. It is testable.
Example for the browser page (values in Dp):
```
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}
```
The logic layer knows the screen dimensions (from the renderer on init) and computes regions accordingly.
### Scrolling
Scrollable elements (ListView, TextField multiline) contain only the visible items. The logic layer:
1. Tracks scroll offset (pixels or item index)
2. Computes which items are visible given the offset and viewport height
3. Emits only those items
4. Updates `ScrollOffset` field for the renderer to maintain scroll position
Scroll events from the renderer are routed back to the logic layer, which updates the offset and re-renders.
## 5. Input Routing
Interactive elements have an `ID` field. The renderer reports input events as:
```go
type InputEvent struct {
ElementID string
Type InputType // Tap, DoubleTap, LongPress, Scroll, KeyDown, KeyUp
Data any // type-specific payload (key code, scroll delta, etc.)
}
```
The logic layer routes `InputEvent` to the appropriate handler based on `ElementID`.
## 6. Rendering Order
Elements are rendered in slice order. Later elements draw on top of earlier ones. Typical order:
```
1. Background (full screen)
2. Header / Label
3. Search bar or main content
4. List or text area
5. Overlay elements (cursor, selection highlight, toast)
6. Status bar (bottom)
```
## 7. Theme
Minimal theming via a `Theme` struct passed to the renderer:
```go
type Theme struct {
FontSize unit.Sp
}
```
Elements that don't specify explicit colors/sizes use theme defaults. The theme is part of the app state, not a global. Screen dimensions, header/status bar heights, and padding are layout constants known to the logic layer's layout functions, not theme fields.
## 8. Page Compositions
### 8.1 Browser Page
```
[]Element{
Label{Text: "My Documents"}, // header
TextField{Placeholder: "Search..."}, // search bar
ListView{Items: [...], ScrollOffset: 0}, // file list
AlphaIndex{Letters: ["A"..."Z"]}, // right sidebar
}
```
### 8.2 Editor Page
```
[]Element{
Label{Text: "notes.txt"}, // header (filename)
TextField{Multiline: true, VisibleLines: [...], ScrollOffset: 420},
Cursor{Line: 5, Column: 12},
StatusBar{Left: "Ln 47, Col 12", Right: "1024 / 50000"},
}
```
### 8.3 Merge Page
```
[]Element{
Label{Text: "notes.txt — Conflict"},
Label{Text: "Hunk 3 of 7 (Lines 142148)"},
MergeHunk{ContextLines: [...], OurLines: [...], TheirLines: [...]},
Button{Text: "Ours", OnPress: "resolve_ours"},
Button{Text: "Theirs", OnPress: "resolve_theirs"},
Button{Text: "Both", OnPress: "resolve_both"},
Button{Text: "Next", Primary: true, OnPress: "next_hunk"},
}
```
## 9. Testing
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)
list, ok := elems[2].(ListView)
assert.True(t, ok)
assert.Equal(t, 3, len(list.Items))
assert.Equal(t, "foo.txt", list.Items[0].Text)
}
func TestEditorCursorPosition(t *testing.T) {
state := EditorState{Cursor: 1024, Buffer: buffer}
elems := EditorLayout(state, screen)
cursor, ok := elems[1].(Cursor)
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)
}
```
No Android SDK, no Gioui, no display server. Pure Go tests.
## 10. Out of Scope
- Element animations (may be added later)
- Element transitions between pages
- Right-to-left text
- Dynamic font sizing / accessibility scaling
- Custom element types beyond the catalog above