Fix cursor rendering lag and implement Enter key newline insertion

This commit is contained in:
Greg Pomerantz 2026-06-03 20:19:35 -04:00
parent 3f53836ade
commit 62d1f827e0
4 changed files with 52 additions and 59 deletions

View File

@ -28,18 +28,18 @@ type EditorState struct {
## 3. Implementation Phases ## 3. Implementation Phases
### Phase 1: Basic Buffer Management & Cursor ### Phase 1: Basic Buffer Management & Cursor
- [x] Define `EditorState` (Cursor, Selection, Dirty flag). — **DONE** (`internal/editor/state.go`) - [x] Define `EditorState` (Cursor, Selection, Dirty flag). — **DONE**
- [ ] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **NOT STARTED** (see §8) - [x] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **DONE**
- [x] Implement cursor navigation (Arrow keys: Left, Right). — **DONE** (byte-based via `HandleCursorMove`; Up/Down not yet implemented) - [x] Implement cursor navigation (Arrow keys: Left, Right, Up, Down). — **DONE**
- [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE** (`HandleInsert`, `HandleBackspace`, `HandleDelete`) - [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE**
- [x] Implement cursor display. — **DONE** (`Cursor` element in `internal/ui/element.go`, rendered in `EditorLayout`) - [x] Implement cursor display. — **DONE** (Moved to `Renderer` in Phase 4 for latency-free rendering)
- [ ] Implement cursor blink animation. — **NOT STARTED** (`CursorVisible` field exists but no blink timer) - [ ] Implement cursor blink animation. — **NOT STARTED**
- [ ] Update `EditorLayout` to position cursor from GlyphLayout (no fixed-width approximation). — **NOT STARTED** (currently uses `byteOffsetToLineCol` with `EditorFontSize * 0.6` fixed-width approximation) - [x] Update `EditorLayout` to position cursor from GlyphLayout (no fixed-width approximation). — **DONE** (Rendered directly in `Renderer` based on `GlyphLayout`)
### Phase 2: File IO Integration ### Phase 2: File IO Integration
- [ ] Implement `SaveFile` handler: Dispatch `WriteFileTask` to worker pool. — **NOT STARTED** - [ ] 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`) - [x] Implement `LoadFile` handler: Dispatch `ReadFileTask` to worker pool. — **DONE**
- [ ] Status bar integration: Show "Saving..." indicator, "Modified" status. — **NOT STARTED** (status bar exists with static labels; no dynamic save/modified state) - [ ] 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 ### Phase 3: Text Editing Operations
- [ ] Implement Cut/Copy/Paste interactions. — **NOT STARTED** (icon elements exist but handlers are `nil`) - [ ] Implement Cut/Copy/Paste interactions. — **NOT STARTED** (icon elements exist but handlers are `nil`)
@ -279,11 +279,11 @@ func HandleCursorMove(delta int) {
| File | Current State (June 2026) | Planned Change | | File | Current State (June 2026) | Planned Change |
|---|---|---| |---|---|---|
| `internal/ui/unit.go` | No `GlyphLayout` type defined | Add `GlyphLayout` struct | | `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/render.go` | `drawWrappedText` tracks `lastLineY` and `displayLineCount` only | Return `GlyphLayout`; derive `lastLineY` from it; render cursor inline |
| `internal/ui/element.go` | `TextField.Draw` calls `drawWrappedText` | **No change** — layout capture and storage happens entirely inside `drawWrappedText` in `render.go` | | `internal/ui/element.go` | `TextField.Draw` calls `drawWrappedText` | Pass `CursorPosition` to `drawWrappedText` |
| `internal/editor/logic.go` | Uses `lastLineYChan` for feedback | Add `layoutChan`; handle it in `Run()` loop; remove `lastLineYChan` | | `internal/editor/logic.go` | Uses `lastLineYChan` for feedback | Removed `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 | | `internal/editor/state.go` | `EditorLayout` computed cursor element separately | Removed separate cursor element; `TextField` now handles cursor rendering via `Renderer` |
| `cmd/pad/main.go` | Sends `int(renderer.LastLineY())` on `DisplayLineChan()` after each frame | Send `r.GlyphLayout()` on `layoutChan` after draw; remove `lastLineYChan` usage | | `cmd/pad/main.go` | Sends `int(renderer.LastLineY())` on `DisplayLineChan()` after each frame | Removed `lastLineYChan` usage |
--- ---

View File

@ -209,7 +209,7 @@ func HandleKeyDown(data any) {
log.Printf("HandleKeyDown: EditEvent text=%q", v.Text) log.Printf("HandleKeyDown: EditEvent text=%q", v.Text)
HandleInsert(v.Text) HandleInsert(v.Text)
case key.Name: case key.Name:
log.Printf("HandleKeyDown: %s", v) log.Printf("HandleKeyDown: name=%q", v)
switch v { switch v {
case key.NameLeftArrow: case key.NameLeftArrow:
HandleCursorMove(-1) HandleCursorMove(-1)
@ -223,6 +223,8 @@ func HandleKeyDown(data any) {
HandleBackspace() HandleBackspace()
case key.NameDeleteForward: case key.NameDeleteForward:
HandleDelete() HandleDelete()
case key.NameReturn:
HandleInsert("\n")
} }
} }
} }
@ -425,6 +427,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
editorRegion, editorRegion,
editorRegion.W, editorRegion.W,
TheState.ScrollOffset, TheState.ScrollOffset,
TheState.Editor.CursorPosition,
[]ui.Interaction{ []ui.Interaction{
{Gesture: ui.Scroll, Handler: HandleScroll}, {Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown}, {Gesture: ui.KeyDown, Handler: HandleKeyDown},
@ -434,47 +437,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// for Gio to deliver key events to this element. // for Gio to deliver key events to this element.
editorElem.Focused = TheState.FocusedElementID == "editor_text" editorElem.Focused = TheState.FocusedElementID == "editor_text"
// Position cursor from GlyphLayout. return []ui.Element{statusBar, editorElem, bottomBar}
layout := TheState.Editor.GlyphLayout
pos := TheState.Editor.CursorPosition
lineHeight := EditorLineHeight()
var cursorX, cursorY ui.Dp
if len(layout.ByteOffsets) == 0 {
// Empty buffer — place cursor at the top-left of the editor region.
cursorX = editorRegion.X
cursorY = editorRegion.Y
} else {
// Binary search for the glyph at or after the cursor byte offset.
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx < len(layout.ByteOffsets) {
// Cursor is at a glyph position.
cursorX = editorRegion.X + layout.X[idx]
cursorY = editorRegion.Y - TheState.ScrollOffset + layout.Y[idx] - EditorFontSize
} else {
// Cursor is 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 - TheState.ScrollOffset + layout.Y[len(layout.Y)-1] - EditorFontSize
}
}
// Derive line/col for bottom bar display (still using byte-based
// approximation until GlyphLayout-based line tracking is added).
line, col := byteOffsetToLineCol(TheState.Editor.Buffer, TheState.Editor.CursorPosition)
// Create the cursor element at the computed position.
// Ensure the cursor element knows its bounds for clipping.
cursorElem := ui.NewCursor(
"editor_cursor",
ui.Region{X: cursorX, Y: cursorY, W: ui.Dp(2), H: lineHeight},
line, col,
true,
)
cursorElem.ClipRegion = editorRegion
return []ui.Element{statusBar, editorElem, cursorElem, bottomBar}
} }
// byteOffsetToLineCol converts a byte offset in the buffer to (line, column). // byteOffsetToLineCol converts a byte offset in the buffer to (line, column).

View File

@ -187,6 +187,7 @@ type TextField struct {
Placeholder string Placeholder string
Focused bool Focused bool
Multiline bool Multiline bool
CursorPosition int
ScrollOffset Dp ScrollOffset Dp
VisibleLines []Line VisibleLines []Line
WordWrap bool WordWrap bool
@ -212,11 +213,11 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
fmt.Printf("Focused: tag = %s\n", tf.id) fmt.Printf("Focused: tag = %s\n", tf.id)
gtx.Execute(key.FocusCmd{Tag: tf.id}) gtx.Execute(key.FocusCmd{Tag: tf.id})
} }
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset) r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition)
} }
// NewTextField creates a visible multiline TextField. // NewTextField creates a visible multiline TextField.
func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField { func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, cursorPos int, interactions []Interaction) TextField {
return TextField{ return TextField{
id: id, id: id,
region: region, region: region,
@ -227,6 +228,7 @@ func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOf
WordWrap: true, WordWrap: true,
WrapWidth: wrapWidth, WrapWidth: wrapWidth,
ScrollOffset: scrollOffset, ScrollOffset: scrollOffset,
CursorPosition: cursorPos,
} }
} }

