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:
parent
97d869db6c
commit
6720359360
57
cmd/pad/main.go
Normal file
57
cmd/pad/main.go
Normal 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
233
doc/conflict_resolution.md
Normal 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
378
doc/element_model.md
Normal 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 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 {
|
||||||
|
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 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 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
14
go.mod
Normal 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
21
go.sum
Normal 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
106
internal/ui/element.go
Normal 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
70
internal/ui/render.go
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user