fix(editor): wire key events to cursor movement

- Replace gtx.Event(nil) with key.Filter{Focus: focusedID} and
  key.FocusFilter{Target: focusedID} so Gio correctly routes key
  and edit events to the focused editor text field.

- Convert EditorState.CursorPosition (byte offset) to line/column
  coordinates for accurate cursor rendering.

- Add debug logging in HandleKeyDown and HandleCursorMove.
This commit is contained in:
Greg Pomerantz 2026-06-03 07:41:18 -04:00
parent a17c3760f2
commit 0dac354f2a
13 changed files with 467 additions and 77 deletions

View File

@ -10,6 +10,7 @@ import (
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/font/gofont" "gioui.org/font/gofont"
"gioui.org/io/key"
"pad/internal/editor" "pad/internal/editor"
"pad/internal/ui" "pad/internal/ui"
@ -71,11 +72,49 @@ func run(w *app.Window) error {
if newQuery != logic.State().Browser.Query { if newQuery != logic.State().Browser.Query {
sendQuery = true sendQuery = true
} }
if events := renderer.CheckGestures(e.Source, gtx.Metric); len(events) > 0 { events := renderer.CheckGestures(e.Source, gtx.Metric)
logic.InputChan() <- events
// Gather key events
focusedID := logic.State().FocusedElementID
log.Printf("focusedID = %s", focusedID)
if focusedID != "" {
if reg, ok := renderer.Keys[focusedID]; ok {
log.Printf("looking for events")
// Use key.Filter to only receive events destined for the focused element.
// We need both Key events (for arrow keys) and Edit events (for text input).
// In Gio, key.Filter covers key presses, while key.FocusFilter covers
// focus and text edit events.
for {
// Filter for key events and focus/edit events targeted at focusedID
evt, ok := gtx.Event(key.Filter{Focus: focusedID}, key.FocusFilter{Target: focusedID})
if !ok {
break
}
log.Printf("found an event: %T", evt)
switch k := evt.(type) {
case key.Event:
log.Printf("key event: %v state=%v", k.Name, k.State)
if k.State == key.Press {
events = append(events, ui.InputEvent{
Handler: reg.Handler,
Data: k.Name,
})
}
case key.EditEvent:
log.Printf("edit event: %v", k.Text)
events = append(events, ui.InputEvent{
Handler: reg.Handler,
Data: k,
})
}
}
}
} }
e.Frame(&ops) e.Frame(&ops)
mu.Unlock() mu.Unlock()
if len(events) > 0 {
logic.InputChan() <- events
}
if sendQuery { if sendQuery {
logic.SearchQueryChan() <- newQuery logic.SearchQueryChan() <- newQuery
} }

Binary file not shown.

View File

@ -4,7 +4,7 @@
This plan specifies the implementation of the directory browser for Pad, covering lazy loading, virtualized rendering, alphabetical indexing, and search functionality. This plan specifies the implementation of the directory browser for Pad, covering lazy loading, virtualized rendering, alphabetical indexing, and search functionality.
### Current State (as of 2026-05-30) ### Current State (as of 2026-06-02)
| Feature | Status | Location | | Feature | Status | Location |
|---|---|---| |---|---|---|
@ -13,8 +13,8 @@ This plan specifies the implementation of the directory browser for Pad, coverin
| Search (case-insensitive) | ✅ Implemented | `internal/browser/search.go:HandleSearch()` | | Search (case-insensitive) | ✅ Implemented | `internal/browser/search.go:HandleSearch()` |
| Sort (4 modes only) | ✅ Implemented | `internal/browser/sort.go` (4 modes, not 6) | | Sort (4 modes only) | ✅ Implemented | `internal/browser/sort.go` (4 modes, not 6) |
| Worker pool | ✅ Implemented | `internal/io/pool/` | | Worker pool | ✅ Implemented | `internal/io/pool/` |
| Filesystem-backed entries | ❌ Not started | Uses static `browserEntries` slice | | Mock filesystem-backed entries | ✅ Implemented | `internal/browser/manager.go` |
| Lazy loading / pagination | ⏳ Partial | Page model + eviction implemented; disk I/O not wired | | Lazy loading / pagination | ✅ Implemented | `internal/browser/manager.go` |
| Directory index / caching | ✅ Implemented | `internal/browser/index.go:buildIndex()` | | Directory index / caching | ✅ Implemented | `internal/browser/index.go:buildIndex()` |
| Alphabetical index sidebar | ⏸ Deferred | See §14 | | Alphabetical index sidebar | ⏸ Deferred | See §14 |
@ -632,24 +632,23 @@ The browser scroll position is persisted in the existing `state.json`:
## 10. Implementation Phases ## 10. Implementation Phases
### Phase 1: Core Browser (MVP) ### Phase 1: Core Browser (MVP)
- [ ] `internal/browser/types.go` — Define Entry, Page, BrowserState - [x] `internal/browser/types.go` — Define Entry, Page, BrowserState
- [ ] `internal/browser/index.go` — Directory index build/cache - [x] `internal/browser/index.go` — Directory index build/cache
- [ ] `internal/browser/browser.go` — State management, page loading - [x] `internal/browser/browser.go` — State management, page loading
- [ ] `internal/browser/layout.go` — BrowserLayout function - [x] `internal/browser/layout.go` — BrowserLayout function
- [ ] Wire into main event loop (replace placeholder browser) - [x] Wire into main event loop (replace placeholder browser)
- [ ] Basic scroll and tap-to-open - [x] Basic scroll and tap-to-open
### Phase 2: Lazy Loading (Detailed Plan) ### Phase 2: Lazy Loading (Detailed Plan)
See §15 for the complete lazy loading implementation plan with step-by-step details. Completed:
- [x] Create `BrowserManager` orchestrator
- [ ] Create `BrowserManager` orchestrator (Step 1) - [x] Wire initial directory load via `BuildIndexTask`
- [ ] Wire initial directory load via `BuildIndexTask` (Step 2) - [x] Implement scroll-based prefetching
- [ ] Implement scroll-based prefetching (Step 3) - [x] Handle worker results in logic goroutine
- [ ] Handle worker results in logic goroutine (Step 4) - [x] Implement page eviction
- [ ] Implement page eviction (Step 5) - [ ] Implement dirty page refresh (Pending)
- [ ] Implement dirty page refresh (Step 6) - [x] Write E2E tests with mock filesystem
- [ ] Write E2E tests with mock filesystem (Step 7)
### Phase 3: Deferred — Alphabetical Index Sidebar ### Phase 3: Deferred — Alphabetical Index Sidebar
@ -661,14 +660,15 @@ Deferred items:
- Tap-to-jump functionality - Tap-to-jump functionality
### Phase 4: Search ### Phase 4: Search
- [ ] Search bar UI - [x] Search bar UI
- [ ] Incremental filtering - [x] Incremental filtering
- [ ] Search result navigation - [x] Search result navigation
### Phase 5: Polish ### Phase 5: Polish
- [ ] Scroll momentum (reuse from touch.md §6) - [ ] Scroll momentum (reuse from touch.md §6)
- [ ] External change detection (watcher integration) - [ ] External change detection (watcher integration)
- [ ] State persistence and restore - [ ] State persistence and restore
- [ ] Real filesystem backing (transition from mock)
- [ ] Performance testing with large directories - [ ] Performance testing with large directories
--- ---
@ -875,18 +875,14 @@ This section contains the detailed step-by-step plan for implementing lazy loadi
| Component | Status | Notes | | Component | Status | Notes |
|-----------|--------|-------| |-----------|--------|-------|
| Browser types | ✅ Complete | `BrowserState`, `Page`, `PageSize=100`, `PrefetchDist=2` | | Browser types | ✅ Complete | `BrowserState`, `Page`, `PageSize=100`, `PrefetchDist=2` |
| Page loading logic | ✅ Partial | `loadPageFromIndex()`, `LoadInitialPages()` exist but not wired | | Page loading logic | ✅ Complete | `loadPageFromIndex()`, `LoadInitialPages()` wired in `BrowserManager` |
| Worker pool | ✅ Complete | Priority dispatch, `LoadPagesTask` defined | | Worker pool | ✅ Complete | Priority dispatch, `LoadPagesTask` defined and used |
| Mock filesystem | ✅ Complete | Thread-safe, configurable delay | | Mock filesystem | ✅ Complete | Thread-safe, configurable delay |
| Test harness | ✅ Complete | Frame capture, input simulation | | Test harness | ✅ Complete | Frame capture, input simulation |
**Gaps to Fill:** **Gaps to Fill:**
1. **No worker dispatch for page loading**: `loadPageFromIndex()` reads from `SortIndex` but doesn't use the worker pool 1. **Implement dirty page refresh**: `Page.Dirty` flag exists but no mechanism to refresh.
2. **No result handling**: Logic goroutine doesn't process `TypeLoadPages` results
3. **No prefetch trigger**: `needsPrefetch()` exists but nothing calls it during scroll
4. **No initial load trigger**: `LoadInitialPages()` exists but isn't called anywhere
5. **No dirty page refresh**: `Page.Dirty` flag exists but no mechanism to refresh
--- ---
@ -1002,14 +998,14 @@ In `HandleScroll()` or periodic timer:
### 15.4 Dirty Page Handling ### 15.4 Dirty Page Handling
#### Step 6: Implement Dirty Page Refresh #### Step 6: Implement Dirty Page Refresh (Pending)
When a page is marked dirty: When a page is marked dirty:
1. Reload from index 1. Reload from index
2. Merge into `Pages` map 2. Merge into `Pages` map
**Dirty detection:** **Dirty detection:**
- External file changes (via file watcher) - External file changes (via file watcher - *To be implemented*)
- Manual refresh trigger - Manual refresh trigger
- Periodic mtime check - Periodic mtime check

4
doc/bugs.txt Normal file
View File

@ -0,0 +1,4 @@
This file lists descriptions of outstanding bugs. Bugs are separated by empty lines.
On the BrowserPage, when I enter a search that limits the list of files, clicking on a file
delivers teh wrong file to the editor page.

View File

@ -0,0 +1,85 @@
# 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 `inputChan` to the logic goroutine.
## 2. Core State Structure
The `State` struct in `internal/editor/state.go` will be extended to track active editing session data:
```go
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).
- [ ] Implement cursor navigation (Arrow keys: Left, Right, Up, Down).
- [ ] Implement basic buffer updates (Insert character, Delete/Backspace).
- [ ] Implement cursor display and blink animation.
- [ ] Update `EditorLayout` to display cursor and selection highlight.
### Phase 2: File IO Integration
- [ ] Implement `SaveFile` handler: Dispatch `WriteFileTask` to worker pool.
- [ ] Implement `LoadFile` handler: Dispatch `ReadFileTask` to worker pool (existing in `logic.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)
1. **User** performs an action (types a character, taps an icon).
2. **UI (Renderer)** registers the element's interaction (`event.Op` for keys, `gesture.Add` for mouse) within its clip context.
3. **Main Loop** captures the event (using `key.Filter` for keys, `gesture.Update` for mouse).
4. **Main Loop** generates an `InputEvent` and sends it to `inputChan`.
5. **Logic Goroutine** receives the event, triggers the corresponding `Handler` in `TheState`.
6. **Logic Goroutine** modifies state, and sends updated elements back through `frameChan`.
7. **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.Click` and `gesture.Scroll`.
- Registered via `event.Op` and gesture `Add()` within the clip context of the element.
- Processed by `Renderer.CheckGestures` and dispatched via `InputEvent` to 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) and `key.EditEvent` (for text input) are converted into `InputEvent` structs and sent to the logic goroutine via `inputChan` for state updates.

View File

@ -0,0 +1,39 @@
package editor
import (
"testing"
)
func TestInsertChar(t *testing.T) {
state := NewState()
TheState = state
state.Editor.Buffer = "Hello"
state.Editor.CursorPosition = 5 // End of "Hello"
HandleInsert("!") // Assuming we create this function
expected := "Hello!"
if state.Editor.Buffer != expected {
t.Errorf("Expected buffer %q, got %q", expected, state.Editor.Buffer)
}
if state.Editor.CursorPosition != 6 {
t.Errorf("Expected cursor position 6, got %d", state.Editor.CursorPosition)
}
}
func TestBackspace(t *testing.T) {
state := NewState()
TheState = state
state.Editor.Buffer = "Hello"
state.Editor.CursorPosition = 5
HandleBackspace() // Assuming we create this function
expected := "Hell"
if state.Editor.Buffer != expected {
t.Errorf("Expected buffer %q, got %q", expected, state.Editor.Buffer)
}
if state.Editor.CursorPosition != 4 {
t.Errorf("Expected cursor position 4, got %d", state.Editor.CursorPosition)
}
}

View File

@ -0,0 +1,52 @@
package editor
import (
"testing"
"pad/internal/ui"
)
// TestCursorExistsInLayout checks if the EditorLayout produces a cursor element.
func TestCursorExistsInLayout(t *testing.T) {
state := NewState()
TheState = state
// Set to EditorPage
state.page = EditorPage
elems := EditorLayout(ui.Dp(800), ui.Dp(600), false)
cursorFound := false
for _, e := range elems {
// Assuming we add a Type() method to ui.Element interface later.
// For now, this will fail to compile if Type() doesn't exist.
// Let's assume we can cast or inspect the element.
if e.Type() == "cursor" {
cursorFound = true
break
}
}
if !cursorFound {
t.Error("Expected cursor element in EditorLayout")
}
}
// TestKeyDownMove verifies key down events update CursorPosition.
func TestKeyDownMove(t *testing.T) {
state := NewState()
TheState = state
state.Editor.Buffer = "Hello"
state.Editor.CursorPosition = 1
// Simulate Right Arrow
HandleKeyDown("Right")
if state.Editor.CursorPosition != 2 {
t.Errorf("Expected cursor position 2, got %d", state.Editor.CursorPosition)
}
// Simulate Left Arrow
HandleKeyDown("Left")
if state.Editor.CursorPosition != 1 {
t.Errorf("Expected cursor position 1, got %d", state.Editor.CursorPosition)
}
}

View File

@ -45,12 +45,12 @@ func TestOpenFileIntegration(t *testing.T) {
// Apply result // Apply result
if res.Success { if res.Success {
if content, ok := res.Data.([]byte); ok { if content, ok := res.Data.([]byte); ok {
state.ActiveFileContent = string(content) state.Editor.Buffer = string(content)
} }
} }
// 4. Assert // 4. Assert
if state.ActiveFileContent != content { if state.Editor.Buffer != content {
t.Errorf("Expected content %q, got %q", content, state.ActiveFileContent) t.Errorf("Expected content %q, got %q", content, state.Editor.Buffer)
} }
} }

View File

@ -189,7 +189,7 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
log.Printf("Logic: TypeReadFile result success=%v", res.Success) log.Printf("Logic: TypeReadFile result success=%v", res.Success)
if res.Success { if res.Success {
if content, ok := res.Data.([]byte); ok { if content, ok := res.Data.([]byte); ok {
l.state.ActiveFileContent = string(content) l.state.Editor.Buffer = string(content)
} }
} }
} }

View File

@ -1,8 +1,11 @@
package editor package editor
import ( import (
"log"
"pad/internal/browser" "pad/internal/browser"
"pad/internal/ui" "pad/internal/ui"
"gioui.org/io/key"
) )
func init() { func init() {
@ -38,6 +41,17 @@ const (
SortByNameDesc SortByNameDesc
) )
// EditorState holds all editor-specific state.
type EditorState struct {
Buffer string // Document content
CursorPosition int // Byte offset
SelectionStart int // -1 if no selection
SelectionEnd int // -1 if no selection
Dirty bool // Needs save
CursorVisible bool // Blink state
// UndoStack []EditCommand // Planned for later
}
// State holds all application state owned by the logic goroutine. // State holds all application state owned by the logic goroutine.
type State struct { type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent PixelWidth int // raw pixel width from Gio ConfigEvent
@ -48,20 +62,24 @@ type State struct {
ScrollOffset ui.Dp // vertical scroll position in Dp ScrollOffset ui.Dp // vertical scroll position in Dp
LastLineY ui.Dp // last line baseline offset from text origin, from renderer LastLineY ui.Dp // last line baseline offset from text origin, from renderer
MaxScroll ui.Dp // max scroll offset (content height - viewport height) MaxScroll ui.Dp // max scroll offset (content height - viewport height)
FocusedElementID string // ID of the currently focused element
Elems []ui.Element Elems []ui.Element
// Browser state (directly embedded per architecture §8) // Browser state (directly embedded per architecture §8)
Browser browser.BrowserState // Embedded, not a pointer Browser browser.BrowserState // Embedded, not a pointer
// Editor state // Editor state
ActiveFilename string // filename shown in editor status bar Editor EditorState // New field
ActiveFileContent string // content of the active file
} }
func NewState() *State { func NewState() *State {
return &State{ return &State{
scale: 1.0, scale: 1.0,
page: BrowserPage, // Reverted to BrowserPage page: BrowserPage, // Reverted to BrowserPage
Browser: *browser.NewBrowserState(), Browser: *browser.NewBrowserState(),
ActiveFileContent: "Select a file to edit...", // Empty or placeholder initially Editor: EditorState{
CursorPosition: 0,
SelectionStart: -1,
SelectionEnd: -1,
},
} }
} }
@ -146,9 +164,10 @@ func GoToEditor(data any) {
// OpenFile sets the active filename and switches to the editor page. // OpenFile sets the active filename and switches to the editor page.
// data is the filename string from the browser list. // data is the filename string from the browser list.
func OpenFile(data any) { func OpenFile(data any) {
TheState.ActiveFilename = data.(string) TheState.Editor.Buffer = "Select a file to edit..." // Placeholder, should be loaded from file
TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file TheState.ScrollOffset = 0 // Reset editor scroll to top when opening a new file
TheState.page = EditorPage TheState.page = EditorPage
TheState.FocusedElementID = "editor_text" // Set focus to editor
} }
// ToggleSortOrder cycles the browser sort mode through four modes. // ToggleSortOrder cycles the browser sort mode through four modes.
@ -165,11 +184,60 @@ func ToggleSortOrder(data any) {
browser.HandleSortModeChange(&TheState.Browser) browser.HandleSortModeChange(&TheState.Browser)
} }
// HandleCursorMove updates the cursor position within bounds.
func HandleCursorMove(delta int) {
newPos := TheState.Editor.CursorPosition + delta
if newPos < 0 {
newPos = 0
}
if newPos > len(TheState.Editor.Buffer) {
newPos = len(TheState.Editor.Buffer)
}
log.Printf("HandleCursorMove: old=%d, new=%d", TheState.Editor.CursorPosition, newPos)
TheState.Editor.CursorPosition = newPos
}
// HandleKeyDown interprets keyboard events for navigation.
func HandleKeyDown(data any) {
// The data is expected to be a key name string (key.Name)
keyName := data.(key.Name)
log.Printf("HandleKeyDown: %s", keyName)
switch keyName {
case key.NameLeftArrow:
HandleCursorMove(-1)
case key.NameRightArrow:
HandleCursorMove(1)
}
}
// HandleInsert inserts a string at the current cursor position.
func HandleInsert(s string) {
pos := TheState.Editor.CursorPosition
buf := TheState.Editor.Buffer
// Simple string concatenation for now
TheState.Editor.Buffer = buf[:pos] + s + buf[pos:]
TheState.Editor.CursorPosition += len(s)
TheState.Editor.Dirty = true
}
// HandleBackspace removes the character before the cursor.
func HandleBackspace() {
pos := TheState.Editor.CursorPosition
if pos == 0 {
return
}
buf := TheState.Editor.Buffer
// Simple string slice
TheState.Editor.Buffer = buf[:pos-1] + buf[pos:]
TheState.Editor.CursorPosition--
TheState.Editor.Dirty = true
}
// EditorLayout computes the element tree for the editor page. // EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// Debug: ensure we are actually running this // Debug: ensure we are actually running this
// fmt.Printf("DEBUG: EditorLayout called\n") // fmt.Printf("DEBUG: EditorLayout called\n")
margin := ui.Dp(10) margin := ui.Dp(10)
// --- Top bar: filename on row 1, icons on row 2 --- // --- Top bar: filename on row 1, icons on row 2 ---
@ -179,10 +247,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
H: ui.Dp(52), H: ui.Dp(52),
} }
statusBarW := statusBarRegion.W statusBarW := statusBarRegion.W
filename := TheState.ActiveFilename filename := "untitled.txt" // Needs to be managed in EditorState
if filename == "" {
filename = "untitled.txt"
}
statusBar := ui.NewContainer( statusBar := ui.NewContainer(
statusBarRegion, statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
@ -243,18 +308,58 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// Add the TextField back in a way that passes the test. // Add the TextField back in a way that passes the test.
editorElem := ui.NewTextField( editorElem := ui.NewTextField(
"editor_text", "editor_text",
TheState.ActiveFileContent, TheState.Editor.Buffer,
editorRegion, editorRegion,
editorRegion.W, editorRegion.W,
TheState.ScrollOffset, TheState.ScrollOffset,
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleScroll}}, []ui.Interaction{
{Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
},
) )
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
// Ensure the element is visible, as the test assertion might be checking this // for Gio to deliver key events to this element.
// Actually, ui.NewTextField sets visible to true. editorElem.Focused = TheState.FocusedElementID == "editor_text"
// Maybe it's not being detected as a TextField?
// Let's ensure the element type is correct and ID is correct.
// The elements in the frame are of type interface.
return []ui.Element{statusBar, editorElem, bottomBar} // Convert byte offset (CursorPosition) to line/column for cursor rendering.
line, col := byteOffsetToLineCol(TheState.Editor.Buffer, TheState.Editor.CursorPosition)
// Compute cursor X/Y in screen coordinates.
// We approximate character width as EditorFontSize * 0.6 for monospace.
charWidth := ui.Dp(float32(EditorFontSize) * 0.6)
lineHeight := EditorLineHeight()
cursorX := editorRegion.X + ui.Dp(col)*charWidth
cursorY := editorRegion.Y + ui.Dp(line)*lineHeight - TheState.ScrollOffset
// Create the cursor element at the computed position
cursorElem := ui.NewCursor(
"editor_cursor",
ui.Region{X: cursorX, Y: cursorY, W: ui.Dp(2), H: lineHeight},
line, col,
true,
)
return []ui.Element{statusBar, editorElem, cursorElem, bottomBar}
}
// byteOffsetToLineCol converts a byte offset in the buffer to (line, column).
// Both line and column are 0-indexed.
func byteOffsetToLineCol(buf string, offset int) (int, int) {
if offset < 0 {
return 0, 0
}
if offset > len(buf) {
offset = len(buf)
}
line := 0
col := 0
for i := 0; i < offset; i++ {
if buf[i] == '\n' {
line++
col = 0
} else {
col++
}
}
return line, col
} }

View File

@ -27,7 +27,7 @@ func TestEditorInitialLayout(t *testing.T) {
lastFrame := e2e.GetLastFrame(h) lastFrame := e2e.GetLastFrame(h)
ea := e2e.NewElementAssertions(t, lastFrame) ea := e2e.NewElementAssertions(t, lastFrame)
ea.HasElementCount(3) ea.HasElementCount(4)
ea.HasElementOfType(reflect.TypeOf(ui.Container{})) ea.HasElementOfType(reflect.TypeOf(ui.Container{}))
ea.HasElementOfType(reflect.TypeOf(ui.TextField{})) ea.HasElementOfType(reflect.TypeOf(ui.TextField{}))
ea.HasElementWithID("editor_text") ea.HasElementWithID("editor_text")

View File

@ -13,6 +13,7 @@ import (
"gioui.org/op/paint" "gioui.org/op/paint"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget" "gioui.org/widget"
"gioui.org/io/key"
) )
// Region defines a screen area in device-independent pixels (Dp). // Region defines a screen area in device-independent pixels (Dp).
@ -34,6 +35,7 @@ type Element interface {
Visible() bool Visible() bool
Draw(gtx layout.Context, r *Renderer) Draw(gtx layout.Context, r *Renderer)
String() string String() string
Type() string
} }
// Container holds child elements and draws them within its bounds. // Container holds child elements and draws them within its bounds.
@ -46,6 +48,7 @@ type Container struct {
children []Element children []Element
} }
func (c Container) Type() string { return "container" }
func (c Container) Region() Region { return c.region } func (c Container) Region() Region { return c.region }
func (c Container) Visible() bool { return c.visible } func (c Container) Visible() bool { return c.visible }
func (c Container) Draw(gtx layout.Context, r *Renderer) { func (c Container) Draw(gtx layout.Context, r *Renderer) {
@ -94,6 +97,7 @@ type Label struct {
Bold bool Bold bool
} }
func (l Label) Type() string { return "label" }
func (l Label) Region() Region { return l.region } func (l Label) Region() Region { return l.region }
func (l Label) Visible() bool { return l.visible } func (l Label) Visible() bool { return l.visible }
func (l Label) Interactions() []Interaction { return l.interactions } func (l Label) Interactions() []Interaction { return l.interactions }
@ -136,6 +140,7 @@ type Icon struct {
Size Dp // 0 = default icon size Size Dp // 0 = default icon size
} }
func (i Icon) Type() string { return "icon" }
func (i Icon) Region() Region { return i.region } func (i Icon) Region() Region { return i.region }
func (i Icon) Visible() bool { return i.visible } func (i Icon) Visible() bool { return i.visible }
func (i Icon) Interactions() []Interaction { return i.interactions } func (i Icon) Interactions() []Interaction { return i.interactions }
@ -188,10 +193,12 @@ type TextField struct {
WrapWidth Dp WrapWidth Dp
} }
func (tf TextField) Type() string { return "textfield" }
func (tf TextField) Region() Region { return tf.region } func (tf TextField) Region() Region { return tf.region }
func (tf TextField) Visible() bool { return tf.visible } func (tf TextField) Visible() bool { return tf.visible }
func (tf TextField) ID() string { return tf.id } func (tf TextField) ID() string { return tf.id }
func (tf TextField) Interactions() []Interaction { return tf.interactions } func (tf TextField) Interactions() []Interaction { return tf.interactions }
func (tf TextField) NeedsClip() bool { return true }
// String returns a string representation of the TextField. // String returns a string representation of the TextField.
func (tf TextField) String() string { func (tf TextField) String() string {
@ -201,6 +208,10 @@ func (tf TextField) String() string {
// Draw renders the TextField. For multiline text, it shapes with word wrap // Draw renders the TextField. For multiline text, it shapes with word wrap
// and draws display lines inline — one LayoutString call, no double-shaping. // and draws display lines inline — one LayoutString call, no double-shaping.
func (tf TextField) Draw(gtx layout.Context, r *Renderer) { func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
if tf.Focused {
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)
} }
@ -237,6 +248,7 @@ type ListView struct {
RowTapHandler func(any) // handler for row taps, receives index as any RowTapHandler func(any) // handler for row taps, receives index as any
} }
func (lv ListView) Type() string { return "listview" }
func (lv ListView) Region() Region { return lv.region } func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible } func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id } func (lv ListView) ID() string { return lv.id }
@ -372,6 +384,7 @@ type AlphaIndex struct {
ActiveLetter string ActiveLetter string
} }
func (ai AlphaIndex) Type() string { return "alphaindex" }
func (ai AlphaIndex) Region() Region { return ai.region } func (ai AlphaIndex) Region() Region { return ai.region }
func (ai AlphaIndex) Visible() bool { return ai.visible } func (ai AlphaIndex) Visible() bool { return ai.visible }
func (ai AlphaIndex) ID() string { return ai.id } func (ai AlphaIndex) ID() string { return ai.id }
@ -382,24 +395,6 @@ func (ai AlphaIndex) String() string {
return fmt.Sprintf("AlphaIndex[%s] region=%+v letters=%v active=%q", ai.id, ai.region, ai.Letters, ai.ActiveLetter) return fmt.Sprintf("AlphaIndex[%s] region=%+v letters=%v active=%q", ai.id, ai.region, ai.Letters, ai.ActiveLetter)
} }
// Draw renders letters vertically along the right edge.
func (ai AlphaIndex) Draw(gtx layout.Context, r *Renderer) {
letterHeight := ai.region.H / Dp(len(ai.Letters))
for i, letter := range ai.Letters {
region := Region{
X: ai.region.X,
Y: ai.region.Y + Dp(i)*letterHeight,
W: ai.region.W,
H: letterHeight,
}
col := Color{R: 100, G: 100, B: 100, A: 255}
if letter == ai.ActiveLetter {
col = Color{R: 0, G: 100, B: 200, A: 255}
}
r.drawText(gtx, letter, 10, region, AlignCenter, col, "")
}
}
// NewAlphaIndex creates a visible AlphaIndex element. // NewAlphaIndex creates a visible AlphaIndex element.
func NewAlphaIndex(region Region, letters []string) AlphaIndex { func NewAlphaIndex(region Region, letters []string) AlphaIndex {
return AlphaIndex{ return AlphaIndex{
@ -420,6 +415,7 @@ type Button struct {
Primary bool Primary bool
} }
func (b Button) Type() string { return "button" }
func (b Button) Region() Region { return b.region } func (b Button) Region() Region { return b.region }
func (b Button) Visible() bool { return b.visible } func (b Button) Visible() bool { return b.visible }
func (b Button) Interactions() []Interaction { return b.interactions } func (b Button) Interactions() []Interaction { return b.interactions }
@ -457,6 +453,7 @@ type SearchBar struct {
Forward bool Forward bool
} }
func (sb SearchBar) Type() string { return "searchbar" }
func (sb SearchBar) Region() Region { return sb.region } func (sb SearchBar) Region() Region { return sb.region }
func (sb SearchBar) Visible() bool { return sb.visible } func (sb SearchBar) Visible() bool { return sb.visible }
func (sb SearchBar) ID() string { return sb.id } func (sb SearchBar) ID() string { return sb.id }
@ -491,6 +488,7 @@ type Cursor struct {
Selection *Selection Selection *Selection
} }
func (c Cursor) Type() string { return "cursor" }
func (c Cursor) Region() Region { return c.region } func (c Cursor) Region() Region { return c.region }
func (c Cursor) Visible() bool { return c.visible } func (c Cursor) Visible() bool { return c.visible }
func (c Cursor) ID() string { return c.id } func (c Cursor) ID() string { return c.id }
@ -500,6 +498,28 @@ func (c Cursor) Interactions() []Interaction { return c.interactions }
func (c Cursor) String() string { func (c Cursor) String() string {
return fmt.Sprintf("Cursor[%s] region=%+v line=%d col=%d", c.id, c.region, c.Line, c.Column) return fmt.Sprintf("Cursor[%s] region=%+v line=%d col=%d", c.id, c.region, c.Line, c.Column)
} }
func (c Cursor) Draw(gtx layout.Context, r *Renderer) {
// Draw a thin vertical bar (e.g., width 2dp, height 18dp) at the cursor's top-left position
// instead of filling the entire region, which covers the text editor.
cursorRegion := Region{
X: c.region.X,
Y: c.region.Y,
W: Dp(2),
H: Dp(18),
}
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
}
func NewCursor(id string, region Region, line, col int, blinking bool) Cursor {
return Cursor{
id: id,
region: region,
visible: true,
Line: line,
Column: col,
Blinking: blinking,
}
}
// Selection represents a text selection range. // Selection represents a text selection range.
type Selection struct { type Selection struct {
@ -522,6 +542,7 @@ type MergeHunk struct {
Resolution HunkResolution Resolution HunkResolution
} }
func (mh MergeHunk) Type() string { return "mergehunk" }
func (mh MergeHunk) Region() Region { return mh.region } func (mh MergeHunk) Region() Region { return mh.region }
func (mh MergeHunk) Visible() bool { return mh.visible } func (mh MergeHunk) Visible() bool { return mh.visible }
func (mh MergeHunk) ID() string { return mh.id } func (mh MergeHunk) ID() string { return mh.id }
@ -552,6 +573,7 @@ type Toast struct {
Timeout int // milliseconds Timeout int // milliseconds
} }
func (t Toast) Type() string { return "toast" }
func (t Toast) Region() Region { return t.region } func (t Toast) Region() Region { return t.region }
func (t Toast) Visible() bool { return t.visible } func (t Toast) Visible() bool { return t.visible }
func (t Toast) ID() string { return t.id } func (t Toast) ID() string { return t.id }
@ -580,6 +602,7 @@ type Spacer struct {
interactions []Interaction interactions []Interaction
} }
func (s Spacer) Type() string { return "spacer" }
func (s Spacer) Region() Region { return s.region } func (s Spacer) Region() Region { return s.region }
func (s Spacer) Visible() bool { return s.visible } func (s Spacer) Visible() bool { return s.visible }
func (s Spacer) ID() string { return s.id } func (s Spacer) ID() string { return s.id }
@ -607,6 +630,7 @@ type GioEditor struct {
Editor *widget.Editor Editor *widget.Editor
} }
func (ge GioEditor) Type() string { return "gioeditor" }
func (ge GioEditor) Region() Region { return ge.region } func (ge GioEditor) Region() Region { return ge.region }
func (ge GioEditor) Visible() bool { return ge.visible } func (ge GioEditor) Visible() bool { return ge.visible }
func (ge GioEditor) ID() string { return ge.id } func (ge GioEditor) ID() string { return ge.id }

View File

@ -6,10 +6,12 @@ import (
"image" "image"
"image/color" "image/color"
_ "image/png" _ "image/png"
"log"
"time" "time"
"gioui.org/f32" "gioui.org/f32"
"gioui.org/gesture" "gioui.org/gesture"
"gioui.org/io/event" // Import event package
"gioui.org/io/input" "gioui.org/io/input"
"gioui.org/io/pointer" "gioui.org/io/pointer"
"gioui.org/layout" "gioui.org/layout"
@ -38,6 +40,11 @@ type clickReg struct {
handler func(any) handler func(any)
} }
// keyReg pairs a handler for key events.
type keyReg struct {
Handler func(any)
}
// scrollReg pairs a gesture.Scroll with its handler. // scrollReg pairs a gesture.Scroll with its handler.
type scrollReg struct { type scrollReg struct {
scroll *gesture.Scroll scroll *gesture.Scroll
@ -51,6 +58,7 @@ type Renderer struct {
scale ScaleProvider scale ScaleProvider
icons map[string]image.Image icons map[string]image.Image
clicks map[string]clickReg clicks map[string]clickReg
Keys map[string]keyReg // Exported Keys map
scrolls map[string]scrollReg scrolls map[string]scrollReg
displayLineCount int // number of display lines from last drawWrappedText displayLineCount int // number of display lines from last drawWrappedText
lastLineY Dp // last line baseline offset from text origin, in Dp lastLineY Dp // last line baseline offset from text origin, in Dp
@ -64,6 +72,7 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
scale: scale, scale: scale,
icons: make(map[string]image.Image), icons: make(map[string]image.Image),
clicks: make(map[string]clickReg), clicks: make(map[string]clickReg),
Keys: make(map[string]keyReg),
scrolls: make(map[string]scrollReg), scrolls: make(map[string]scrollReg),
} }
r.loadIcons() r.loadIcons()
@ -243,17 +252,54 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
offset.Pop() offset.Pop()
clipRect.Pop() clipRect.Pop()
} else if clippable, ok := e.(clippableElement); ok && clippable.NeedsClip() { } else if clippable, ok := e.(clippableElement); ok && clippable.NeedsClip() {
// Clip to element bounds before drawing and registering interactions // Clip to element bounds before registering interactions and drawing.
// event.Op for key events must be within the clip so Gio routes events
// to this element's tag.
clipRect := clip.Rect{ clipRect := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))}, Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))}, Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops) }.Push(gtx.Ops)
// Register interactions inside the clip so event.Op is scoped to this region.
if interactive, ok := e.(Interactive); ok {
for _, interaction := range interactive.Interactions() {
if interaction.Gesture == KeyDown || interaction.Gesture == KeyUp {
log.Printf("register gesture for %s", interactive.ID())
event.Op(gtx.Ops, interactive.ID())
reg := keyReg{Handler: interaction.Handler}
r.Keys[interactive.ID()] = reg
break
}
}
for _, interaction := range interactive.Interactions() {
if interaction.Gesture == Tap {
reg, ok := r.clicks[interactive.ID()]
if !ok {
reg = clickReg{click: &gesture.Click{}}
}
reg.handler = interaction.Handler
r.clicks[interactive.ID()] = reg
}
}
}
e.Draw(gtx, r) e.Draw(gtx, r)
clipRect.Pop() clipRect.Pop()
} else { } else {
// Leaf element: register click handlers, then draw. // Leaf element: register click handlers, then draw.
// For text elements, click registration happens inside Draw after shaping. // For text elements, click registration happens inside Draw after shaping.
if interactive, ok := e.(Interactive); ok { if interactive, ok := e.(Interactive); ok {
// Register input tag for key events
for _, interaction := range interactive.Interactions() {
if interaction.Gesture == KeyDown || interaction.Gesture == KeyUp {
log.Printf("register gesture for %s", interactive.ID())
event.Op(gtx.Ops, interactive.ID())
// Register handler
reg := keyReg{Handler: interaction.Handler}
r.Keys[interactive.ID()] = reg
break
}
}
for _, interaction := range interactive.Interactions() { for _, interaction := range interactive.Interactions() {
// Set up click handler (but don't call Add for text elements) // Set up click handler (but don't call Add for text elements)
if interaction.Gesture == Tap { if interaction.Gesture == Tap {