Pad/doc/editor_implementation_plan.md
Greg Pomerantz d73cb0be2c Integrate real filesystem with interface abstraction
- Define pool.FileSystem interface in internal/io/pool/filesystem.go
- Move DirEntry interface to internal/io/pool/types/types.go to break
  the pool <-> mock import cycle
- Implement real.FileSystem with atomic writes (temp file + os.Rename)
- Update mock.FileSystem to satisfy pool.FileSystem interface
- Update worker pool tasks to accept pool.FileSystem interface
- Update main.go to use RealFileSystem by default, with -root flag
- Update all browser/editor/test references to types.DirEntry
2026-06-04 16:19:38 -04:00

26 KiB

Editor Implementation Plan

1. Design Principles

  • Single-Owner Pattern: The logic goroutine is the exclusive authority for modifying the editor buffer, cursor, and selection state.
  • Asynchronous I/O: File saving and loading operations are delegated to the worker pool. The UI never blocks on disk operations.
  • Buffer-Based Editing: For performance, the editor will eventually utilize a gap buffer or rope data structure. For MVP, string manipulation is acceptable if performance targets are met.
  • Event-Driven: Keyboard input, mouse interactions, and menu actions are passed through the existing inputChan to the logic goroutine.

2. Core State Structure

The State struct in internal/editor/state.go is extended to track active editing session data:

type EditorState struct {
    Buffer         string
    CursorPosition int
    GlyphLayout    GlyphLayout
    SelectionStart int           // -1 if no selection (unused)
    SelectionEnd   int           // -1 if no selection (unused)
    CursorVisible  bool          // Blink state (no timer yet)
    Filename       string        // Current file path being edited
    fileVersion    map[string]int   // per-file: version of the loaded buffer
    lastWriteVersion map[string]int // per-file: version of last successful write
    saveTimer      *time.Timer   // nil = no pending save
}

Dirty is computed, not stored:

func (e *EditorState) IsDirty() bool {
    bufVer := e.fileVersion[e.Filename]
    writeVer := e.lastWriteVersion[e.Filename]
    return bufVer > writeVer
}

Current state (as of June 2026): EditorState is defined with Dirty bool (single boolean, cleared on file switch). UndoStack is deferred. SelectionStart/SelectionEnd are present but unused. Save file feature not yet implemented.

3. Auto-Save Design

Auto-save is transparent, asynchronous, and fire-and-forget. No save button, no UI indicators, no "do you want to save?" dialog.

3.1 Per-File Version Tracking

The Dirty boolean is replaced by per-file version maps:

Field Meaning
fileVersion[filename] Version of the buffer currently loaded for this file (monotonically increasing)
lastWriteVersion[filename] Version of the last successful write for this file
Dirty Computed: fileVersion > lastWriteVersion

Dirty lifecycle:

Event Effect
Edit fileVersion[filename]++ → Dirty becomes true
Write succeeds lastWriteVersion[filename] = fileVersion[filename] → Dirty becomes false
Write fails lastWriteVersion unchanged → Dirty stays true
Switch files Old file's versions preserved in map → Dirty preserved
Re-open file fileVersion preserved (not reset) → Dirty preserved
Next edit on dirty file New save attempt (retry)

This means:

  • Dirty state survives file switches
  • Dirty state survives file re-opens
  • Failed writes leave the file dirty
  • No automatic retry — the next edit triggers a new save attempt

3.2 Debounce Timer

A 1-second debounce timer fires after the last edit. The timer is reset on each new edit, so only one write is dispatched per debounce window.

// In inputChan handler, after processing events:
if l.state.Editor.IsDirty() && l.state.Editor.Filename != "" {
    if l.saveTimer != nil {
        l.saveTimer.Stop()
    }
    gen := l.saveGeneration
    l.saveGeneration++
    buffer := l.state.Editor.Buffer  // capture in logic goroutine
    l.saveTimer = time.AfterFunc(1*time.Second, func() {
        if l.saveGeneration == gen {
            l.workerPool.DispatchNonBlocking(
                pool.NewWriteFileTask(l.state.Editor.Filename, []byte(buffer), l.mockFS),
            )
        }
    })
}

