doc(editor): add GlyphLayout architecture for cursor placement tracking
- 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
This commit is contained in:
parent
bb55b4ab81
commit
1609731ca1
|
|
@ -25,11 +25,12 @@ type EditorState struct {
|
||||||
## 3. Implementation Phases
|
## 3. Implementation Phases
|
||||||
|
|
||||||
### Phase 1: Basic Buffer Management & Cursor
|
### Phase 1: Basic Buffer Management & Cursor
|
||||||
- [ ] Define `EditorState` (Cursor, Selection, Dirty flag).
|
- [ ] Define `EditorState` (Cursor, Selection, Dirty flag, GlyphLayout).
|
||||||
- [ ] Implement cursor navigation (Arrow keys: Left, Right, Up, Down).
|
- [ ] 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 basic buffer updates (Insert character, Delete/Backspace).
|
||||||
- [ ] Implement cursor display and blink animation.
|
- [ ] 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
|
### Phase 2: File IO Integration
|
||||||
- [ ] Implement `SaveFile` handler: Dispatch `WriteFileTask` to worker pool.
|
- [ ] 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.
|
- **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.
|
- **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.
|
- **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 |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user