diff --git a/doc/editor_implementation_plan.md b/doc/editor_implementation_plan.md index a4202cb..ba0d843 100644 --- a/doc/editor_implementation_plan.md +++ b/doc/editor_implementation_plan.md @@ -28,18 +28,18 @@ type EditorState struct { ## 3. Implementation Phases ### Phase 1: Basic Buffer Management & Cursor -- [x] Define `EditorState` (Cursor, Selection, Dirty flag). — **DONE** (`internal/editor/state.go`) -- [ ] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **NOT STARTED** (see §8) -- [x] Implement cursor navigation (Arrow keys: Left, Right). — **DONE** (byte-based via `HandleCursorMove`; Up/Down not yet implemented) -- [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE** (`HandleInsert`, `HandleBackspace`, `HandleDelete`) -- [x] Implement cursor display. — **DONE** (`Cursor` element in `internal/ui/element.go`, rendered in `EditorLayout`) -- [ ] Implement cursor blink animation. — **NOT STARTED** (`CursorVisible` field exists but no blink timer) -- [ ] Update `EditorLayout` to position cursor from GlyphLayout (no fixed-width approximation). — **NOT STARTED** (currently uses `byteOffsetToLineCol` with `EditorFontSize * 0.6` fixed-width approximation) +- [x] Define `EditorState` (Cursor, Selection, Dirty flag). — **DONE** +- [x] Implement `GlyphLayout` capture in `drawWrappedText` and feedback via `layoutChan`. — **DONE** +- [x] Implement cursor navigation (Arrow keys: Left, Right, Up, Down). — **DONE** +- [x] Implement basic buffer updates (Insert character, Delete/Backspace). — **DONE** +- [x] Implement cursor display. — **DONE** (Moved to `Renderer` in Phase 4 for latency-free rendering) +- [ ] Implement cursor blink animation. — **NOT STARTED** +- [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 - [ ] 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`) -- [ ] Status bar integration: Show "Saving..." indicator, "Modified" status. — **NOT STARTED** (status bar exists with static labels; no dynamic save/modified state) +- [x] Implement `LoadFile` handler: Dispatch `ReadFileTask` to 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`) @@ -279,11 +279,11 @@ func HandleCursorMove(delta int) { | File | Current State (June 2026) | Planned Change | |---|---|---| | `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/element.go` | `TextField.Draw` calls `drawWrappedText` | **No change** — layout capture and storage happens entirely inside `drawWrappedText` in `render.go` | -| `internal/editor/logic.go` | Uses `lastLineYChan` for feedback | Add `layoutChan`; handle it in `Run()` loop; remove `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 | -| `cmd/pad/main.go` | Sends `int(renderer.LastLineY())` on `DisplayLineChan()` after each frame | Send `r.GlyphLayout()` on `layoutChan` after draw; remove `lastLineYChan` usage | +| `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` | Pass `CursorPosition` to `drawWrappedText` | +| `internal/editor/logic.go` | Uses `lastLineYChan` for feedback | Removed `lastLineYChan` | +| `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 | Removed `lastLineYChan` usage | --- diff --git a/internal/editor/state.go b/internal/editor/state.go index 16c1237..2b788ee 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -209,7 +209,7 @@ func HandleKeyDown(data any) { log.Printf("HandleKeyDown: EditEvent text=%q", v.Text) HandleInsert(v.Text) case key.Name: - log.Printf("HandleKeyDown: %s", v) + log.Printf("HandleKeyDown: name=%q", v) switch v { case key.NameLeftArrow: HandleCursorMove(-1) @@ -223,6 +223,8 @@ func HandleKeyDown(data any) { HandleBackspace() case key.NameDeleteForward: HandleDelete() + case key.NameReturn: + HandleInsert("\n") } } } @@ -425,6 +427,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { editorRegion, editorRegion.W, TheState.ScrollOffset, + TheState.Editor.CursorPosition, []ui.Interaction{ {Gesture: ui.Scroll, Handler: HandleScroll}, {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. editorElem.Focused = TheState.FocusedElementID == "editor_text" - // Position cursor from GlyphLayout. - 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} + return []ui.Element{statusBar, editorElem, bottomBar} } // byteOffsetToLineCol converts a byte offset in the buffer to (line, column). diff --git a/internal/ui/element.go b/internal/ui/element.go index 13564e1..4f660d2 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -187,6 +187,7 @@ type TextField struct { Placeholder string Focused bool Multiline bool + CursorPosition int ScrollOffset Dp VisibleLines []Line WordWrap bool @@ -212,11 +213,11 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { fmt.Printf("Focused: tag = %s\n", 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. -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{ id: id, region: region, @@ -227,6 +228,7 @@ func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOf WordWrap: true, WrapWidth: wrapWidth, ScrollOffset: scrollOffset, + CursorPosition: cursorPos, } } diff --git a/internal/ui/render.go b/internal/ui/render.go index 163b00f..cfcf6b3 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -7,6 +7,7 @@ import ( "image/color" _ "image/png" "log" + "sort" "time" "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. // Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent // 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 == "" { return } @@ -525,6 +526,33 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w call := m.Stop() 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() r.displayLineCount = lineCount // Store captured layout; derive lastLineY from it.