- 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>
380 lines
11 KiB
Markdown
380 lines
11 KiB
Markdown
# 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 142–148"
|
||
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 142–148)"},
|
||
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
|