diff --git a/doc/editor_implementation_plan.md b/doc/editor_implementation_plan.md index aaadcc3..c1d598a 100644 --- a/doc/editor_implementation_plan.md +++ b/doc/editor_implementation_plan.md @@ -25,11 +25,12 @@ type EditorState struct { ## 3. Implementation Phases ### Phase 1: Basic Buffer Management & Cursor -- [ ] Define `EditorState` (Cursor, Selection, Dirty flag). -- [ ] Implement cursor navigation (Arrow keys: Left, Right, Up, Down). +- [ ] Define `EditorState` (Cursor, Selection, Dirty flag, GlyphLayout). +- [ ] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. +- [ ] Implement cursor navigation (Arrow keys: Left, Right, Up, Down) using GlyphLayout. - [ ] Implement basic buffer updates (Insert character, Delete/Backspace). - [ ] Implement cursor display and blink animation. -- [ ] Update `EditorLayout` to display cursor and selection highlight. +- [ ] Update `EditorLayout` to position cursor from GlyphLayout (no fixed-width approximation). ### Phase 2: File IO Integration - [ ] Implement `SaveFile` handler: Dispatch `WriteFileTask` to worker pool. @@ -83,3 +84,159 @@ type EditorState struct { - **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 + +`Renderer.drawWrappedText` already iterates every glyph via `r.shp.NextGlyph()`. It captures layout data during that loop and returns it: + +```go +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` stores the returned layout on the renderer for the main loop to pick up. + +### 8.5 Feedback Path + +A new channel carries the full layout from renderer to logic: + +```go +layoutChan chan GlyphLayout // in Logic struct +``` + +Main loop sends `r.GlyphLayout()` after each draw frame. Logic stores it in `State.Editor.GlyphLayout`. The old `lastLineYChan` is removed — `LastLineY` is derived from `GlyphLayout.Y[len-1]`. + +### 8.6 Editor State Consumption + +`EditorState` gains the layout field: + +```go +type EditorState struct { + Buffer string + CursorPosition int + GlyphLayout GlyphLayout + // ... +} +``` + +**Cursor rendering** in `EditorLayout`: +```go +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`): +```go +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 | Change | +|---|---| +| `internal/ui/unit.go` | Add `GlyphLayout` struct | +| `internal/ui/render.go` | `drawWrappedText` returns `GlyphLayout`; derive `lastLineY` from it; expose via `r.GlyphLayout()` | +| `internal/ui/element.go` | `TextField.Draw` stores returned layout on renderer | +| `internal/editor/logic.go` | Add `layoutChan`; handle it in `Run()` loop; remove `lastLineYChan` | +| `internal/editor/state.go` | `EditorState.GlyphLayout` field; `EditorLayout` uses it for cursor positioning; `HandleCursorMove` uses it for navigation | +| `cmd/pad/main.go` | Main loop sends `r.GlyphLayout()` on `layoutChan` after draw; remove `lastLineYChan` usage |