Add Gioui scaffolding with Label and ListView rendering

- Element interface with Region() and Visible() methods
- Label and ListView element types with constructors
- Renderer that clips and draws elements in slice order
- Main app loop with Gioui window and frame events

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Greg Pomerantz 2026-05-07 17:45:56 -04:00
parent 97d869db6c
commit 6720359360
7 changed files with 879 additions and 0 deletions

57
cmd/pad/main.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"log"
"gioui.org/app"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"pad/internal/ui"
)
func main() {
go func() {
w := new(app.Window)
w.Option(app.Title("Pad"))
w.Option(app.Size(unit.Dp(390), unit.Dp(844)))
if err := run(w); err != nil {
log.Fatal(err)
}
}()
app.Main()
}
func run(w *app.Window) error {
var ops op.Ops
shaper := text.NewShaper()
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
elems := starterElements()
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
renderer.Draw(gtx, elems)
e.Frame(&ops)
}
}
}
// starterElements returns a hardcoded set of elements for initial testing.
func starterElements() []ui.Element {
return []ui.Element{
ui.NewLabel("Pad", 20, ui.Region{X: 0, Y: 0, W: 390, H: 48}),
ui.NewListView(ui.Region{X: 0, Y: 48, W: 390, H: 700},
[]ui.ListItem{
{Text: "notes.txt"},
{Text: "ideas.txt"},
{Text: "todo.txt"},
}),
}
}

233
doc/conflict_resolution.md Normal file
View File

