diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 0c0332c..4e189b5 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -10,6 +10,7 @@ import ( "gioui.org/text" "gioui.org/unit" "gioui.org/font/gofont" + "gioui.org/io/key" "pad/internal/editor" "pad/internal/ui" @@ -71,11 +72,49 @@ func run(w *app.Window) error { if newQuery != logic.State().Browser.Query { sendQuery = true } - if events := renderer.CheckGestures(e.Source, gtx.Metric); len(events) > 0 { - logic.InputChan() <- events + events := renderer.CheckGestures(e.Source, gtx.Metric) + + // 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) mu.Unlock() + if len(events) > 0 { + logic.InputChan() <- events + } if sendQuery { logic.SearchQueryChan() <- newQuery } diff --git a/cmd/pad/pad b/cmd/pad/pad deleted file mode 100755 index 37f1d27..0000000 Binary files a/cmd/pad/pad and /dev/null differ diff --git a/doc/browser_implementation_plan.md b/doc/browser_implementation_plan.md index 4488d18..2150793 100644 --- a/doc/browser_implementation_plan.md +++ b/doc/browser_implementation_plan.md @@ -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. -### Current State (as of 2026-05-30) +### Current State (as of 2026-06-02) | 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()` | | Sort (4 modes only) | ✅ Implemented | `internal/browser/sort.go` (4 modes, not 6) | | Worker pool | ✅ Implemented | `internal/io/pool/` | -| Filesystem-backed entries | ❌ Not started | Uses static `browserEntries` slice | -| Lazy loading / pagination | ⏳ Partial | Page model + eviction implemented; disk I/O not wired | +| Mock filesystem-backed entries | ✅ Implemented | `internal/browser/manager.go` | +| Lazy loading / pagination | ✅ Implemented | `internal/browser/manager.go` | | Directory index / caching | ✅ Implemented | `internal/browser/index.go:buildIndex()` | | Alphabetical index sidebar | ⏸ Deferred | See §14 | @@ -632,24 +632,23 @@ The browser scroll position is persisted in the existing `state.json`: ## 10. Implementation Phases ### Phase 1: Core Browser (MVP) -- [ ] `internal/browser/types.go` — Define Entry, Page, BrowserState -- [ ] `internal/browser/index.go` — Directory index build/cache -- [ ] `internal/browser/browser.go` — State management, page loading -- [ ] `internal/browser/layout.go` — BrowserLayout function -- [ ] Wire into main event loop (replace placeholder browser) -- [ ] Basic scroll and tap-to-open +- [x] `internal/browser/types.go` — Define Entry, Page, BrowserState +- [x] `internal/browser/index.go` — Directory index build/cache +- [x] `internal/browser/browser.go` — State management, page loading +- [x] `internal/browser/layout.go` — BrowserLayout function +- [x] Wire into main event loop (replace placeholder browser) +- [x] Basic scroll and tap-to-open ### Phase 2: Lazy Loading (Detailed Plan) -See §15 for the complete lazy loading implementation plan with step-by-step details. - -- [ ] Create `BrowserManager` orchestrator (Step 1) -- [ ] Wire initial directory load via `BuildIndexTask` (Step 2) -- [ ] Implement scroll-based prefetching (Step 3) -- [ ] Handle worker results in logic goroutine (Step 4) -- [ ] Implement page eviction (Step 5) -- [ ] Implement dirty page refresh (Step 6) -- [ ] Write E2E tests with mock filesystem (Step 7) +Completed: +- [x] Create `BrowserManager` orchestrator +- [x] Wire initial directory load via `BuildIndexTask` +- [x] Implement scroll-based prefetching +- [x] Handle worker results in logic goroutine +- [x] Implement page eviction +- [ ] Implement dirty page refresh (Pending) +- [x] Write E2E tests with mock filesystem ### Phase 3: Deferred — Alphabetical Index Sidebar @@ -661,14 +660,15 @@ Deferred items: - Tap-to-jump functionality ### Phase 4: Search -- [ ] Search bar UI -- [ ] Incremental filtering -- [ ] Search result navigation +- [x] Search bar UI +- [x] Incremental filtering +- [x] Search result navigation ### Phase 5: Polish - [ ] Scroll momentum (reuse from touch.md §6) - [ ] External change detection (watcher integration) - [ ] State persistence and restore +- [ ] Real filesystem backing (transition from mock) - [ ] 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 | |-----------|--------|-------| | Browser types | ✅ Complete | `BrowserState`, `Page`, `PageSize=100`, `PrefetchDist=2` | -| Page loading logic | ✅ Partial | `loadPageFromIndex()`, `LoadInitialPages()` exist but not wired | -| Worker pool | ✅ Complete | Priority dispatch, `LoadPagesTask` defined | +| Page loading logic | ✅ Complete | `loadPageFromIndex()`, `LoadInitialPages()` wired in `BrowserManager` | +| Worker pool | ✅ Complete | Priority dispatch, `LoadPagesTask` defined and used | | Mock filesystem | ✅ Complete | Thread-safe, configurable delay | | Test harness | ✅ Complete | Frame capture, input simulation | **Gaps to Fill:** -1. **No worker dispatch for page loading**: `loadPageFromIndex()` reads from `SortIndex` but doesn't use the worker pool -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 +1. **Implement 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 -#### Step 6: Implement Dirty Page Refresh +#### Step 6: Implement Dirty Page Refresh (Pending) When a page is marked dirty: 1. Reload from index 2. Merge into `Pages` map **Dirty detection:** -- External file changes (via file watcher) +- External file changes (via file watcher - *To be implemented*) - Manual refresh trigger - Periodic mtime check diff --git a/doc/bugs.txt b/doc/bugs.txt new file mode 100644 index 0000000..acd0e02 --- /dev/null +++ b/doc/bugs.txt @@ -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. diff --git a/doc/editor_implementation_plan.md b/doc/editor_implementation_plan.md new file mode 100644 index 0000000..aaadcc3 --- /dev/null +++ b/doc/editor_implementation_plan.md @@ -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. diff --git a/internal/editor/buffer_test.go b/internal/editor/buffer_test.go new file mode 100644 index 0000000..5ad0579 --- /dev/null +++ b/internal/editor/buffer_test.go @@ -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) + } +} diff --git a/internal/editor/cursor_test.go b/internal/editor/cursor_test.go new file mode 100644 index 0000000..c94ce44 --- /dev/null +++ b/internal/editor/cursor_test.go @@ -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) + } +} diff --git a/internal/editor/integration_test.go b/internal/editor/integration_test.go index c431412..50cd3fc 100644 --- a/internal/editor/integration_test.go +++ b/internal/editor/integration_test.go @@ -45,12 +45,12 @@ func TestOpenFileIntegration(t *testing.T) { // Apply result if res.Success { if content, ok := res.Data.([]byte); ok { - state.ActiveFileContent = string(content) + state.Editor.Buffer = string(content) } } // 4. Assert - if state.ActiveFileContent != content { - t.Errorf("Expected content %q, got %q", content, state.ActiveFileContent) + if state.Editor.Buffer != content { + t.Errorf("Expected content %q, got %q", content, state.Editor.Buffer) } } diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 33a2140..8195413 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -189,7 +189,7 @@ func (l *Logic) handleWorkerResult(res pool.Result) { log.Printf("Logic: TypeReadFile result success=%v", res.Success) if res.Success { if content, ok := res.Data.([]byte); ok { - l.state.ActiveFileContent = string(content) + l.state.Editor.Buffer = string(content) } } } diff --git a/internal/editor/state.go b/internal/editor/state.go index a33d6b9..e0a1b9a 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -1,8 +1,11 @@ package editor import ( + "log" + "pad/internal/browser" "pad/internal/ui" + "gioui.org/io/key" ) func init() { @@ -38,6 +41,17 @@ const ( 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. type State struct { PixelWidth int // raw pixel width from Gio ConfigEvent @@ -48,20 +62,24 @@ type State struct { ScrollOffset ui.Dp // vertical scroll position in Dp LastLineY ui.Dp // last line baseline offset from text origin, from renderer MaxScroll ui.Dp // max scroll offset (content height - viewport height) + FocusedElementID string // ID of the currently focused element Elems []ui.Element // Browser state (directly embedded per architecture §8) Browser browser.BrowserState // Embedded, not a pointer // Editor state - ActiveFilename string // filename shown in editor status bar - ActiveFileContent string // content of the active file + Editor EditorState // New field } func NewState() *State { return &State{ - scale: 1.0, - page: BrowserPage, // Reverted to BrowserPage - Browser: *browser.NewBrowserState(), - ActiveFileContent: "Select a file to edit...", // Empty or placeholder initially + scale: 1.0, + page: BrowserPage, // Reverted to BrowserPage + Browser: *browser.NewBrowserState(), + 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. // data is the filename string from the browser list. 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.page = EditorPage + TheState.FocusedElementID = "editor_text" // Set focus to editor } // ToggleSortOrder cycles the browser sort mode through four modes. @@ -165,11 +184,60 @@ func ToggleSortOrder(data any) { 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. func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { // Debug: ensure we are actually running this // fmt.Printf("DEBUG: EditorLayout called\n") - + margin := ui.Dp(10) // --- 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), } statusBarW := statusBarRegion.W - filename := TheState.ActiveFilename - if filename == "" { - filename = "untitled.txt" - } + filename := "untitled.txt" // Needs to be managed in EditorState statusBar := ui.NewContainer( statusBarRegion, 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. editorElem := ui.NewTextField( "editor_text", - TheState.ActiveFileContent, + TheState.Editor.Buffer, editorRegion, editorRegion.W, TheState.ScrollOffset, - []ui.Interaction{{Gesture: ui.Scroll, Handler: HandleScroll}}, + []ui.Interaction{ + {Gesture: ui.Scroll, Handler: HandleScroll}, + {Gesture: ui.KeyDown, Handler: HandleKeyDown}, + }, ) - - // Ensure the element is visible, as the test assertion might be checking this - // Actually, ui.NewTextField sets visible to true. - // 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. + // Set Focused so TextField.Draw() issues key.FocusCmd, which is required + // for Gio to deliver key events to this element. + editorElem.Focused = TheState.FocusedElementID == "editor_text" - 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 } diff --git a/internal/test/e2e/harness_test.go b/internal/test/e2e/harness_test.go index 3d4770e..972a1b2 100644 --- a/internal/test/e2e/harness_test.go +++ b/internal/test/e2e/harness_test.go @@ -27,7 +27,7 @@ func TestEditorInitialLayout(t *testing.T) { lastFrame := e2e.GetLastFrame(h) ea := e2e.NewElementAssertions(t, lastFrame) - ea.HasElementCount(3) + ea.HasElementCount(4) ea.HasElementOfType(reflect.TypeOf(ui.Container{})) ea.HasElementOfType(reflect.TypeOf(ui.TextField{})) ea.HasElementWithID("editor_text") diff --git a/internal/ui/element.go b/internal/ui/element.go index a8671b2..2a6daeb 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -13,6 +13,7 @@ import ( "gioui.org/op/paint" "gioui.org/unit" "gioui.org/widget" + "gioui.org/io/key" ) // Region defines a screen area in device-independent pixels (Dp). @@ -34,6 +35,7 @@ type Element interface { Visible() bool Draw(gtx layout.Context, r *Renderer) String() string + Type() string } // Container holds child elements and draws them within its bounds. @@ -46,6 +48,7 @@ type Container struct { children []Element } +func (c Container) Type() string { return "container" } func (c Container) Region() Region { return c.region } func (c Container) Visible() bool { return c.visible } func (c Container) Draw(gtx layout.Context, r *Renderer) { @@ -94,6 +97,7 @@ type Label struct { Bold bool } +func (l Label) Type() string { return "label" } func (l Label) Region() Region { return l.region } func (l Label) Visible() bool { return l.visible } func (l Label) Interactions() []Interaction { return l.interactions } @@ -136,6 +140,7 @@ type Icon struct { Size Dp // 0 = default icon size } +func (i Icon) Type() string { return "icon" } func (i Icon) Region() Region { return i.region } func (i Icon) Visible() bool { return i.visible } func (i Icon) Interactions() []Interaction { return i.interactions } @@ -188,10 +193,12 @@ type TextField struct { WrapWidth Dp } +func (tf TextField) Type() string { return "textfield" } func (tf TextField) Region() Region { return tf.region } func (tf TextField) Visible() bool { return tf.visible } func (tf TextField) ID() string { return tf.id } func (tf TextField) Interactions() []Interaction { return tf.interactions } +func (tf TextField) NeedsClip() bool { return true } // String returns a string representation of the TextField. 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 // and draws display lines inline — one LayoutString call, no double-shaping. 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) } @@ -237,6 +248,7 @@ type ListView struct { 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) Visible() bool { return lv.visible } func (lv ListView) ID() string { return lv.id } @@ -372,6 +384,7 @@ type AlphaIndex struct { ActiveLetter string } +func (ai AlphaIndex) Type() string { return "alphaindex" } func (ai AlphaIndex) Region() Region { return ai.region } func (ai AlphaIndex) Visible() bool { return ai.visible } 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) } -// 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. func NewAlphaIndex(region Region, letters []string) AlphaIndex { return AlphaIndex{ @@ -420,6 +415,7 @@ type Button struct { Primary bool } +func (b Button) Type() string { return "button" } func (b Button) Region() Region { return b.region } func (b Button) Visible() bool { return b.visible } func (b Button) Interactions() []Interaction { return b.interactions } @@ -457,6 +453,7 @@ type SearchBar struct { Forward bool } +func (sb SearchBar) Type() string { return "searchbar" } func (sb SearchBar) Region() Region { return sb.region } func (sb SearchBar) Visible() bool { return sb.visible } func (sb SearchBar) ID() string { return sb.id } @@ -491,6 +488,7 @@ type Cursor struct { Selection *Selection } +func (c Cursor) Type() string { return "cursor" } func (c Cursor) Region() Region { return c.region } func (c Cursor) Visible() bool { return c.visible } 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 { 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. type Selection struct { @@ -522,6 +542,7 @@ type MergeHunk struct { Resolution HunkResolution } +func (mh MergeHunk) Type() string { return "mergehunk" } func (mh MergeHunk) Region() Region { return mh.region } func (mh MergeHunk) Visible() bool { return mh.visible } func (mh MergeHunk) ID() string { return mh.id } @@ -552,6 +573,7 @@ type Toast struct { Timeout int // milliseconds } +func (t Toast) Type() string { return "toast" } func (t Toast) Region() Region { return t.region } func (t Toast) Visible() bool { return t.visible } func (t Toast) ID() string { return t.id } @@ -580,6 +602,7 @@ type Spacer struct { interactions []Interaction } +func (s Spacer) Type() string { return "spacer" } func (s Spacer) Region() Region { return s.region } func (s Spacer) Visible() bool { return s.visible } func (s Spacer) ID() string { return s.id } @@ -607,6 +630,7 @@ type GioEditor struct { Editor *widget.Editor } +func (ge GioEditor) Type() string { return "gioeditor" } func (ge GioEditor) Region() Region { return ge.region } func (ge GioEditor) Visible() bool { return ge.visible } func (ge GioEditor) ID() string { return ge.id } diff --git a/internal/ui/render.go b/internal/ui/render.go index ea36180..72e13a9 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -6,10 +6,12 @@ import ( "image" "image/color" _ "image/png" + "log" "time" "gioui.org/f32" "gioui.org/gesture" + "gioui.org/io/event" // Import event package "gioui.org/io/input" "gioui.org/io/pointer" "gioui.org/layout" @@ -38,6 +40,11 @@ type clickReg struct { handler func(any) } +// keyReg pairs a handler for key events. +type keyReg struct { + Handler func(any) +} + // scrollReg pairs a gesture.Scroll with its handler. type scrollReg struct { scroll *gesture.Scroll @@ -51,6 +58,7 @@ type Renderer struct { scale ScaleProvider icons map[string]image.Image clicks map[string]clickReg + Keys map[string]keyReg // Exported Keys map scrolls map[string]scrollReg displayLineCount int // number of display lines from last drawWrappedText 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, icons: make(map[string]image.Image), clicks: make(map[string]clickReg), + Keys: make(map[string]keyReg), scrolls: make(map[string]scrollReg), } r.loadIcons() @@ -243,17 +252,54 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) { offset.Pop() clipRect.Pop() } 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{ 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))}, }.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) clipRect.Pop() } else { // Leaf element: register click handlers, then draw. // For text elements, click registration happens inside Draw after shaping. 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() { // Set up click handler (but don't call Add for text elements) if interaction.Gesture == Tap {