- Add Section 8 describing GlyphLayout data structure and feedback path - Renderer captures byte offsets, X/Y positions, and advance widths during shaping - Layout flows via layoutChan to logic goroutine, replacing lastLineYChan - Editor uses layout for accurate cursor rendering and navigation - Handles word wrap, screen resize, editing, scroll, and non-fixed-width fonts
10 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 will be extended to track active editing session data:
type EditorState struct {
Buffer string // Document content (or gap buffer)
CursorPosition int // Byte offset
SelectionStart int // -1 if no selection
SelectionEnd int // -1 if no selection
UndoStack []EditCommand // For undo/redo
Dirty bool // Needs save
}
3. Implementation Phases
Phase 1: Basic Buffer Management & Cursor
- Define
EditorState(Cursor, Selection, Dirty flag, GlyphLayout). - Implement
GlyphLayoutcapture indrawWrappedTextand feedback vialayoutChan. - 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
EditorLayoutto position cursor from GlyphLayout (no fixed-width approximation).
Phase 2: File IO Integration
- Implement
SaveFilehandler: DispatchWriteFileTaskto worker pool. - Implement
LoadFilehandler: DispatchReadFileTaskto worker pool (existing inlogic.go). - Status bar integration: Show "Saving..." indicator, "Modified" status.
Phase 3: Text Editing Operations
- Implement Cut/Copy/Paste interactions.
- Implement multi-line text navigation (Home/End, PageUp/PageDown).
- Implement text selection display and mouse interaction.
- Implement soft-wrap toggle (already exists, need to verify logic).
Phase 4: Polish & Advanced Features
- Undo/Redo stack implementation.
- Performance testing with large files (>1MB).
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
Renderer.drawWrappedText already iterates every glyph via r.shp.NextGlyph(). It captures layout data during that loop and returns it:
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:
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:
type EditorState struct {
Buffer string
CursorPosition int
GlyphLayout GlyphLayout
// ...
}
Cursor rendering in EditorLayout:
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):
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 |