@ -0,0 +1,233 @@
# Conflict Resolution Specification
## 1. Overview
Pad operates on a directory synced by Syncthing. External modifications to files originate from edits on other devices, delivered via Syncthing. This document specifies how Pad detects, classifies, and resolves such changes.
## 2. Syncthing Conflict File Format
When Syncthing detects that two devices have modified the same file, it preserves both versions:
- **Original file** (`notes.txt`) — the local device's version (what Pad edited)
- **Conflict file** (`notes.sync-conflict-2024-05-15-1430-ABCDEF1.txt`) — the remote device's version
**Naming pattern:** `<base>.sync-conflict-<date>-<time>-<modifiedBy>.<ext>`
| Component | Format | Example |
|---|---|---|
| `base` | Original filename without extension | `notes` |
| `date` | ISO date | `2024-05-15` |
| `time` | 24-hour time, no separator | `1430` |
| `modifiedBy` | Remote device short ID | `ABCDEF1` |
To resolve the original file from a conflict file, strip the `.sync-conflict-<date>-<time>-<modifiedBy>` suffix and reattach the extension.
## 3. Change Classification
When the file system watcher detects a change, Pad classifies it into one of two categories:
| Category | Trigger | Meaning |
|---|---|---|
| **External update** | File content changed on disk, no conflict file exists | Another device edited and synced; our device received the update cleanly |
| **Sync conflict** | A `.sync-conflict-*` file appeared for a file we have open or know about | Both devices edited the same file; Syncthing could not merge automatically |
### Detection Logic
```
ON file_modified(event):
original = event.path
conflict = find_conflict_file_for(original)
IF conflict exists:
→ SYNC CONFLICT
ELSE:
→ EXTERNAL UPDATE
ON new_file(event):
IF event.path matches conflict pattern:
original = resolve_original(event.path)
→ SYNC CONFLICT for original
```
## 4. External Update (No Conflict)
The file was modified on another device and synced cleanly. Our local edits (if any) were already synced before the remote edit, so there is no divergence.
### Procedure
1. **Detect** — watcher fires `file_modified` for the active file
2. **Read** — load the new content from disk
3. **Diff** — compute diff between the editor's buffer (old) and disk content (new) to understand what changed
4. **Re-anchor undo stack** — for each operation in the undo stack, attempt to locate its context in the new file content (see §6)
5. **Replace buffer** — swap the edit buffer with the new content
6. **Preserve cursor** — if the cursor position falls within a changed region, move it to the start of the changed region; otherwise keep it at the same byte offset
7. **Continue** — user can edit immediately, undo works with re-anchored operations
### Edge Cases
| Scenario | Behavior |
|---|---|
| User has un-saved edits (within debounce window) | Flush auto-save immediately, then proceed |
| User is actively typing | Complete the current keystroke, then apply update on next detection |
| File was truncated externally | Load empty/new content, reset cursor to 0 |
| Undo operations cannot be re-anchored | Drop unanchorable operations silently (context no longer exists in new content) |
## 5. Sync Conflict
Both devices edited the same file. Syncthing preserved both versions. Pad must help the user integrate the changes.
### Procedure
1. **Detect** — watcher fires `new_file` for a conflict file, or `file_modified` with a corresponding conflict file present
2. **Identify** — resolve the original file path from the conflict file name
3. **Load both versions:**
- **Ours** (`notes.txt`) — the local file, may contain the user's current edits
- **Theirs** (`notes.sync-conflict-*.txt`) — the remote device's version
4. **Diff** — compute a line-based diff between ours and theirs, producing a list of conflicting hunks
5. **Present merge UI** — show the user each hunk with resolution options (see §7)
6. **Apply resolution** — produce a merged file from the user's choices
7. **Persist** — write merged content to the original file (`notes.txt`), delete the conflict file
8. **Re-anchor undo stack** — same as external update (§4, step 4)
9. **Replace buffer** — swap edit buffer with merged content
10. **Continue** — user can edit immediately
### Edge Cases
| Scenario | Behavior |
|---|---|
| Conflict file for a file the user never opened | Leave it alone (out of scope; Syncthing will handle on next sync cycle) |
| Multiple conflict files for the same original (rare, from rapid edits) | Resolve the most recent one first; delete all conflict files after resolution |
| User closes the app while merge UI is shown | Persist the unresolved state; reopen merge UI on relaunch |
| Conflict file is deleted by user externally | Treat as abandoned; no action needed |
## 6. Undo Stack Re-anchoring
After any external change or conflict resolution, the undo stack must be reconciled with the new file content.
### Algorithm
For each chain in the undo stack (most recent first):
1. **Locate the head's context** in the new file content:
- For **insert chains**: search for `before` + `after` pattern (text surrounding the insertion point)
- For **delete chains**: search for `after` pattern (text after the deletion point)
2. **If context is found:**
- Update the head's `pos` to the new location
- All tail entries inherit from the re-anchored head
- Chain is preserved
3. **If context is not found:**
- Remove the entire chain from the undo stack
- The text surrounding the operation has changed too much to reliably replay
4. **If context is found multiple times:**
- Pick the occurrence closest to the original `pos`
- If ambiguity remains, pick the first occurrence
### Limitations
- Re-anchoring is best-effort. If the external change modified text within a chain's context window, that chain is lost.
- This is acceptable — the alternative (full diff-based rebasing) is significantly more complex and error-prone.
## 7. Merge UI
The merge UI presents conflicting hunks to the user for per-hunk resolution.
### Hunk Structure
Each hunk represents a region where ours and theirs diverge:
```
─── Hunk 3 of 7 ─────────────────────────
Line 142 — Line 148
Context (unchanged):
The quick brown fox
Ours:
jumps over the lazy dog
The end.
Theirs:
jumps over the sleepy cat
A new paragraph begins.
The end.
```
### Resolution Options (per hunk)
| Choice | Result |
|---|---|
| **Keep ours** | Use our version of this hunk |
| **Keep theirs** | Use their version of this hunk |
| **Merge both** | Concatenate ours + theirs (ours first, then a blank line, then theirs) |
### UI Behavior
- **One hunk at a time** — shown full screen, swipe or tap to navigate between hunks
- **Tap a version** to preview it (highlight in the full file context)
- **Tap a resolution button** to accept and move to next hunk
- **Apply all** button appears after the last hunk is resolved
- **Cancel** — abort merge, keep our version, leave conflict file untouched (will reappear on next sync)
### State Persistence
The merge session (which conflict file, which hunks, which resolutions) is persisted to the state file. If the app is killed during a merge:
1. On relaunch, detect the unresolved conflict file
2. Re-compute the diff (idempotent)
3. Restore any resolutions the user already made (from state file)
4. Present the merge UI from the first unresolved hunk
## 8. Diff Engine
Pad needs a line-based diff engine that produces hunks with context.
### Requirements
- **Line-based** — hunks are groups of lines, not character offsets
- **Streaming** — must not load entire files into memory; process line by line
- **Context-aware** — each hunk includes N context lines before and after the change (configurable, default 3)
- **Positioned** — each hunk reports the starting line number in both versions
- **Efficient** — must handle files with millions of lines without O(n²) behavior
### Output Format
```go
type Hunk struct {
OurStartLine int // Starting line in our version (1-indexed)
OurLines []string // Lines from our version (replaced by TheirLines)
TheirStartLine int // Starting line in their version (1-indexed)
TheirLines []string // Lines from their version (replaces OurLines)
ContextBefore []string // Unchanged lines before the hunk
ContextAfter []string // Unchanged lines after the hunk
}
```
### Implementation Options
| Approach | Pros | Cons |
|---|---|---|
| **Go `textdiff` / `diffmatchpatch`** | Available, battle-tested | Character-based, not line-based; may need wrapping |
| **Shell `diff` invocation** | Standard, line-based, efficient | Requires subprocess, harder to stream on Android |
| **Custom Myers diff** | Full control, line-based, streaming | More code to write and test |
| **Import `github.com/sergi/go-diff`** | Line-based, Go-native | External dependency, may not stream |
**Recommendation:** Import a Go-native line-based diff library (e.g., `go-diff` or `diffmatchpatch` with line-level wrapping). Evaluate during implementation.
## 9. Interaction with Auto-save
| Event | Auto-save behavior |
|---|---|
| External update applied | Auto-save the new buffer content (so our disk copy matches the synced version) |
| Merge in progress | Pause auto-save (buffer is in an inconsistent state) |
| Merge applied | Auto-save the merged content immediately |
| Conflict file detected while auto-save debounce is active | Flush auto-save first, then proceed with conflict detection |
## 10. Out of Scope
- Three-way merge (we don't have access to the common ancestor; Syncthing doesn't preserve it)
- Automatic merge (all changes require user confirmation)
- Binary file conflicts (Pad only handles text files)
- Folder-level conflicts (only file-level)