3.3 Generation Counter

The saveGeneration field prevents stale timer callbacks from dispatching writes:

type Logic struct {
    saveTimer      *time.Timer
    saveGeneration int   // incremented each time a new timer starts
}

Each timer callback captures its own gen value. When the callback fires, it checks l.saveGeneration == gen. If they differ, the timer was replaced and the callback drops silently.

Why Stop() alone isn't enough: time.AfterFunc creates a goroutine that waits on a timer channel. Stop() closes the channel, preventing future firings. But if the callback goroutine has already woken up (the timer fired), Stop() doesn't interrupt it. The generation check handles this in-flight case.

Scenario: Two rapid edits

Time 0s:  Edit → gen=0, saveGen=1, Timer A starts
Time 0.5s: Edit → gen=1, saveGen=2, Timer A stopped, Timer B starts
Time 1.0s: Timer A fires → gen(0) != saveGen(2) → drop
Time 1.5s: Timer B fires → gen(1) == saveGen(2) → dispatch

Only the latest timer dispatches. All others are filtered by the generation check.

3.4 Failure Mode Handling

Three likely failure modes:

Failure Recoverable? Response
Device I/O error No Log error, leave dirty, no retry
Network filesystem unavailable Maybe Log error, leave dirty, no retry
Removable media not found No (not relevant on mobile) Log error, leave dirty, no retry

No automatic retry. The dirty state persists until the next edit triggers a new save attempt. If the filesystem recovers (e.g., network comes back), the next save will succeed. If not, the file stays dirty and the user will eventually notice.

3.5 Stale Result Filtering

Write results are filtered by file path:

case res.TaskType == pool.TypeWriteFile:
    if res.FilePath != l.state.Editor.Filename {
        return  // File switch — stale result for a different file
    }
    if res.IsSuccess() {
        l.state.Editor.markSaved()
    } else {
        log.Printf("Auto-save failed for %s: %v", res.FilePath, res.Error)
        // Dirty stays true — next edit triggers retry
    }

When a write fails, lastWriteVersion is not updated. The file stays dirty. The next edit on that file triggers a new save attempt.

3.6 File Switch While Save In Flight

Edit A → Dirty=true → timer starts
Timer fires → write dispatched
Open file B → timer cancelled, dirty cleared for B
Write A result arrives → path check: A != B → ignored

The file switch cancels the timer and clears dirty for the new file. An in-flight save for the old file is harmless — its result is ignored by the path check.

3.7 Atomic Write (Temp + Rename)

The mock filesystem implements atomic writes via temp file + rename:

func (fs *FileSystem) WriteFileAtomic(path string, content []byte) error {
    // 1. Write to .tmp/ subdirectory (top-level, outside user directories)
    tempPath := ".tmp/" + filepath.Base(path)
    fs.files[tempPath] = &File{...}

    // 2. Atomic rename — insert + delete in one locked operation
    fs.files[path] = &File{...}
    delete(fs.files, tempPath)

    // 3. Send change notifications (outside lock)
    fs.sendNotifications(...)
}

Key design points:

  • Temp files live in top-level .tmp/ — never in any user directory
  • The rename is atomic: both insert and delete happen while holding the mutex
  • No partial writes are ever visible
  • Change notifications fire after the lock is released

3.8 Concurrency Correctness

Concern How it's handled
Multiple writes in flight Generation counter prevents old timer callbacks from dispatching
Out-of-order completion Not an issue — only one write dispatched at a time (guaranteed by generation counter)
Write fails lastWriteVersion unchanged → dirty stays true → next edit retries
Write dropped (channel full) No result → dirty stays true → next edit retries
File switch while save in flight Timer cancelled; stale result ignored by path check
Re-open dirty file fileVersion preserved → dirty stays true → next edit retries
Buffer race with timer callback Buffer captured in logic goroutine before timer starts
No blocking on critical path DispatchNonBlocking never blocks; timer callback is separate goroutine

4. Implementation Phases

