Pad/doc/editor_implementation_plan.md

17 KiB
Raw Blame History

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        // Document content (plain string for now)
    CursorPosition int           // Byte offset
    SelectionStart int           // -1 if no selection (field exists, unused)
    SelectionEnd   int           // -1 if no selection (field exists, unused)
    Dirty          bool          // Needs save
    CursorVisible  bool          // Blink state (field exists, no timer yet)
    // UndoStack []EditCommand // Planned for Phase 4
}

Current state (as of June 2026): EditorState is defined in internal/editor/state.go with the fields above. UndoStack is deferred. SelectionStart/SelectionEnd are present but not yet wired into any UI or input handler.

3. Implementation Phases

Phase 1: Basic Buffer Management & Cursor

  • Define EditorState (Cursor, Selection, Dirty flag). — DONE (internal/editor/state.go)
  • Implement GlyphLayout capture in drawWrappedText and feedback via layoutChan. — NOT STARTED (see §8)
  • Implement cursor navigation (Arrow keys: Left, Right). — DONE (byte-based via HandleCursorMove; Up/Down not yet implemented)
  • Implement basic buffer updates (Insert character, Delete/Backspace). — DONE (HandleInsert, HandleBackspace, HandleDelete)
  • Implement cursor display. — DONE (Cursor element in internal/ui/element.go, rendered in EditorLayout)
  • Implement cursor blink animation. — NOT STARTED (CursorVisible field exists but no blink timer)
  • Update EditorLayout to position cursor from GlyphLayout (no fixed-width approximation). — NOT STARTED (currently uses byteOffsetToLineCol with EditorFontSize * 0.6 fixed-width approximation)

Phase 2: File IO Integration

  • Implement SaveFile handler: Dispatch WriteFileTask to worker pool. — NOT STARTED
  • Implement LoadFile handler: Dispatch ReadFileTask to worker pool. — DONE (openFileChan in logic.go dispatches ReadFileTask; handleWorkerResult loads content into Editor.Buffer)
  • Status bar integration: Show "Saving..." indicator, "Modified" status. — NOT STARTED (status bar exists with static labels; no dynamic save/modified state)

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

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

Current state (as of June 2026): drawWrappedText iterates every glyph via r.shp.NextGlyph() but does not capture per-glyph layout data. It tracks only lastLineY (the baseline Y of the last drawn glyph) and displayLineCount. These are exposed via Renderer.LastLineY() and Renderer.DisplayLineCount().

Planned change: drawWrappedText will be extended to capture layout data during the glyph iteration loop and return a GlyphLayout:

func (r *Renderer) drawWrappedText(...) GlyphLayout {
    // ... existing shaping setup ...
    r.shp.LayoutString(params, str)

    var layout GlyphLayout
    byteOffset := 0
    for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() {
        glyphX := Dp(float32(g.Xdot >> 6)) - scrollOffset
        glyphY := Dp(float32(g.Ydot))

        layout.ByteOffsets = append(layout.ByteOffsets, byteOffset)
        layout.X = append(layout.X, glyphX)
        layout.Y = append(layout.Y, glyphY)
        layout.Advance = append(layout.Advance, Dp(float32(g.Advance >> 6)))

        byteOffset += utf8.RuneLen(runeAtThisGlyph)
        // ... existing draw logic ...
    }
    r.lastLineY = layout.Y[len(layout.Y)-1] // derived, not separate
    return layout
}

TextField.Draw will store the returned layout on the renderer for the main loop to pick up.

8.5 Feedback Path

Current state (as of June 2026): The main loop sends lastLineY (as int) on DisplayLineChan() after each frame. The logic goroutine stores it in State.LastLineY. This is the only layout feedback path currently in use.

// Current: main.go
lastLineY := int(renderer.LastLineY())
logic.DisplayLineChan() <- lastLineY

// Current: logic.go
case y := <-l.lastLineYChan:
    if ui.Dp(y) != l.state.LastLineY {
        l.state.LastLineY = ui.Dp(y)
        l.frameChan <- l.state.layout(l.browserManager)
    }

Planned change: A new layoutChan will carry the full GlyphLayout from renderer to logic, replacing lastLineYChan. LastLineY will be derived from GlyphLayout.Y[len-1].

layoutChan chan GlyphLayout  // in Logic struct (replaces lastLineYChan)

8.6 Editor State Consumption

Current state (as of June 2026): EditorState does not yet contain a GlyphLayout field. Cursor positioning uses a fixed-width approximation:

// Current: state.go — EditorLayout()
line, col := byteOffsetToLineCol(TheState.Editor.Buffer, TheState.Editor.CursorPosition)
charWidth := ui.Dp(float32(EditorFontSize) * 0.6)
lineHeight := EditorLineHeight()
cursorX := editorRegion.X + ui.Dp(col)*charWidth
cursorY := editorRegion.Y + ui.Dp(line)*lineHeight - TheState.ScrollOffset

