15 KiB
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
inputChanto 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 - Implement
GlyphLayoutcapture indrawWrappedTextand feedback vialayoutChan. — DONE - Implement cursor navigation (Arrow keys: Left, Right, Up, Down). — DONE (Left/Right: byte-based ±1; Up/Down: GlyphLayout-based)
- Implement basic buffer updates (Insert character, Delete/Backspace). — DONE
- Implement cursor display. — DONE (Rendered inline in
Renderer.drawWrappedTextusingGlyphLayout) - Implement cursor blink animation. — NOT STARTED (
CursorVisiblefield exists but no timer drives it) - Tap-to-position cursor from GlyphLayout. — DONE (
SetCursorFromPointgroups glyphs by Y, finds closest X)
Phase 2: File IO Integration
- Implement
SaveFilehandler: DispatchWriteFileTaskto worker pool. — NOT STARTED - Implement
LoadFilehandler: DispatchReadFileTaskto 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/SelectionEndfields exist inEditorStatebut are unused) - Implement soft-wrap toggle. — DONE (
ToggleWordWrapinstate.go, wired to bottom bar "Wrap" label tap)
Phase 4: Polish & Advanced Features
- Undo/Redo stack implementation. — NOT STARTED (
UndoStackfield is commented out inEditorState) - Performance testing with large files (>1MB). — NOT STARTED
4. Interaction Flow (Example: Keyboard/Mouse Input)
- User performs an action (types a character, taps an icon).
- UI (Renderer) registers the element's interaction (
event.Opfor keys,gesture.Addfor mouse) within its clip context. - Main Loop captures the event (using
key.Filterfor keys,gesture.Updatefor mouse). - Main Loop generates an
InputEventand sends it toinputChan. - Logic Goroutine receives the event, triggers the corresponding
HandlerinTheState. - Logic Goroutine modifies state, and sends updated elements back through
frameChan. - 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.Clickandgesture.Scroll. - Registered via
event.Opand gestureAdd()within the clip context of the element. - Processed by
Renderer.CheckGesturesand dispatched viaInputEventto 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) andkey.EditEvent(for text input) are converted intoInputEventstructs and sent to the logic goroutine viainputChanfor 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 separatelastLineYfeedback- Visual line breaks = any index
iwhereY[i] > Y[i-1] - Cursor at byte offset
b→ binary searchByteOffsetsfor 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:
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].
// 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.
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 |