Phase 1: Basic Buffer Management & Cursor

  • Define EditorState (Cursor, Selection, Dirty flag). — DONE
  • Implement GlyphLayout capture in drawWrappedText and feedback via layoutChan. — DONE
  • Implement cursor navigation (Arrow keys: Left, Right, Up, Down). — DONE (Left/Right: byte-based ±1; Up/Down: GlyphLayout-based)
  • Implement basic buffer updates (Insert character, Delete/Backspace). — DONE
  • Implement cursor display. — DONE (Rendered inline in Renderer.drawWrappedText using GlyphLayout)
  • Implement cursor blink animation. — NOT STARTED (CursorVisible field exists but no timer drives it)
  • Tap-to-position cursor from GlyphLayout. — DONE (SetCursorFromPoint groups glyphs by Y, finds closest X)

Phase 2: File IO Integration

  • Implement SaveFile handler: Debounced auto-save via WriteFileTask to worker pool. — DONE (see §3)
  • Implement LoadFile handler: Dispatch ReadFileTask to worker pool. — DONE
  • Status bar integration: Show "Saving..." indicator, "Modified" status. — UPDATED: Added <dirty> indicator when WriteFailed(). status bar also needs "Saving..." indicator.

Phase 3: Text Editing Operations

  • Implement Cut/Copy/Paste interactions. — NOT STARTED (icon elements exist but handlers are nil)
  • Implement multi-line text navigation (Home/End, PageUp/PageDown). — NOT STARTED
  • Implement text selection display and mouse interaction. — NOT STARTED (SelectionStart/SelectionEnd fields exist in EditorState but are unused)
  • Implement soft-wrap toggle. — DONE (ToggleWordWrap in state.go, wired to bottom bar "Wrap" label tap)

Phase 4: Polish & Advanced Features

  • Undo/Redo stack implementation. — NOT STARTED (UndoStack field is commented out in EditorState)
  • Performance testing with large files (>1MB). — NOT STARTED

4.5 Real Filesystem Integration

To support real filesystem operations with minimal changes, we are abstracting the filesystem behind an interface.

4.5.1 Design

  • Interface Definition: Define a FileSystem interface in internal/io/pool/ that captures the required operations (ReadDir, ReadFile, WriteFile, DirExists, etc.).
  • Mock Update: Update the existing internal/io/pool/mock to satisfy this interface.
  • Real Implementation: Create internal/io/pool/real that implements the FileSystem interface using standard os and path/filepath packages.
  • Dependency Injection: Update worker pool tasks to accept the FileSystem interface instead of the concrete *mock.FileSystem type.
  • Atomic Writes: The RealFileSystem must also implement atomic writes via temporary files and os.Rename to maintain the consistency guarantees of the mock.

4.5.2 Implementation Roadmap

  1. Define pool.FileSystem interface.
  2. Update mock.FileSystem to satisfy the interface.
  3. Update pool tasks to accept the interface.
  4. Implement real.FileSystem.
  5. Update main.go to inject the correct implementation based on build/runtime flags.

5. Interaction Flow (Example: Keyboard/Mouse Input)

  1. User performs an action (types a character, taps an icon).
  2. UI (Renderer) registers the element's interaction (event.Op for keys, gesture.Add for mouse) within its clip context.
  3. Main Loop captures the event (using key.Filter for keys, gesture.Update for mouse).
  4. Main Loop generates an InputEvent and sends it to inputChan.
  5. Logic Goroutine receives the event, triggers the corresponding Handler in TheState.
  6. Logic Goroutine modifies state, and sends updated elements back through frameChan.
  7. Renderer paints the new frame.

6. Performance Targets

Operation Target Mechanism
Typing latency < 16ms Single-owner logic update, immediate frame redraw
File Load (1MB) < 100ms Async worker pool read, background loading
File Save < 100ms Async worker pool write
Undo/Redo < 10ms O(1) or O(N) memory-based buffer update

7. Input Handling Mechanism

7.1 Mouse/Touch Input

  • Uses gesture.Click and gesture.Scroll.
  • Registered via event.Op and gesture Add() within the clip context of the element.
  • Processed by Renderer.CheckGestures and dispatched via InputEvent to the logic goroutine.

