Pad/doc/editor_implementation_plan.md

338 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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** (`internal/editor/state.go`)
- [ ] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **NOT STARTED** (see §8)
- [x] Implement cursor navigation (Arrow keys: Left, Right). — **DONE** (byte-based via `HandleCursorMove`; Up/Down not yet implemented)
- [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE** (`HandleInsert`, `HandleBackspace`, `HandleDelete`)
- [x] 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**
- [x] 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)
- [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
**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`:
```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` calls `drawWrappedText`, which captures the layout as a side effect and stores it on the renderer (`r.glyphLayout`). No code change is needed in `element.go` the storage happens entirely inside `drawWrappedText` in `render.go`.
### 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.
```go
// 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]`.
```go
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:
```go
// 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:
```go
// 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:
```go
type EditorState struct {
Buffer string
CursorPosition int
GlyphLayout GlyphLayout
// ...
}
```
**Cursor rendering** in `EditorLayout` (planned):
```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`, planned):
```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 | 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` | **No change** layout capture and storage happens entirely inside `drawWrappedText` in `render.go` |
| `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** | `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` | `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 |