338 lines
17 KiB
Markdown
338 lines
17 KiB
Markdown
# 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` 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.
|
||
|
||
```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` 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** | `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 |
|