378
doc/element_model.md Normal file
View File

@ -0,0 +1,378 @@
# 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.
```go
// Region defines a screen area in device-independent pixels (Dp).
type Region struct {
X, Y unit.Dp
W, H unit.Dp
}
// Element is the base of all UI elements.
type Element struct {
ID string // unique identifier for input routing
Region Region // where to draw (computed by logic/layout pass, in Dp)
Visible bool // false = skip rendering
}
```
```
Each concrete element embeds `Element` and adds its own fields.
## 3. Element Catalog
### 3.1 Static Text
```go
type Label struct {
Element
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 {
Element
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 {
Element // region is the cursor rectangle within the text field
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 {
Element
Items []ListItem
ScrollOffset int // index of the first visible item
Selected int // index of selected item (-1 = none)
OnSelect string // callback ID for routing
}
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 {
Element
Letters []string // visible letters (e.g., ["A", "B", "C", ...])
ActiveLetter string // currently pressed letter (for highlighting)
OnTap string // callback ID
}
```
Used for: quick navigation in the directory browser.
### 3.6 Buttons
```go
type Button struct {
Element
Text string
Enabled bool
Primary bool // true = emphasized style (e.g., filled background)
OnPress string // callback ID
}
```
Used for: merge resolution (ours/theirs/both), dismiss, apply.
### 3.7 Merge Hunk
```go
type MergeHunk struct {
Element
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 {
Element
Left string // e.g., "Ln 10, Col 5"
Right string // e.g., "1024 / 50000 bytes"
}
```
### 3.9 Toast / Notification
```go
type Toast struct {
Element
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 {
Element
// 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 layout pass:
```go
type Theme struct {
ScreenWidth unit.Dp
ScreenHeight unit.Dp
FontSize unit.Sp
HeaderH unit.Dp
StatusBarH unit.Dp
Padding unit.Dp
TextColor Color
BgColor Color
AccentColor Color
Metric unit.Metric // PxPerDp, PxPerSp — for renderer use
}
```
Elements that don't specify explicit colors/sizes use theme defaults. The theme is part of the app state, not a global.
## 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 render functions, and assert on the resulting `[]Element`:
```go
func TestBrowserFiltersBySearch(t *testing.T) {
state := BrowserState{Entries: allFiles, Search: "foo"}
elems := BrowserLayout(state, theme)
list := elems[2].(ListView)
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, theme)
cursor := elems[1].(Cursor)
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, theme)
hunk := elems[2].(MergeHunk)
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

14
go.mod Normal file
View File

@ -0,0 +1,14 @@
module pad
go 1.24.2
require gioui.org v0.9.0
require (
gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.3.0 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

21
go.sum Normal file
View File

@ -0,0 +1,21 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc=
gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=

106
internal/ui/element.go Normal file
View File

@ -0,0 +1,106 @@
package ui
import "gioui.org/unit"
// Region defines a screen area in device-independent pixels (Dp).
type Region struct {
X, Y unit.Dp
W, H unit.Dp
}
// Element is the base interface for all UI elements.
type Element interface {
Region() Region
Visible() bool
}
// TextAlign specifies horizontal text alignment.
type TextAlign int
const (
AlignStart TextAlign = iota
AlignCenter
AlignEnd
)
// Label displays static text.
type Label struct {
id string
region Region
visible bool
Text string
Align TextAlign
FontSize unit.Sp // 0 = theme default
Color Color // 0 = theme default
Bold bool
}
func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible }
func (l Label) ID() string { return l.id }
// TextField accepts text input or displays multiline text.
type TextField struct {
id string
region Region
visible bool
Value string
Placeholder string
Focused bool
Multiline bool
ScrollOffset unit.Dp
}
func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id }
// ListView displays a scrollable list of items.
type ListView struct {
id string
region Region
visible bool
Items []ListItem
ScrollOffset int
Selected int
}
func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id }
// ListItem is a single entry in a ListView.
type ListItem struct {
Text string
Subtext string
Selected bool
}
// Color is an RGBA color.
type Color struct {
R, G, B, A uint8
}
// Theme holds styling defaults for the UI.
type Theme struct {
FontSize unit.Sp
}
// NewLabel creates a visible Label element.
func NewLabel(text string, fontSize unit.Sp, region Region) Label {
return Label{
region: region,
visible: true,
Text: text,
FontSize: fontSize,
}
}
// NewListView creates a visible ListView element.
func NewListView(region Region, items []ListItem) ListView {
return ListView{
region: region,
visible: true,
Items: items,
}
}

70
internal/ui/render.go Normal file
View File

@ -0,0 +1,70 @@
package ui
import (
"image"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/text"
"gioui.org/widget/material"
)
// Renderer consumes a slice of elements and draws them.
type Renderer struct {
theme Theme
shp *text.Shaper
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer {
return &Renderer{theme: th, shp: shp}
}
// Draw iterates elements and draws each in slice order (back-to-front).
func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
for _, e := range elems {
if !e.Visible() {
continue
}
switch v := e.(type) {
case Label:
r.drawLabel(gtx, v)
case ListView:
r.drawListView(gtx, v)
default:
// unknown element type, skip
}
}
}
func (r *Renderer) drawLabel(gtx layout.Context, l Label) {
reg := l.Region()
c := clip.Rect{
Min: image.Point{X: int(reg.X), Y: int(reg.Y)},
Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)},
}.Push(gtx.Ops)
th := material.NewTheme()
th.Shaper = r.shp
size := l.FontSize
if size == 0 {
size = r.theme.FontSize
}
material.Label(th, size, l.Text).Layout(gtx)
c.Pop()
}
func (r *Renderer) drawListView(gtx layout.Context, lv ListView) {
reg := lv.Region()
c := clip.Rect{
Min: image.Point{X: int(reg.X), Y: int(reg.Y)},
Max: image.Point{X: int(reg.X + reg.W), Y: int(reg.Y + reg.H)},
}.Push(gtx.Ops)
th := material.NewTheme()
th.Shaper = r.shp
for _, item := range lv.Items {
material.Label(th, r.theme.FontSize, item.Text).Layout(gtx)
}
c.Pop()
}