Pad/doc/element_model.md

19 KiB
Raw Blame History

Element Model Specification

1. Overview

Pad's logic layer produces a slice of positioned elements. Each element knows how to draw itself: the Element interface includes a Draw method that delegates low-level rendering to a *Renderer. Elements are Go structs with both structural data (text, alignment, etc.) and behavioral code (their Draw implementation).

Logic:  (State, Event) → []Element
Draw:   Element.Draw(gtx, r) → r.drawText(), r.drawBg(), ...
Test:   assert on []Element directly (no renderer needed)

The Renderer provides low-level primitives (drawText, drawBg, drawPng) that elements call during their Draw method. It handles Dp→Px conversion and Gio shaper interop. The Renderer's Draw(gtx, elems) iterates the slice and calls each element's Draw method.

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

The project uses distinct Go types to prevent accidental mixing of coordinate units at compile time:

Unit Go Type Use
Dp (device-independent pixels) ui.Dp (alias of unit.Dp) Element positions, sizes, spacing in the logic/layout layer
Px (physical pixels) ui.Px (alias of int) Gio interop only (gtx.Constraints, gtx.Dp(), etc.)
Sp (scaled pixels) unit.Sp (float32) Font sizes (respects user text scaling preference; Gio's shaper requires unit.Sp)

Type Safety

ui.Dp and ui.Px are distinct named types. The compiler prevents accidental mixing:

var pos ui.Dp = ui.Dp(100)
var winW ui.Px = ui.Px(780)

// Compile error: invalid operation: pos + winW (mismatched types ui.Dp and ui.Px)
// Must use explicit conversion:
converted := ui.ToPx(pos, scale)  // Dp → Px

Conversion Functions

// Convert Dp to Px using the scale factor (pixels per DP)
func ToPx(dp Dp, scale float32) Px

// Convert Px to Dp using the scale factor
func ToDp(px Px, scale float32) float32

Architecture

  • Logic/Layout layer: Works exclusively in Dp. Element regions, positions, and sizes are all in Dp.
  • Renderer: Converts DpPx at Gio interop boundaries using gtx.Metric.PxPerDp.
  • main.go: Converts app.ConfigEvent pixel dimensions to Dp before passing to the logic layer.

The Renderer holds a ScaleProvider interface (backed by *editor.State) that provides the current scale factor. It converts Dp↔Px on demand via r.toPx() / r.toDp(). The main window clip is applied at the top of Renderer.Draw() to bound all drawing to the window area.

2.3 Element Interface

All UI elements implement the Element interface. Elements know their region and visibility, and each element draws itself by delegating to the *Renderer:

type Element interface {
    Region() Region
    Visible() bool
    Draw(gtx layout.Context, r *Renderer)
}

Concrete elements are Go structs with unexported fields for region, visible, and id. Constructors (NewLabel, NewContainer, NewIcon, etc.) set these fields, keeping the API clean and preventing external mutation.

type Region struct {
    X, Y Dp  // ui.Dp, not unit.Dp
    W, H Dp
}

type Label struct { /* unexported region, visible, id + exported Text, Align, ... */ }
func NewLabel(text string, fontSize unit.Sp, region Region, align TextAlign) Label
func (l Label) Region() Region
func (l Label) Visible() bool
func (l Label) Draw(gtx layout.Context, r *Renderer) { r.drawText(...) }
func (l Label) ID() string

The Renderer's Draw(gtx, elems) iterates the element slice, skips invisible elements, and calls each element's Draw method. Container elements additionally clip to their bounds and offset their children. 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.0 Container

Composite element that holds child elements within a clipped region with an optional background. Children's regions are relative to the Container's origin.

type Container struct {
    /* region, visible, id — unexported */
    background Color
    children   []Element
}
func NewContainer(region Region, bg Color, children []Element) Container
func (c Container) Draw(gtx layout.Context, r *Renderer)

Container.Draw() draws its background (if any). The Renderer's drawElement handles clipping to the container's bounds and offsetting children before recursing into them. Children are drawn in slice order.

Used for: StatusBar, BottomBar, and any grouped UI region with a shared background and containment clipping.

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.1b Icon

Displays a named icon image (loaded from embedded PNGs). The icon automatically scales to fill its region when Size is 0, using the region's W and H as the target dimensions.

type Icon struct {
    /* region, visible, id — unexported */
    Name string  // icon name, e.g. "cut", "copy", "paste"
    Size Dp      // 0 = auto-scale to region W×H
}
func NewIcon(name string, region Region, size Dp) Icon
func (i Icon) Draw(gtx layout.Context, r *Renderer)

Icons are loaded from the embedded filesystem (icons/*.png). The Renderer caches them in a map and renders via r.drawPng(). When Size is 0 (the default), Icon.Draw passes region.W and region.H as the target dimensions, and r.drawPng() applies an affine scale transform so the source image fills that space. The Size field can be set to a non-zero value to override the region-based sizing.

Used for: action icons in the StatusBar (cut, copy, paste).

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)
}
func NewButton(text string, enabled bool, primary bool, region Region) Button
func (b Button) Draw(gtx layout.Context, r *Renderer)

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) — Composition

The status bar is not a standalone element type. It is composed from Container, Label, and Icon elements:

// EditorLayout builds the status bar as:
Container{
    region: Region{X: margin, Y: margin, W: screenWidth - margin*2, H: 52},
    background: Color{R: 230, G: 230, B: 230, A: 255},
    children: []Element{
        // Row 1: filename
        NewLabel("filename.txt", 14, Region{X: 0, Y: 2, W: statusBarW, H: 20}, AlignStart),
        // Row 2: cut, copy, paste icons (Size=0 → auto-scale to region W×H)
        NewIcon("cut", Region{X: 0, Y: 28, W: 24, H: 24}, 0),
        NewIcon("copy", Region{X: 48, Y: 28, W: 24, H: 24}, 0),
        NewIcon("paste", Region{X: 96, Y: 28, W: 24, H: 24}, 0),
    },
}

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, Paste) on the left. Icons are 24×24 DP each with Size=0, so they auto-scale to fill their regions.

The Container clips its children to its bounds and provides a shared background. Children use regions relative to the Container's origin — the Renderer applies op.Offset automatically when drawing Container children.

When a sync conflict is detected for the active file, a conflict Icon appears. Tapping it navigates to the merge resolution page.

3.10 Bottom Bar — Composition

The bottom bar is not a standalone element type. It is composed from Container and Label elements:

// EditorLayout builds the bottom bar as:
Container{
    region: Region{X: margin, Y: screenHeight - margin - 24, W: screenWidth - margin*2, H: 24},
    background: Color{R: 230, G: 230, B: 230, A: 255},
    children: []Element{
        NewLabel("Ln 47, Col 12", 12, Region{X: 0, Y: 2, W: barW, H: 20}, AlignStart),
        NewLabel("1024 / 50000", 12, Region{X: 0, Y: 2, W: barW, H: 20}, AlignCenter),
        NewLabel("Wrap: On", 12, Region{X: 0, Y: 2, W: barW, H: 20}, AlignEnd),
    },
}

The bottom bar appears at the bottom of the editor page. It displays cursor position, byte position, and word wrap status. It is always visible with a fixed height of 24 DP.

Layout:

┌──────────────────────────────────────────────────────────────┐
│  Ln 10, Col 5    1024 / 50000    Wrap: On                   │
└──────────────────────────────────────────────────────────────┘
  • Left: Cursor position (line number, column number)
  • Center: Byte position (current byte / total file bytes)
  • Right: Word wrap status (tap to toggle On/Off)

3.11 Toast / Notification

type Toast struct {
    /* region, visible, id — unexported */
    Text    string
    Timeout int // auto-dismiss after this many milliseconds
}
func NewToast(region Region, text string, timeout int) Toast

Used for: "undo skipped (text changed)", "file saved", "conflict detected".

3.12 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. Elements draw themselves: the Renderer iterates the slice and calls Element.Draw(gtx, r) for each visible element. Container elements additionally clip to their bounds and offset their children.

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: (ScreenSize) → []Element. It is testable.

Currently implemented: EditorLayout(screenWidth, screenHeight ui.Dp) []ui.Element in internal/editor/state.go. It produces:

Screen: 390 × 844 Dp

Container (StatusBar)    → Region{X:10, Y:10, W:370, H:52}
Container (BottomBar)    → Region{X:10, Y:810, W:370, H:24}

The logic layer knows the screen dimensions (converted from raw pixels to Dp in main.go) 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: Container with Labels and Icons
    Container{
        background: gray,
        children: []Element{
            NewLabel("notes.txt", 14, ..., AlignStart),     // filename
            NewIcon("cut", Region{X:0, Y:28, W:24, H:24}, 0),       // cut (auto-scale)
            NewIcon("copy", Region{X:48, Y:28, W:24, H:24}, 0),     // copy (auto-scale)
            NewIcon("paste", Region{X:96, Y:28, W:24, H:24}, 0),    // paste (auto-scale)
        },
    },
    // (future) TextField{Multiline: true, ...},
    // (future) Cursor{Line: 5, Column: 12, Selection: &Selection{...}},
    // BottomBar: Container with Labels
    Container{
        background: gray,
        children: []Element{
            NewLabel("Ln 47, Col 12", 12, ..., AlignStart),
            NewLabel("1024 / 50000", 12, ..., AlignCenter),
            NewLabel("Wrap: On", 12, ..., AlignEnd),
        },
    },
}

StatusBar and BottomBar are compositions of Container, Label, and Icon — not standalone element types. The EditorLayout function in internal/editor/state.go builds these compositions. Icon elements use Size=0 so they auto-scale to fill their 24×24 DP regions.

Future pages will add TextField (editor content), SearchBar, Cursor, and Button elements.

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 TestEditorLayout(t *testing.T) {
    elems := EditorLayout(ui.Dp(390), ui.Dp(844))

    // StatusBar is a Container
    status, ok := elems[0].(Container)
    assert.True(t, ok)
    assert.Equal(t, ui.Dp(52), status.Region().H)
    assert.Equal(t, 3, len(status.children))

    // BottomBar is a Container
    bottom, ok := elems[1].(Container)
    assert.True(t, ok)
    assert.Equal(t, ui.Dp(24), bottom.Region().H)
}

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