Pad/doc/element_model.md
Greg Pomerantz 8b09697e6b Add search button to top bar, move cursor info to bottom bar
StatusBar (top bar):
- Added Search icon (always visible, toggles search bar)
- Removed cursor position from top bar (moved to bottom bar)
- Fixed icon slots: Cut, Copy, Search, Paste (left to right)
- File size between Paste and Conflict icons

BottomBar (new element):
- Always visible at bottom of editor page (24 DP fixed height)
- Left: cursor position (Ln X, Col Y)
- Right: word wrap status (On/Off)
- Cursor position updated on every cursor movement
- Word wrap status persisted to .pad/state.json

Search button:
- Tap toggles search bar visible/invisible
- When active: shows SearchBar element, focuses text field, shows keyboard
- When inactive: hides SearchBar, clears query, hides keyboard
- Primary search activation method on Android (Ctrl+F still works as secondary)

Layout:
- Top: StatusBar (filename + action icons)
- Middle: SearchBar (when active) + TextField (editor)
- Bottom: BottomBar (cursor position + word wrap status)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-08 15:13:16 -04:00

15 KiB
Raw Blame History

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:

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.

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

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

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
    WordWrap     bool      // true = wrap long lines visually (default)
    WrapWidth    unit.Dp   // width at which wrapping occurs (auto if 0)
}

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.

Word wrap: when WordWrap is true, long lines are broken visually at word boundaries within the element's region width. This is a display-only feature — no newlines are inserted into the underlying text. The logic layer computes wrapped display lines from the raw text, region width, and estimated character advance. Users can toggle word wrap off (horizontal scroll instead).

type SearchBar struct {
    /* region, visible, id — unexported */
    Query       string  // current search text
    Match       int     // current match index (0-based)
    Total       int     // total number of matches
    Forward     bool    // true = last search was forward, false = backward
}

Used for: in-editor text search. Appears as a narrow bar below the header. Contains a text field and up/down arrows. The logic layer:

  1. Finds all occurrences of Query in the visible buffer (or full file for small files)
  2. Sets Match to the current position in the match list
  3. Moves the editor cursor to the matched text on each keystroke or arrow tap
  4. Up arrow = previous match, down arrow = next match

3.4 Cursor

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.5 Lists

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.6 Alphabet Index

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.7 Buttons

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.8 Merge Hunk

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.9 Status Bar (Top Bar)

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
    Right        string  // e.g., "1024 / 50000 bytes"
    CutCopy      bool    // true = show cut icon in left slot
    Copy         bool    // true = show copy icon in left slot (next to cut)
    Search       bool    // true = show search icon (toggle search bar)
    Paste        bool    // true = show paste icon in right slot
    ConflictIcon bool    // true = show conflict warning icon (rightmost)
}

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, Search, Paste) + file size + conflict icon.

Fixed icon slots (24×24 DP each, fixed positions):

  • Cut icon: leftmost action icon
  • Copy icon: second from left (36 DP from Cut icon)
  • Search icon: third from left (36 DP from Copy icon)
  • Paste icon: fourth from left (36 DP from Search icon)
  • Conflict icon: rightmost

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, 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

type BottomBar struct {
    /* region, visible, id — unexported */
    CursorPos    string  // e.g., "Ln 10, Col 5"
    WordWrap     bool    // true = word wrap is enabled (shown for reference)
}

The bottom bar appears at the bottom of the editor page. It displays cursor position and editor state information. It is always visible (no icons, just text).

Layout:

┌──────────────────────────────────────────────────────────────┐
│  Ln 10, Col 5    Word Wrap: On                               │
└──────────────────────────────────────────────────────────────┘
  • Left: Cursor position (line number, column number)
  • Right: Word wrap status (On/Off)

The BottomBar's Region.H is fixed (24 DP). It sits below the editor content.

3.11 Toast / Notification

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.11 Spacer

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:

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. Status bar (top)
3. Search bar or main content
4. List or text area
5. Overlay elements (cursor, selection highlight)
6. Bottom bar

7. Theme

Minimal theming via a Theme struct passed to the renderer:

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{
    StatusBar{
        Filename: "notes.txt", FilenameExp: false,
        Right: "1024 / 50000",
        CutCopy: true, Copy: false, Search: false, Paste: true, ConflictIcon: false,
    },
    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", 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.

The StatusBar contains:

  • Filename on line 1 (truncated with ellipsis if too long)
  • Action icons (Cut, Copy, Search, Paste) on line 2, fixed positions
  • File size between Paste and Conflict icons

The BottomBar contains:

  • Cursor position on the left (e.g., "Ln 47, Col 12")
  • Word wrap status on the right (e.g., "Word Wrap: On")

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:

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