7.2 Keyboard Input

  • Registration: Elements register as input handlers using event.Op(gtx.Ops, elementID). This tags the current clip context for input routing.
  • Focus: Focus is managed by the logic goroutine. When an element is focused, key.FocusCmd{Tag: elementID} is submitted to the operation stack.
  • Filtering: Keyboard events are processed in the main loop using key.Filter{Focus: focusedElementID} to ensure events are only routed to the active element.
  • Routing: key.Event (for key presses) and key.EditEvent (for text input) are converted into InputEvent structs and sent to the logic goroutine via inputChan for state updates.

8. Glyph Layout Architecture

8.1 Problem

The editor must know the exact screen position of every character after shaping and word wrapping. This is required for:

  • Accurate cursor rendering (no fixed-width approximation)
  • Correct cursor navigation (arrow keys respect visual line boundaries)
  • Scroll-aware positioning (cursor follows text when viewport changes)
  • Screen resize resilience (cursor tracks text as wrap points shift)
  • Future features: mouse click-to-place, text selection, search highlight positioning

8.2 Core Principle

The renderer is the single source of truth for where characters appear on screen. Logic never guesses positions — it reads the layout the renderer already computed during the draw pass.

8.3 Data Structure

type GlyphLayout struct {
    ByteOffsets []int  // byte offset of each glyph in the buffer
    X           []Dp   // screen X (Dp) of each glyph, relative to text region origin
    Y           []Dp   // screen Y (Dp) of each glyph, relative to text region origin
    Advance     []Dp   // advance width (Dp) of each glyph
}

Each index i represents one glyph. ByteOffsets[i] is the byte position in the buffer, (X[i], Y[i]) is its screen location, and Advance[i] is its width. The slice length equals the total number of glyphs (one per rune).

Derived values:

  • LastLineY = Y[len(Y)-1] (last glyph's baseline Y) — replaces the separate lastLineY feedback
  • Visual line breaks = any index i where Y[i] > Y[i-1]
  • Cursor at byte offset b → binary search ByteOffsets for exact match, then read (X[idx], Y[idx])

Storing all X advances is not expensive — a single page of text is a tiny amount of memory.

8.4 Capture Point

Implemented. drawWrappedText in render.go captures full per-glyph layout data during the shaping loop:

func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos int) {
    // ... shaping setup with WrapHeuristically ...
    r.shp.LayoutString(params, str)

    var layout GlyphLayout
    byteOffset := 0
    for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
        layout.ByteOffsets = append(layout.ByteOffsets, byteOffset)
        layout.X = append(layout.X, Dp(float32(g.X>>6)/r.scale.Scale()))
        layout.Y = append(layout.Y, Dp(float32(g.Y)/r.scale.Scale()))
        layout.Advance = append(layout.Advance, Dp(float32(g.Advance>>6)/r.scale.Scale()))

        for i := uint16(0); i < g.Runes; i++ {
            _, sz := utf8.DecodeRuneInString(str[byteOffset:])
            byteOffset += sz
        }
        // ... draw logic ...
    }
    // Store captured layout; derive lastLineY from it.
    r.glyphLayout = layout
    if len(layout.Y) > 0 {
        r.lastLineY = layout.Y[len(layout.Y)-1]
    }
}

The layout is stored on the renderer as r.glyphLayout. lastLineY is derived from layout.Y[len-1]. Exposed via Renderer.GlyphLayout().

8.5 Feedback Path

Implemented. A layoutChan carries the full GlyphLayout from renderer to logic. LastLineY is derived from GlyphLayout.Y[len-1].

// main.go — after each FrameEvent
glyphLayout := renderer.GlyphLayout()
logic.LayoutChan() <- glyphLayout

// logic.go
case layout := <-l.layoutChan:
    l.state.Editor.GlyphLayout = layout
    var derivedLastLineY ui.Dp
    if len(layout.Y) > 0 {
        derivedLastLineY = layout.Y[len(layout.Y)-1]
    }
    if derivedLastLineY != l.state.LastLineY {
        l.state.LastLineY = derivedLastLineY
        l.frameChan <- l.state.layout(l.browserManager)
    }

8.6 Editor State Consumption

Implemented. EditorState contains a GlyphLayout field. All cursor operations are GlyphLayout-based.

