Fix cursor rendering lag and implement Enter key newline insertion
This commit is contained in:
parent
3f53836ade
commit
62d1f827e0
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user