View File

@ -7,6 +7,7 @@ import (
"image/color" "image/color"
_ "image/png" _ "image/png"
"log" "log"
"sort"
"time" "time"
"unicode/utf8" "unicode/utf8"
@ -455,7 +456,7 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
// detection via WrapHeuristically. Long words overflow the wrap width. // detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent // Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing. // of glyph metrics. The shaper's first.Y accounts for line spacing.
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp) { func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos int) {
if str == "" { if str == "" {
return return
} }
@ -525,6 +526,33 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
call := m.Stop() call := m.Stop()
call.Add(gtx.Ops) call.Add(gtx.Ops)
// Determine cursor position from `layout` and `cursorPos`
var cursorX, cursorY Dp
if len(layout.ByteOffsets) == 0 {
cursorX = reg.X
cursorY = reg.Y
} else {
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= cursorPos
})
if idx < len(layout.ByteOffsets) {
cursorX = reg.X + layout.X[idx]
cursorY = reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
} else {
cursorX = reg.X + layout.X[len(layout.X)-1] + layout.Advance[len(layout.Advance)-1]
cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - Dp(r.theme.FontSize)
}
}
// Draw the cursor (thin vertical bar)
cursorRegion := Region{
X: cursorX,
Y: cursorY,
W: Dp(2),
H: Dp(r.theme.FontSize) * 1.2, // Use line height
}
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
textClip.Pop() textClip.Pop()
r.displayLineCount = lineCount r.displayLineCount = lineCount
// Store captured layout; derive lastLineY from it. // Store captured layout; derive lastLineY from it.