Cursor movement (HandleCursorMove) is byte-based (±1 per arrow press), not glyph-based:

// Current: state.go
func HandleCursorMove(delta int) {
    newPos := TheState.Editor.CursorPosition + delta
    if newPos < 0 { newPos = 0 }
    if newPos > len(TheState.Editor.Buffer) { newPos = len(TheState.Editor.Buffer) }
    TheState.Editor.CursorPosition = newPos
}

This works for monospace-like navigation but is inaccurate for proportional fonts and does not respect visual (wrapped) line boundaries.

Planned change: EditorState gains the layout field and cursor logic switches to GlyphLayout-based positioning:

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

Cursor rendering in EditorLayout (planned):

func EditorLayout(...) []ui.Element {
    layout := TheState.Editor.GlyphLayout
    pos := TheState.Editor.CursorPosition

    idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
        return layout.ByteOffsets[i] >= pos
    })

    var cursorX, cursorY Dp
    if idx < len(layout.ByteOffsets) {
        cursorX = editorRegion.X + layout.X[idx]
        cursorY = editorRegion.Y + layout.Y[idx]
    } else {
        // cursor at end of buffer — place after last glyph
        cursorX = editorRegion.X + layout.X[len(layout.X)-1] + layout.Advance[len(layout.Advance)-1]
        cursorY = editorRegion.Y + layout.Y[len(layout.Y)-1]
    }
    // ... create cursor element ...
}

Cursor movement (HandleCursorMove, planned):

func HandleCursorMove(delta int) {
    layout := TheState.Editor.GlyphLayout
    pos := TheState.Editor.CursorPosition

    idx := findGlyphIndex(layout, pos) // binary search ByteOffsets

    if delta > 0 {
        idx++ // next glyph
    } else {
        idx-- // previous glyph
    }

    if idx >= 0 && idx < len(layout.ByteOffsets) {
        TheState.Editor.CursorPosition = layout.ByteOffsets[idx]
    }
}

Arrow-up / arrow-down (future): find current glyph's Y, search for the glyph with the closest X on the target Y line.

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 Current State (June 2026) Planned Change
internal/ui/unit.go No GlyphLayout type defined Add GlyphLayout struct
internal/ui/render.go drawWrappedText tracks lastLineY and displayLineCount only Return GlyphLayout; derive lastLineY from it; expose via r.GlyphLayout()
internal/ui/element.go TextField.Draw calls drawWrappedText but ignores any layout return value Store returned GlyphLayout on renderer
internal/editor/logic.go Uses lastLineYChan for feedback Add layoutChan; handle it in Run() loop; remove lastLineYChan
internal/editor/state.go EditorLayout uses fixed-width approximation (EditorFontSize * 0.6) and byteOffsetToLineCol; HandleCursorMove is byte-based (±1) EditorState.GlyphLayout field; EditorLayout uses it for cursor positioning; HandleCursorMove uses it for navigation
cmd/pad/main.go Sends int(renderer.LastLineY()) on DisplayLineChan() after each frame Send r.GlyphLayout() on layoutChan after draw; remove lastLineYChan usage

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, SelectionStart/End (unused), Dirty, CursorVisible
HandleInsert Done String concatenation at cursor position, advances cursor, sets Dirty
HandleBackspace Done Removes byte before cursor, decrements cursor, sets Dirty
HandleDelete Done Removes byte after cursor, sets Dirty
HandleCursorMove Done (basic) Byte-based ±1 movement, bounded by buffer length
HandleKeyDown Done Dispatches key.Name (arrows, delete) and key.EditEvent (text input)
EditorLayout Done Full layout: status bar, text field, cursor element, bottom bar
Cursor element Done Renders as 2dp × 18dp vertical bar at computed position
byteOffsetToLineCol Done Converts byte offset to (line, col) for cursor positioning
File open flow Done OpenFileopenFileChanReadFileTask → 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, etc.

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 TestByteOffsetToLineCol_Logical — verifies logical line/column conversion
integration_test.go TestOpenFileIntegration — verifies full file open flow through worker pool

9.3 Known Gaps

Gap Description
GlyphLayout capture Not yet implemented — cursor uses fixed-width approximation
Cursor blink CursorVisible field exists but no blink timer is driven
Up/Down arrow HandleKeyDown does not handle NameUpArrow/NameDownArrow
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
Save file No SaveFile handler or WriteFileTask integration
Undo/Redo Commented out in EditorState, no implementation
Status bar dynamics Filename, line/col, byte count are static strings
Dirty flag UI Dirty is set on edits but not displayed in status bar