# 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: ```go 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 - [x] Define `EditorState` (Cursor, Selection, Dirty flag). — **DONE** - [x] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **DONE** - [x] Implement cursor navigation (Arrow keys: Left, Right, Up, Down). — **DONE** (Left/Right: byte-based ±1; Up/Down: GlyphLayout-based) - [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE** - [x] 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) - [x] Tap-to-position cursor from GlyphLayout. — **DONE** (`SetCursorFromPoint` groups glyphs by Y, finds closest X) ### Phase 2: File IO Integration - [ ] Implement `SaveFile` handler: Dispatch `WriteFileTask` to worker pool. — **NOT STARTED** - [x] Implement `LoadFile` handler: Dispatch `ReadFileTask` to worker pool. — **DONE** - [ ] Status bar integration: Show "Saving..." indicator, "Modified" status. — **NOT STARTED** (currently status bar shows static labels + byte-based approximation for line/col) ### 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) - [x] 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 ```go 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: ```go 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]`. ```go // 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. ```go 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`, `SelectionStart/End` (unused), `Dirty`, `CursorVisible` | | `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, sets `Dirty` | | `HandleBackspace` | **Done** | Removes byte before cursor, decrements cursor, sets `Dirty` | | `HandleDelete` | **Done** | Removes byte after cursor, sets `Dirty` | | `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** | `OpenFile` → `openFileChan` → `ReadFileTask` → worker pool → `handleWorkerResult` → `Editor.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` | `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 | ### 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 | | 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 |