type EditorState struct {
    Buffer         string
    CursorPosition int
    GlyphLayout    GlyphLayout
    // ...
}

Cursor rendering — handled inline in drawWrappedText (render.go). After shaping, the renderer binary-searches ByteOffsets for cursorPos and draws a 2dp vertical bar at the computed position. No separate cursor element is needed in the element tree.

Cursor movement (HandleCursorMove, state.go) — byte-based ±1 with bounds clamping. For proportional fonts this is a reasonable approximation; a glyph-index-based version could be added later.

Vertical cursor movement (HandleVerticalCursorMove, state.go) — fully GlyphLayout-based. Finds the current glyph's Y via binary search, scans for the target line's Y, then picks the glyph with the closest X on that line.

Tap-to-position (SetCursorFromPoint, state.go) — GlyphLayout-based. Groups glyphs by Y-baseline, identifies the target visual line from the tap Y, then finds the closest glyph by X distance on that line. Handles edge case of tapping past the rightmost glyph.

8.7 Correctness Guarantees

Scenario How it works
Word wrap Shaper's WrapHeuristically produces different Y values at wrap points. Layout captures them exactly.
Screen resize Next frame → new wrapWidth → shaper produces new layout → main loop sends new layout → cursor follows text.
Editing Buffer changes → layout re-computed on next frame → ByteOffsets reflect new positions.
Scroll Y values are computed minus scrollOffset → cursor Y tracks naturally.
LastLineY Derived from layout.Y[len-1] — no separate tracking needed.
Non-fixed-width fonts Advance comes from actual glyph metrics, not approximation.
Empty buffer Layout is empty (len == 0). Cursor rendering handles this as the "after last glyph" case.

8.8 Summary of Changes

File Status Notes
internal/ui/unit.go Done GlyphLayout struct defined with ByteOffsets, X, Y, Advance
internal/ui/render.go Done drawWrappedText captures full GlyphLayout; derives lastLineY from it; renders cursor inline
internal/ui/element.go Done TextField.Draw passes CursorPosition to drawWrappedText
internal/editor/logic.go Done Uses layoutChan (full GlyphLayout) instead of lastLineYChan
internal/editor/state.go Done GlyphLayout stored on EditorState; cursor rendering handled by Renderer
cmd/pad/main.go Done Sends renderer.GlyphLayout() on LayoutChan() after each frame

9. Current Implementation Status (as of June 2026)

This section documents what has been implemented beyond the original plan, and what gaps remain.

9.1 Implemented Components

Component Status Notes
EditorState struct Done Buffer, CursorPosition, GlyphLayout, fileVersion, lastWriteVersion, Filename, saveTimer
GlyphLayout capture Done Full per-glyph capture in drawWrappedText (ByteOffsets, X, Y, Advance)
layoutChan feedback Done Main loop sends GlyphLayout after each frame; logic derives LastLineY from it
HandleInsert Done String concatenation at cursor position, advances cursor, calls markDirty()
HandleBackspace Done Removes byte before cursor, decrements cursor, calls markDirty()
HandleDelete Done Removes byte after cursor, calls markDirty()
HandleCursorMove Done Byte-based ±1 movement, bounded by buffer length
HandleVerticalCursorMove Done GlyphLayout-based: binary search for current Y, scan for target Y, find closest X
SetCursorFromPoint Done GlyphLayout-based tap-to-position: groups glyphs by Y, finds closest X on target line
HandleKeyDown Done Dispatches key.Name (arrows, delete, return) and key.EditEvent (text input)
EditorLayout Done Full layout: status bar, text field, bottom bar
Cursor rendering Done Inline in Renderer.drawWrappedText — 2dp vertical bar positioned from GlyphLayout
File open flow Done OpenFileReadFileTask → worker pool → handleWorkerResultEditor.Buffer
Soft-wrap toggle Done ToggleWordWrap wired to bottom bar label
Page navigation Done Browser ↔ Editor page switching via GoToBrowser/GoToEditor
Keyboard focus Done FocusedElementID + key.FocusCmd + key.Filter/key.FocusFilter routing
Scroll handling Done HandleScroll clamped to [0, MaxScroll] derived from LastLineY
Mock filesystem Done populateMockFileSystem with realistic directory structure
Worker pool Done 4-goroutine pool with ReadFileTask, BuildIndexTask, WriteFileTask, etc.
Per-file dirty tracking Done fileVersion + lastWriteVersion maps, computed IsDirty()
Auto-save debounce Done 1s debounce with generation counter, DispatchNonBlocking
Atomic write (mock) Done WriteFileAtomic — temp file + mutex-protected rename
Stale result filtering Done Path check + version tracking in handleWorkerResult
WriteFileTask atomic Done Execute() calls WriteFileAtomic, Result carries FilePath
Generation counter Done saveGeneration prevents stale timer callbacks from dispatching
OpenFile filename Done Tracks Filename, cancels pending timer on file switch
Dynamic filename in UI Done EditorLayout uses TheState.Editor.Filename
markDirty() / markSaved() Done Helper methods on EditorState
IsDirty() computed Done fileVersion > lastWriteVersion
Result.FilePath field Done Added to Result struct for stale result filtering
WriteFileAtomic in mock Done Top-level .tmp/ directory, atomic rename, no partial writes

9.2 Test Coverage

Test File Coverage
buffer_test.go TestInsertChar — verifies insert at end of buffer
buffer_test.go TestBackspace — verifies backspace at end of buffer
cursor_test.go TestCursorPositioning_Basic — verifies GlyphLayout-based tap-to-position
cursor_test.go TestCursorPositioning_EmptyDocument — verifies empty document handling
cursor_test.go TestCursorPositioning_IncompleteLayout — verifies graceful handling of partial layout
cursor_test.go TestHandleCursorMove_Bounds — verifies cursor clamping at buffer start/end
integration_test.go TestOpenFileIntegration — verifies full file open flow through worker pool
auto_save_test.go TestAutoSave_Fires — edit → debounce → WriteFileTask dispatched
auto_save_test.go TestAutoSave_DebounceCoalesces — rapid edits → single WriteFileTask
auto_save_test.go TestAutoSave_GenerationFiltering — old timer callbacks drop
auto_save_test.go TestAutoSave_FileSwitchCancelsTimer — switch file → old not saved
auto_save_test.go TestAutoSave_StaleResultIgnored — result for different file ignored
auto_save_test.go TestAutoSave_FailureLeavesDirty — write fails → dirty stays true
auto_save_test.go TestAutoSave_SuccessClearsDirty — write succeeds → dirty becomes false
auto_save_test.go TestAutoSave_PersistsAcrossSwitch — edit A → switch B → switch A → A still dirty
auto_save_test.go TestAutoSave_AtomicWrite — temp file + rename, no partial writes visible

9.3 Known Gaps

Gap Description
Cursor blink CursorVisible field exists but no blink timer is driven
Home/End/PageUp/PageDown No handlers for these keys
Cut/Copy/Paste Icon elements exist with nil handlers
Text selection SelectionStart/SelectionEnd fields present but unused
Undo/Redo Commented out in EditorState, no implementation
Status bar dynamics Filename, line/col, byte count are static strings
Dirty flag UI Dirty is computed but not displayed in status bar
Real filesystem backend Mock WriteFileAtomic uses in-memory .tmp/; production needs .pad/tmp/ temp+rename
File close save No explicit save-on-file-close; last edits rely on debounce timer

9.4 File-by-File Change Summary for Auto-Save

File Changes
internal/editor/state.go Added Filename, fileVersion, lastWriteVersion, saveTimer; removed Dirty boolean; added IsDirty(), markDirty(), markSaved(); updated OpenFile to cancel timer and track filename
internal/editor/logic.go Added saveGeneration; added debounce logic in inputChan handler; added TypeWriteFile handling in handleWorkerResult
internal/io/pool/task.go WriteFileTask.Execute() calls WriteFileAtomic; Result carries FilePath
internal/io/pool/result.go Added FilePath field to Result
internal/io/pool/mock/filesystem.go Added WriteFileAtomic — temp file + mutex-protected rename
internal/editor/auto_save_test.go New test file: 9 tests covering debounce, generation filtering, stale results, atomic write, failure retry