From 78d240eedb2a1700c8765c282633dddad1c9f66f Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 20 Aug 2026 00:02:54 -0400 Subject: [PATCH] Add in-file search (find bar) - pool: SearchTask + FindSubstring (case-insensitive substring scan with query generation for stale-result dropping) - editor: FindState on EditorState; find bar handlers (ToggleFind/FindNext/ FindPrev/FindClose); worker-pool scan of the in-memory content; edit-aware offset remapping (shift after, drop overlapping); next/prev with wrap-around, selection + scroll to match; main-owned find_bar input forwarded via FindQueryChan (browser search_bar precedent) - ui: find-bar layout below the margin-free top bar; search icon on the top bar right; new search/close/chevron_up/chevron_down icons - logic: findQueryChan, TypeSearch result handling, findReset on open - frame: FindQuery field (main syncs the widget text like Query) - tests: pool scan tests, find-state unit tests (remap, nav, gen gate, focus), e2e find bar + navigation + edit-survival on real files - docs: spec/architecture/development_plan updated (search implemented, channel/task tables) --- cmd/pad/main.go | 10 + doc/architecture.md | 10 +- doc/development_plan.md | 3 +- doc/spec.md | 13 +- internal/editor/frame.go | 6 + internal/editor/logic.go | 20 +- internal/editor/search.go | 288 +++++++++++++++++++++++++++++ internal/editor/search_test.go | 195 +++++++++++++++++++ internal/editor/state.go | 86 ++++++++- internal/io/pool/search_test.go | 107 +++++++++++ internal/io/pool/task.go | 93 ++++++++++ internal/test/e2e/find_test.go | 216 ++++++++++++++++++++++ internal/ui/icons/chevron_down.png | Bin 0 -> 219 bytes internal/ui/icons/chevron_down.svg | 3 + internal/ui/icons/chevron_up.png | Bin 0 -> 217 bytes internal/ui/icons/chevron_up.svg | 3 + internal/ui/icons/close.png | Bin 0 -> 256 bytes internal/ui/icons/close.svg | 4 + internal/ui/icons/icons.go | 28 ++- internal/ui/icons/search.png | Bin 0 -> 420 bytes internal/ui/icons/search.svg | 4 + internal/ui/render.go | 2 +- 22 files changed, 1081 insertions(+), 10 deletions(-) create mode 100644 internal/editor/search.go create mode 100644 internal/editor/search_test.go create mode 100644 internal/io/pool/search_test.go create mode 100644 internal/test/e2e/find_test.go create mode 100644 internal/ui/icons/chevron_down.png create mode 100644 internal/ui/icons/chevron_down.svg create mode 100644 internal/ui/icons/chevron_up.png create mode 100644 internal/ui/icons/chevron_up.svg create mode 100644 internal/ui/icons/close.png create mode 100644 internal/ui/icons/close.svg create mode 100644 internal/ui/icons/search.png create mode 100644 internal/ui/icons/search.svg diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 14d365c..6347fb5 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -101,6 +101,11 @@ func run(w *app.Window) error { var searchEditor widget.Editor renderer.RegisterGioEditor("search_bar", &searchEditor) + // The find bar's (in-file search) widget.Editor is main-owned the same + // way; its text is forwarded via FindQueryChan (editor.findSetQuery). + var findEditor widget.Editor + renderer.RegisterGioEditor("find_bar", &findEditor) + // Clipboard plumbing (architecture.md §6.3): the logic goroutine only // REQUESTS clipboard operations through channels (copy/cut write, paste // read); the main goroutine executes the Gio ops during a frame and @@ -209,6 +214,8 @@ func run(w *app.Window) error { // The logic goroutine handles filtering and triggers a new frame. newQuery := searchEditor.Text() sendQuery := newQuery != frame.Query + newFind := findEditor.Text() + sendFind := newFind != frame.FindQuery events := renderer.CheckGestures(e.Source, gtx.Metric) // Keep frames flowing while a long press is pending: a stationary // finger generates no pointer events, so without this the window @@ -305,6 +312,9 @@ func run(w *app.Window) error { if sendQuery { logic.SearchQueryChan() <- newQuery } + if sendFind { + logic.FindQueryChan() <- newFind + } logic.LayoutChan() <- ui.LayoutFeedback{ GlyphLayout: glyphLayout, WindowText: frame.WindowText, diff --git a/doc/architecture.md b/doc/architecture.md index 77223f8..b45ebc3 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -110,6 +110,7 @@ Sole owner of `*State`. `Run()` selects on: | `configChan` | main | `ConfigUpdate` (pixels or scale) | store scale/pixels; recompute layout | | `inputChan` | main | `[]ui.InputEvent` | run each `Handler(evt.Data)` on the owner | | `searchQueryChan` | main | `string` | store `Browser.Query`, re-filter, new frame | +| `findQueryChan` | main | `string` | in-file search: store `Editor.Find.Query`, dispatch `Search` task, new frame | | `openFileChan` | main (tap) | `string` path | open file in editor (chunked buffer + async index) | | `layoutChan` | main | `ui.GlyphLayout` | store on editor state; derive `LastLineY` for scroll clamping | | `retryChan` | logic | `string` filename | autosave retry | @@ -131,7 +132,12 @@ has a real implementation (`io/pool/real`, rooted at `/`) and a mock (`io/pool/mock`) for tests. Task types in active use: `StatFile`, `ReadFile`, `BuildLineIndex`, -`WriteFile` (editor); `BuildIndex`, `LoadPages` (browser). Dormant/dead task +`WriteFile`, `Search` (editor); `BuildIndex`, `LoadPages` (browser). The +`Search` task runs a case-insensitive substring scan over a full-content +snapshot (`FindSubstring`) and returns the match byte ranges plus the query +generation that dispatched it; the logic drops results whose generation no +longer matches (`EditorState.applySearchResult`), so a superseded scan can +never clobber newer results. Dormant/dead task types — `ReadChunk` (no-op for fully-loaded in-range files), `ReadDir`, `StatDir`, `ReadCache`, `WriteCache`, `Invalidate`, `SaveState`, `SaveUndo` — are candidates for removal in a cleanup round. @@ -139,7 +145,7 @@ are candidates for removal in a cleanup round. ## 3. Channel topology summary - **main → logic:** `InputChan`, `ConfigChan`, `LayoutChan`, `SearchQueryChan`, - `OpenFileChan`. + `FindQueryChan`, `OpenFileChan`. - **timer → logic:** `autosaveChan` (token only). - **logic → logic:** `retryChan` (self-piped). - **logic → frameReceiver:** `FrameChan` (the only outbound state carrier). diff --git a/doc/development_plan.md b/doc/development_plan.md index 0739ae3..6824e5f 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -720,7 +720,8 @@ that kills `widget.Editor`. ## 8. Non-goals (v1) - File-system watcher / live browser refresh (re-scan on return to browser). -- Tabs, split view, search-in-file, syntax highlighting, diff/merge. +- Tabs, split view, syntax highlighting, diff/merge. (Search-in-file was + later implemented; see spec §2.2.) - Desktop/other platforms (Android-first; window `390×844` dp). - Rewriting on `widget.Editor` (v1 plan) — kept only as fallback (§12). diff --git a/doc/spec.md b/doc/spec.md index c2d78ec..8553f2b 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -75,6 +75,17 @@ elsewhere. selection is highlighted in the editor and pushed to the IME. (Shift state is tracked by the app, since Gio's Android bridge drops modifier keys — architecture.md §2.1.) +- **In-file search:** a find icon on the top bar (right side) opens a find + bar directly below it, with a text input, an "N / M" counter, next/prev + navigation, and a close button. The query is a plain substring, + case-insensitive, no regex; each change is scanned over the entire + in-memory content by a worker (`Search` task), so typing never blocks the + UI and stale scans are dropped by generation. Next/prev select and scroll + to the surrounding matches (wrapping around); the first discovery selects + and scrolls to the first match. Edits do not invalidate the result set: + match offsets before the edit are unchanged, matches after it shift by the + length delta, and matches overlapping the edited range are dropped + (finding text an edit creates requires re-typing the query). - **Autosave:** every edit restarts a 1 s debounce; on expiry the full content is written to disk by a worker. At most one write per file is in flight at any time; saves requested during a write are deferred and re-issued with @@ -174,7 +185,7 @@ recorded here so future rounds don't mistake doc text for behavior: | Syncthing conflict handling | **not implemented** | No `.sync-conflict-*` file detection or merging. | | File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. | | Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. | -| In-file search, tabs, split view | **not implemented** | — | +| Tabs, split view | **not implemented** | In-file search IS implemented (spec §2.2); only tabs/split remain deferred. | | Files > 50 MB | **not supported** | `TooLarge` state instead. | | Desktop / other platforms | **not supported** | Android-first. | diff --git a/internal/editor/frame.go b/internal/editor/frame.go index a4d1145..45cfb35 100644 --- a/internal/editor/frame.go +++ b/internal/editor/frame.go @@ -28,6 +28,11 @@ type Frame struct { FontScale float32 // user font-size setting the logic bookkeeping used FocusedElementID string Query string + // FindQuery: the in-file search query the logic goroutine has processed + // (mirrors EditorState.Find.Query). Main compares it against the + // "find_bar" widget's text and forwards changes via FindQueryChan — the + // same contract as Query/searchQueryChan. + FindQuery string // WindowStartByte / WindowStartLine / EditSeq: the editor window this // frame's elements describe. The main goroutine forwards them with the // shaped glyph layout (LayoutFeedback) so the logic goroutine can apply @@ -48,6 +53,7 @@ func (l *Logic) frameOf(elems []ui.Element) Frame { FontScale: l.state.fontScale, FocusedElementID: l.state.FocusedElementID, Query: l.state.Browser.Query, + FindQuery: l.state.Editor.Find.Query, WindowStartByte: l.state.Editor.IMEWindowStartByte, WindowStartLine: l.state.WindowStartLine, WindowText: l.state.Editor.IMEWindowText, diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 3cd25e4..3317aec 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -69,6 +69,7 @@ type Logic struct { layoutChan chan ui.LayoutFeedback resultChan chan ResultEvent searchQueryChan chan string + findQueryChan chan string // in-file search text (main-owned "find_bar") openFileChan chan string retryChan chan string // auto-save retries autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner) @@ -127,6 +128,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic { layoutChan: make(chan ui.LayoutFeedback), resultChan: make(chan ResultEvent), searchQueryChan: make(chan string), + findQueryChan: make(chan string), openFileChan: make(chan string), retryChan: make(chan string, 1), // Buffered channel autosaveChan: make(chan struct{}), @@ -173,6 +175,13 @@ func (l *Logic) SearchQueryChan() chan<- string { return l.searchQueryChan } +// FindQueryChan returns the in-file search query channel. The main goroutine +// forwards the "find_bar" widget's text here when it changes (see +// search.go). +func (l *Logic) FindQueryChan() chan<- string { + return l.findQueryChan +} + // ClipboardSetChan returns the channel the logic goroutine uses to request a // system clipboard write; the main goroutine executes the Gio clipboard op. func (l *Logic) ClipboardSetChan() <-chan string { @@ -247,7 +256,13 @@ func (l *Logic) Run() { } } l.emitFrame() + case q := <-l.findQueryChan: + l.state.Editor.findSetQuery(q) + l.emitFrame() case path := <-l.openFileChan: + // Discard find results for the previous file; the query is kept + // (see EditorState.findReset) and re-scanned against the new file. + TheState.Editor.findReset() // Create chunked buffer for virtual scrolling chunkSize := DefaultChunkSize cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "") @@ -579,11 +594,14 @@ func (l *Logic) handleWorkerResult(res pool.Result) { } }) } + } else if res.TaskType == pool.TypeSearch { + if res.Success { + l.state.Editor.applySearchResult(res) + } } l.emitFrame() } - // State returns the current state. func (l *Logic) State() *State { return l.state diff --git a/internal/editor/search.go b/internal/editor/search.go new file mode 100644 index 0000000..1b08a82 --- /dev/null +++ b/internal/editor/search.go @@ -0,0 +1,288 @@ +package editor + +// In-file search (find bar). +// +// Model: +// - FindState is logic-owned (EditorState.Find); only the logic goroutine +// mutates it. +// - The find bar's text field is a MAIN-owned widget.Editor registered as +// "find_bar" (the browser search bar "search_bar" is the precedent, +// architecture.md §1): its text reaches the logic only through +// Logic.findQueryChan. The find icon, counter, and prev/next/close +// buttons are plain elements whose taps run the handlers below on the +// logic goroutine. +// - A query is scanned by a pool SearchTask over a snapshot of the +// FULL in-memory content (in-range files are fully loaded, so the +// snapshot is exact and unsaved edits are searchable). Results carry a +// generation; only the latest generation is applied, so typing fast +// simply supersedes older scans. +// - Edits do NOT invalidate search: matches outside the edited range are +// shifted by the length delta, matches overlapping it are dropped +// (findEdit). The count is therefore "as of the last scan, adjusted for +// edits" — re-typing the query re-scans. + +import ( + "sort" + + "pad/internal/io/pool" + "pad/internal/ui" +) + +// FindState holds the in-file search state for the open file. +type FindState struct { + Visible bool // the find bar is shown + Query string // the query the logic last processed + Matches [][2]int // ascending, non-overlapping byte ranges + Cur int // index into Matches; -1 when none is selected + Gen uint64 // generation of the newest dispatched scan + Scanning bool // a scan for Gen is in flight +} + +// ToggleFind opens the find bar (or closes it when open). It is the tap +// handler of the find icon in the editor top bar. +func ToggleFind(data any) { + e := &TheState.Editor + if e.Find.Visible { + e.findClose() + } else { + e.findShow() + } +} + +// FindNext / FindPrev move to the next/previous match, wrap around, select +// it, and scroll it into view. They are the tap handlers of the find bar's +// chevron buttons. +func FindNext(data any) { findStep(1) } + +func FindPrev(data any) { findStep(-1) } + +// FindClose is the tap handler of the find bar's close button. +func FindClose(data any) { TheState.Editor.findClose() } + +// findShow opens the find bar. The text input is main-owned, so logic takes +// key focus off the editor (FocusedElementID) and, if a previous query has +// no live matches (they are cleared on close and on file open), re-scans it. +func (e *EditorState) findShow() { + e.Find.Visible = true + if e.Filename != "" { + TheState.FocusedElementID = "find_bar" + } + if e.Find.Query != "" && len(e.Find.Matches) == 0 && !e.Find.Scanning { + e.findDispatchScan() + } +} + +// findClose hides the find bar. The query is kept so re-opening shows it +// (the main-owned widget keeps its text); matches are dropped — the next +// show re-scans, so they are never stale. +func (e *EditorState) findClose() { + e.Find.Visible = false + e.Find.Matches = nil + e.Find.Cur = -1 + e.Find.Scanning = false + if TheState.FocusedElementID == "find_bar" { + TheState.FocusedElementID = "editor_text" + } +} + +// findReset discards all find state for the open file (file open, page +// change). The query is kept: the main-owned widget still shows it, and +// keeping it lets the next show() re-scan the NEW file with the old query +// (and a frame with a changed FindQuery re-sends it, re-scanning live). +func (e *EditorState) findReset() { + // Monotonic generation bump (do not zero the counter): a stale + // in-flight scan carrying a HIGH generation must never equal a fresh + // one, or its result would pass the gen gate in applySearchResult. + e.Find = FindState{Query: e.Find.Query, Gen: e.Find.Gen + 1} +} + +// findSetQuery processes a query forwarded from the main-owned "find_bar" +// widget. An empty query clears the results; otherwise a fresh scan of the +// full in-memory content is dispatched on the worker pool. +func (e *EditorState) findSetQuery(q string) { + f := &e.Find + f.Query = q + f.Matches = nil + f.Cur = -1 + if q == "" || e.TooLarge { + f.Scanning = false + return + } + e.findDispatchScan() +} + +// findDispatchScan snapshots the full content and dispatches a SearchTask. +// Must be called on the logic goroutine. +func (e *EditorState) findDispatchScan() { + f := &e.Find + f.Gen++ + f.Scanning = true + if TheLogic == nil || TheLogic.workerPool == nil { // unit tests + return + } + text := e.Buffer + if cb := e.ChunkedBuffer; cb != nil { + if full, err := cb.FullContent(); err == nil { + text = full + } + } + TheLogic.workerPool.Dispatch(pool.NewSearchTask(text, f.Query, f.Gen)) +} + +// applySearchResult applies a completed SearchTask result. Stale generations +// (a newer query superseded this scan) are dropped. Must be called on the +// logic goroutine. +func (e *EditorState) applySearchResult(res pool.Result) { + f := &e.Find + var sd pool.SearchData + switch v := res.Data.(type) { + case pool.SearchData: + sd = v + case *pool.SearchData: + sd = *v + default: + return + } + if sd.Gen != f.Gen || !f.Visible { + return + } + f.Scanning = false + wasEmpty := len(f.Matches) == 0 + prevStart := -1 + if f.Cur >= 0 && f.Cur < len(f.Matches) { + prevStart = f.Matches[f.Cur][0] + } + f.Matches = sd.Matches + f.Cur = -1 + if len(f.Matches) > 0 { + // Keep the previously current match when it survived the + // re-scan; otherwise select the first one. + if prevStart >= 0 { + if i := sort.Search(len(f.Matches), func(i int) bool { + return f.Matches[i][0] >= prevStart + }); i < len(f.Matches) && f.Matches[i][0] == prevStart { + f.Cur = i + } + } + if f.Cur < 0 { + f.Cur = 0 + } + // The first discovery of matches (matches went 0 -> N) selects and + // scrolls the view to the current match; while the user keeps + // typing, results update in place and the view moves only on + // explicit next/prev. + if wasEmpty { + SetSelection(f.Matches[f.Cur][0], f.Matches[f.Cur][1]) + scrollToFindMatch(f.Matches[f.Cur][0]) + } + } +} + +// findStep selects the next (dir>0) or previous (dir<0) match, wrapping +// around. With no current match yet, "next" goes to the first match at or +// after the current selection start/caret (wrapping to the first) and +// "prev" to the last match ending before the selection end (wrapping to the +// last). +func findStep(dir int) { + e := &TheState.Editor + f := &e.Find + n := len(f.Matches) + if n == 0 { + return + } + idx := -1 + if f.Cur >= 0 { + idx = (f.Cur + dir + n) % n + } else { + ref := e.CursorPosition + if selActive() { + if dir > 0 { + ref = e.SelectionStart + } else { + ref = e.SelectionEnd + } + } + if dir > 0 { + for i, m := range f.Matches { + if m[0] >= ref { + idx = i + break + } + } + if idx < 0 { + idx = 0 // wrap to the first + } + } else { + for i := n - 1; i >= 0; i-- { + if f.Matches[i][1] < ref { + idx = i + break + } + } + if idx < 0 { + idx = n - 1 // wrap to the last + } + } + } + m := f.Matches[idx] + f.Cur = idx + SetSelection(m[0], m[1]) + scrollToFindMatch(m[0]) +} + +// scrollToFindMatch scrolls so the visual line containing absByte sits at +// the top of the editor viewport (clamped to [0, MaxScroll]). +func scrollToFindMatch(absByte int) { + cb := TheState.Editor.ChunkedBuffer + if cb == nil || cb.LineIndex == nil { + return + } + li := cb.LineIndex.FindLogicalLineForByteOffset(absByte) + vl := int64(li) + if w := cb.WrapIndex; w != nil { + vl += int64(w.VisualsBefore(li)) + } + lh := float64(EffectiveLineHeight()) + target := ui.Dp(float64(vl) * lh) + if target < 0 { + target = 0 + } + if target > TheState.MaxScroll { + target = TheState.MaxScroll + } + TheState.ScrollOffset = target +} + +// findEdit reports a length-changing edit to the find state: the byte range +// [start, end) of the pre-edit content was replaced by newLen bytes. Matches +// entirely before it are unchanged, matches entirely after it shift by +// newLen-(end-start), and matches overlapping it are dropped (their text no +// longer matches the query — finding text an edit creates requires a +// re-scan, which happens when the query is re-typed). +func (e *EditorState) findEdit(start, end, newLen int) { + f := &e.Find + if len(f.Matches) == 0 { + return + } + delta := newLen - (end - start) + out := make([][2]int, 0, len(f.Matches)) + cur := -1 + for i, m := range f.Matches { + s, mEnd := m[0], m[1] + var kept [2]int + switch { + case mEnd <= start: + kept = m + case s >= end: + kept = [2]int{s + delta, mEnd + delta} + default: + continue // overlaps the edited range: drop + } + if i == f.Cur { + cur = len(out) + } + out = append(out, kept) + } + f.Matches = out + f.Cur = cur +} diff --git a/internal/editor/search_test.go b/internal/editor/search_test.go new file mode 100644 index 0000000..f64b717 --- /dev/null +++ b/internal/editor/search_test.go @@ -0,0 +1,195 @@ +package editor + +import ( + "testing" + + "pad/internal/io/pool" +) + +// findTestState builds a small state: 10 lines of "aa\n" (30 bytes) with a +// find query "a" whose matches were scanned before the test's edits. +func findTestState() { + // NewLogic (used by other tests in this package) leaves the global + // TheLogic pointing at a STOPPED logic; the find code must not dispatch + // through it (findDispatchScan guards on nil). + TheLogic = nil + TheState = NewState() + TheState.Editor.Filename = "t.txt" + content := "aa\naa\naa\naa\naa\naa\naa\naa\naa\naa\n" + TheState.Editor.Buffer = content + TheState.Editor.ChunkedBuffer = nil + e := &TheState.Editor + e.Find = FindState{Visible: true, Query: "a", Cur: -1} + // Every byte except newlines matches "a". + for i := 0; i < len(content); i++ { + if content[i] != '\n' { + e.Find.Matches = append(e.Find.Matches, [2]int{i, i + 1}) + } + } +} + +func TestFindEdit_ShiftAfter(t *testing.T) { + findTestState() + e := &TheState.Editor + // Insert 4 bytes at offset 20: matches at/after 20 shift by 4; the + // current match (pick one after the edit) keeps its identity. + before := e.Find.Matches[len(e.Find.Matches)-1] + e.Find.Cur = len(e.Find.Matches) - 1 + e.findEdit(20, 20, 4) + if got := e.Find.Matches[len(e.Find.Matches)-1]; got != [2]int{before[0] + 4, before[1] + 4} { + t.Fatalf("last match %v, want %v shifted by 4", got, [2]int{before[0] + 4, before[1] + 4}) + } + if e.Find.Cur != len(e.Find.Matches)-1 { + t.Fatalf("cur %d", e.Find.Cur) + } + // A match before the edit is untouched. + if e.Find.Matches[0] != [2]int{0, 1} { + t.Fatalf("first match %v", e.Find.Matches[0]) + } +} + +func TestFindEdit_DropOverlapping(t *testing.T) { + findTestState() + e := &TheState.Editor + e.Find.Cur = 2 // byte 2 + // Replace [2,4) with nothing: matches 2 and 3 (bytes 2,3) overlap and + // are dropped; later matches shift left by 2; the current match is + // dropped, so Cur resets to -1. + e.findEdit(2, 4, 0) + for _, m := range e.Find.Matches { + if m[0] == 2 && m[1] == 4 { + t.Fatalf("stale match %v survived an edit inside it", m) + } + } + if e.Find.Cur != -1 { + t.Fatalf("cur %d, want -1 (current match was edited away)", e.Find.Cur) + } + // The match that was at byte 4 is now at byte 2. + if e.Find.Matches[0][0] != 0 || e.Find.Matches[1][0] != 1 || e.Find.Matches[2][0] != 2 { + t.Fatalf("matches after delete: %v", e.Find.Matches[:5]) + } +} + +func TestFindStep_NextPrevWrap(t *testing.T) { + findTestState() + e := &TheState.Editor + // No current match yet: next from caret 0 -> the match AT 0 (index 0). + e.CursorPosition = 0 + findStep(1) + if e.Find.Cur != 0 { + t.Fatalf("cur %d, want 0", e.Find.Cur) + } + if e.SelectionStart != 0 || e.SelectionEnd != 1 { + t.Fatalf("selection [%d,%d), want [0,1)", e.SelectionStart, e.SelectionEnd) + } + // next -> 1, prev -> 0. + findStep(1) + findStep(-1) + if e.Find.Cur != 0 { + t.Fatalf("cur %d, want 0", e.Find.Cur) + } + // prev from 0 wraps to the LAST match. + findStep(-1) + if e.Find.Cur != len(e.Find.Matches)-1 { + t.Fatalf("cur %d, want last (wrapped)", e.Find.Cur) + } + // next from last wraps to the FIRST. + findStep(1) + if e.Find.Cur != 0 { + t.Fatalf("cur %d, want 0 (wrapped)", e.Find.Cur) + } +} + +func TestFindStep_NoMatches(t *testing.T) { + findTestState() + e := &TheState.Editor + e.Find.Matches = nil + e.CursorPosition = 5 + findStep(1) + if e.Find.Cur != -1 { + t.Fatalf("cur %d, want -1 with no matches", e.Find.Cur) + } +} + +func TestApplySearchResult_GenGate(t *testing.T) { + findTestState() + e := &TheState.Editor + e.Find.Gen = 3 + // Stale generation: dropped, state untouched. + stale := e.Find.Matches + e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 2, Matches: [][2]int{}}}) + if len(e.Find.Matches) != len(stale) { + t.Fatalf("stale result was applied: %d matches", len(e.Find.Matches)) + } + // Current generation: applied; the surviving previous current match + // keeps its position. + e.Find.Cur = 1 + e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 3, Matches: stale}}) + if e.Find.Cur != 1 { + t.Fatalf("cur %d, want 1 (kept)", e.Find.Cur) + } +} + +func TestApplySearchResult_FirstDiscoveryScrolls(t *testing.T) { + findTestState() + e := &TheState.Editor + // No ChunkedBuffer/LineIndex in this unit state: scrollToFindMatch is a + // no-op, so this only pins that first discovery selects match 0. + e.Find.Matches = nil + e.Find.Cur = -1 + e.Find.Scanning = true + e.Find.Gen = 1 + e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{ + Gen: 1, Matches: [][2]int{{10, 11}, {20, 21}}, + }}) + if e.Find.Scanning { + t.Fatal("scanning flag not cleared") + } + if e.Find.Cur != 0 { + t.Fatalf("cur %d, want 0 (first discovery)", e.Find.Cur) + } +} + +func TestFindShowClose_FocusTransitions(t *testing.T) { + findTestState() + TheState.FocusedElementID = "editor_text" + TheState.Editor.Find.Visible = false + TheState.Editor.findShow() + if !TheState.Editor.Find.Visible { + t.Fatal("find bar not shown") + } + if TheState.FocusedElementID != "find_bar" { + t.Fatalf("focus %q, want find_bar", TheState.FocusedElementID) + } + TheState.Editor.findClose() + if TheState.Editor.Find.Visible { + t.Fatal("find bar still visible") + } + if TheState.FocusedElementID != "editor_text" { + t.Fatalf("focus %q, want editor_text", TheState.FocusedElementID) + } + // The query survives close/reopen. + q := TheState.Editor.Find.Query + TheState.Editor.findShow() + if TheState.Editor.Find.Query != q { + t.Fatal("query lost across close/reopen") + } +} + +func TestFindReset_SupersedesInflight(t *testing.T) { + findTestState() + e := &TheState.Editor + e.Find.Gen = 5 + e.findReset() + if e.Find.Visible || len(e.Find.Matches) != 0 { + t.Fatal("findReset did not clear state") + } + if e.Find.Gen != 6 { + t.Fatalf("gen %d, want 6 (in-flight scan must be superseded)", e.Find.Gen) + } + // The in-flight (gen 5) result must now be dropped. + e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 5, Matches: [][2]int{{0, 1}}}}) + if len(e.Find.Matches) != 0 { + t.Fatal("superseded result was applied") + } +} diff --git a/internal/editor/state.go b/internal/editor/state.go index 75641ae..8fbadaf 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -125,7 +125,9 @@ type EditorState struct { SelDragPressY float64 SelDragAnchorX float64 SelDragAnchorY float64 - Filename string + // Find is the in-file search state (find bar); see search.go. + Find FindState + Filename string // TooLarge is set when an opened file exceeds MaxEditableFileSize. The // editor shows a "too large to edit" notice instead of content (the // browser can still list the file). @@ -376,6 +378,7 @@ func GoToBrowser(data any) { if TheLogic != nil { TheLogic.FlushAll() } + TheState.Editor.findClose() TheState.page = BrowserPage } @@ -638,6 +641,7 @@ func deleteRange(start, end int) { str := TheState.Editor.Buffer TheState.Editor.Buffer = str[:start] + str[end:] } + TheState.Editor.findEdit(start, end, 0) } // --- Touch selection (v1) --------------------------------------------------- @@ -1630,6 +1634,7 @@ func HandleDelete() { if w := utf8AdvanceWidth(seg); w > 0 { buf.Delete(pos, w) buf.UpdateLineIndexAfterDelete(pos, pos+w) + e.findEdit(pos, pos+w, 0) markDirty() } return @@ -1643,7 +1648,9 @@ func HandleDelete() { if pos+4 < end { end = pos + 4 } - TheState.Editor.Buffer = str[:pos] + str[pos+utf8AdvanceWidth(str[pos:end]):] + w := utf8AdvanceWidth(str[pos:end]) + TheState.Editor.Buffer = str[:pos] + str[pos+w:] + e.findEdit(pos, pos+w, 0) markDirty() } @@ -1668,6 +1675,7 @@ func HandleInsert(s string) { str := e.Buffer e.Buffer = str[:pos] + s + str[pos:] } + e.findEdit(pos, pos, len(s)) e.CursorPosition = pos + len(s) markDirty() } @@ -1697,6 +1705,7 @@ func HandleBackspace() { buf.Delete(pos-w, w) buf.UpdateLineIndexAfterDelete(pos-w, pos) TheState.Editor.CursorPosition = pos - w + e.findEdit(pos-w, pos, 0) markDirty() return } @@ -1709,6 +1718,7 @@ func HandleBackspace() { w := utf8BackspaceWidth(str[segStart:pos]) TheState.Editor.Buffer = str[:pos-w] + str[pos:] TheState.Editor.CursorPosition = pos - w + e.findEdit(pos-w, pos, 0) markDirty() } @@ -1807,6 +1817,7 @@ func HandleReplaceRange(startRune, endRune int, text string) { TheState.Editor.Buffer = s[:absStart] + text + s[absStart:] newCursor = absStart + len(text) } + TheState.Editor.findEdit(absStart, absEnd, len(text)) TheState.Editor.CursorPosition = newCursor // A commit consumed any selection it overlapped (see the union above). ClearSelection() @@ -1846,6 +1857,54 @@ func markDirty() { } // EditorLayout computes the element tree for the editor page. +// buildFindBar lays out the in-file search bar: [input][counter][prev][next] +// [close], full screen width, directly below the margin-free top bar (the +// bars merge, so this one does too; inner content keeps the margin). The +// input is a MAIN-owned GioEditor registered as "find_bar" (the browser +// "search_bar" precedent, architecture.md §1); the counter and buttons are +// logic-owned elements whose taps run the handlers in search.go. +func buildFindBar(screenWidth ui.Dp) ui.Element { + margin := ui.Dp(10) + gap := ui.Dp(8) + h := ui.Dp(40) + topY := ui.Dp(32) // top bar height (see EditorLayout) + + // Right-hand button column: prev, next, close. + iconW := ui.IconSize + closeX := screenWidth - margin - iconW + nextX := closeX - gap - iconW + prevX := nextX - gap - iconW + counterW := ui.Dp(96) + inputX := margin + inputW := prevX - gap - counterW - margin + + f := TheState.Editor.Find + counter := "0" + switch { + case f.Scanning: + counter = "…" + case f.Query != "" && len(f.Matches) == 0: + counter = "No matches" + case f.Cur >= 0: + counter = fmt.Sprintf("%d / %d", f.Cur+1, len(f.Matches)) + } + + return ui.NewContainer( + ui.Region{X: 0, Y: topY, W: screenWidth, H: h}, + ui.Color{R: 230, G: 230, B: 230, A: 255}, + []ui.Element{ + ui.NewGioEditor("find_bar", ui.Region{X: inputX, Y: ui.Dp(6), W: inputW, H: ui.Dp(28)}), + ui.NewLabel(counter, 12, ui.Region{X: inputX + inputW + gap, Y: ui.Dp(8), W: counterW, H: ui.Dp(20)}, ui.AlignEnd, "", nil), + ui.NewIcon("chevron_up", ui.Region{X: prevX, Y: ui.Dp(8), W: iconW, H: iconW}, 0, + []ui.Interaction{{Gesture: ui.Tap, Handler: FindPrev}}), + ui.NewIcon("chevron_down", ui.Region{X: nextX, Y: ui.Dp(8), W: iconW, H: iconW}, 0, + []ui.Interaction{{Gesture: ui.Tap, Handler: FindNext}}), + ui.NewIcon("close", ui.Region{X: closeX, Y: ui.Dp(8), W: iconW, H: iconW}, 0, + []ui.Interaction{{Gesture: ui.Tap, Handler: FindClose}}), + }, + ) +} + func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { margin := ui.Dp(10) @@ -1870,7 +1929,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { []ui.Element{ ui.NewIcon("back", ui.Region{X: margin, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0, []ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}), - ui.NewLabel(filename, 14, ui.Region{X: ui.Dp(32) + margin, Y: ui.Dp(6), W: statusBarW - ui.Dp(32) - margin, H: ui.Dp(20)}, ui.AlignStart, "", nil), + ui.NewLabel(filename, 14, ui.Region{X: ui.Dp(32) + margin, Y: ui.Dp(6), W: statusBarW - ui.Dp(32) - margin - ui.IconSize - ui.Dp(8), H: ui.Dp(20)}, ui.AlignStart, "", nil), + ui.NewIcon("search", ui.Region{X: screenWidth - margin - ui.IconSize, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0, + []ui.Interaction{{Gesture: ui.Tap, Handler: ToggleFind}}), }, ) @@ -1919,6 +1980,13 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { }, ) + // --- Find bar (in-file search): full width, directly below the top bar, + // while open (see search.go / buildFindBar). --- + var findBar ui.Element + if TheState.Editor.Find.Visible { + findBar = buildFindBar(screenWidth) + } + // --- Editor text area --- editorY := statusBarRegion.Y + statusBarRegion.H editorH := bottomBarRegion.Y - editorY @@ -2137,6 +2205,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { {Gesture: ui.SelDrag, Handler: HandleSelDragEvt}, }, ) + // While the find bar is open, key focus belongs to the main-owned + // "find_bar" widget.Editor: the editor stops its per-frame IME sync, and + // regaining "editor_text" focus re-issues key.FocusCmd on close (see + // TextField.Draw's focus dedup). + if TheState.Editor.Find.Visible { + TheState.FocusedElementID = "find_bar" + } else if TheState.FocusedElementID == "find_bar" { + TheState.FocusedElementID = "editor_text" + } // Set Focused so TextField.Draw() issues key.FocusCmd, which is required // for Gio to deliver key events to this element. ShowIMESeq carries the // keyboard-raise pulse (see TextField.ShowIMESeq). @@ -2146,6 +2223,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { editorElem.CaretDrag = TheState.Editor.CaretDrag elems := []ui.Element{statusBar, editorElem, bottomBar} + if findBar != nil { + elems = append(elems, findBar) + } // The selection menu is added last so it draws on top of the editor. // Re-anchor it to the selection's live screen position every frame so it // follows the text while scrolling (it used to stay where it was first diff --git a/internal/io/pool/search_test.go b/internal/io/pool/search_test.go new file mode 100644 index 0000000..3640ce1 --- /dev/null +++ b/internal/io/pool/search_test.go @@ -0,0 +1,107 @@ +package pool + +import ( + "reflect" + "testing" +) + +func TestFindSubstring_Basic(t *testing.T) { + text := "hello world hello" + got := FindSubstring(text, "hello") + want := [][2]int{{0, 5}, {12, 17}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestFindSubstring_CaseInsensitive(t *testing.T) { + got := FindSubstring("Hello HELLo hellO", "hEllO") + want := [][2]int{{0, 5}, {6, 11}, {12, 17}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestFindSubstring_NonASCIIFold(t *testing.T) { + // Only the ASCII-foldable "STRASSE" matches "strasse" (lower-cased to + // "strasse"); "Straße" lower-cases to "straße", which does not contain it. + // Byte offset 8: "ß" is two UTF-8 bytes, so "Straße" is 7 bytes. + got := FindSubstring("Straße STRASSE", "strasse") + want := [][2]int{{8, 15}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + // And a query containing a multibyte rune still folds its ASCII parts. + got = FindSubstring("Straße", "STRA") + want = [][2]int{{0, 4}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestFindSubstring_NoMatch(t *testing.T) { + if got := FindSubstring("abc", "xyz"); got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestFindSubstring_EmptyQuery(t *testing.T) { + if got := FindSubstring("abc", ""); got != nil { + t.Fatalf("got %v, want nil for empty query", got) + } +} + +func TestFindSubstring_OverlappingConsumed(t *testing.T) { + // "aa" in "aaa": one match; the match consumes its range. + got := FindSubstring("aaa", "aa") + want := [][2]int{{0, 2}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + // "...abaaba": matches at 3 and 6, not at 4. + got = FindSubstring("abaaba", "aba") + want = [][2]int{{0, 3}, {3, 6}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestFindSubstring_Multibyte(t *testing.T) { + // Offsets are BYTE offsets. "é" is 2 bytes. + text := "éxéx" // bytes: 0-1 é, 2 x, 3-4 é, 5 x + got := FindSubstring(text, "xé") + want := [][2]int{{2, 5}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestFindSubstring_WholeText(t *testing.T) { + got := FindSubstring("abc", "ABC") + want := [][2]int{{0, 3}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestSearchTask_Execute(t *testing.T) { + task := NewSearchTask("one two One TWO", "two", 7) + res := task.Execute() + if !res.Success { + t.Fatalf("unexpected error: %v", res.Error) + } + if res.TaskType != TypeSearch { + t.Fatalf("type %v", res.TaskType) + } + sd, ok := res.Data.(SearchData) + if !ok { + t.Fatalf("data type %T", res.Data) + } + if sd.Gen != 7 { + t.Fatalf("gen %d", sd.Gen) + } + want := [][2]int{{4, 7}, {12, 15}} + if !reflect.DeepEqual(sd.Matches, want) { + t.Fatalf("matches %v, want %v", sd.Matches, want) + } +} diff --git a/internal/io/pool/task.go b/internal/io/pool/task.go index 1753f58..6e61a0c 100644 --- a/internal/io/pool/task.go +++ b/internal/io/pool/task.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync/atomic" "time" @@ -71,6 +72,8 @@ const ( // State persistence types TypeSaveState TypeSaveUndo + // In-file search (scans an in-memory content snapshot on a worker) + TypeSearch ) func (t TaskType) String() string { @@ -105,6 +108,8 @@ func (t TaskType) String() string { return "save_state" case TypeSaveUndo: return "save_undo" + case TypeSearch: + return "search" default: return "unknown" } @@ -502,6 +507,94 @@ func (t *ReadFileTask) Context() context.Context { return t.ctx } func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second } func (t *ReadFileTask) Cancel() { if t.cancel != nil { t.cancel() } } +// SearchData is the payload of a completed SearchTask: the byte ranges +// [start, end) of every match of Query in the scanned Text, ascending and +// non-overlapping. Gen is the query generation the scan was dispatched for, +// so the caller can drop stale results. +type SearchData struct { + Gen uint64 + Matches [][2]int +} + +// SearchTask scans an in-memory content snapshot for a plain, case- +// insensitive substring. The caller snapshots the content (the editor's +// in-range files are fully resident) so the scan never races edits or chunk +// state; a superseding query gets a higher Gen and its result is dropped by +// the caller. Matching is plain substring (no regex), case-insensitive via +// unicode lower-casing of both sides, matching the browser's name filter. +type SearchTask struct { + taskID string + Text string + Query string + Gen uint64 + ctx context.Context + cancel context.CancelFunc +} + +// NewSearchTask creates a new SearchTask. +func NewSearchTask(text, query string, gen uint64) *SearchTask { + ctx, cancel := context.WithCancel(context.Background()) + return &SearchTask{ + taskID: fmt.Sprintf("search-%d", taskIDCounter.Add(1)), + Text: text, + Query: query, + Gen: gen, + ctx: ctx, + cancel: cancel, + } +} + +func (t *SearchTask) Execute() Result { + matches := FindSubstring(t.Text, t.Query) + return Result{TaskID: t.taskID, TaskType: TypeSearch, Success: true, Data: SearchData{Gen: t.Gen, Matches: matches}} +} + +// FindSubstring returns the byte ranges [start, end) of every occurrence of +// query in text, case-insensitively. Occurrences are ascending and non- +// overlapping (a match consumes its range, so "aa" finds one match in +// "aaa"). An empty query matches nothing. +func FindSubstring(text, query string) [][2]int { + if query == "" { + return nil + } + q := toLowerFold(query) + t := toLowerFold(text) + var matches [][2]int + from := 0 + for { + i := indexOf(t[from:], q) + if i < 0 { + return matches + } + abs := from + i + matches = append(matches, [2]int{abs, abs + len(q)}) + from = abs + len(q) + } +} + +// toLowerFold lower-cases s without allocating when s is already +// lower-case ASCII (the common case for both query and content). +func toLowerFold(s string) string { + for i := 0; i < len(s); i++ { + if b := s[i]; b >= 'A' && b <= 'Z' { + return strings.ToLower(s) + } + } + return s +} + +// indexOf is strings.Index but kept local so the scan above reads as one +// unit; it is the standard library's two-way search. +func indexOf(s, sub string) int { return strings.Index(s, sub) } + +func (t *SearchTask) Priority() Priority { return MediumPriority } +func (t *SearchTask) TaskType() TaskType { return TypeSearch } +func (t *SearchTask) TaskID() string { return t.taskID } +func (t *SearchTask) DirPath() string { return "" } +func (t *SearchTask) Context() context.Context { return t.ctx } +func (t *SearchTask) Timeout() time.Duration { return 10 * time.Second } +func (t *SearchTask) Cancel() { if t.cancel != nil { t.cancel() } } + // WriteFileTask writes content to a file. type WriteFileTask struct { taskID string diff --git a/internal/test/e2e/find_test.go b/internal/test/e2e/find_test.go new file mode 100644 index 0000000..05b015b --- /dev/null +++ b/internal/test/e2e/find_test.go @@ -0,0 +1,216 @@ +package e2e_test + +import ( + "strings" + "testing" + "time" + + "pad/internal/editor" + "pad/internal/test/e2e" + "pad/internal/ui" +) + +// findContent is the file the in-file search e2e tests open. "needle" +// occurs 3 times, at byte offsets 0, 25, and 50. +const findContent = "needle at start\n" + + "some filler line here\n" + + "needle in the middle\n" + + "another filler line\n" + + "needle at the end" + +// frameHasFindBar reports whether the latest frame carries the find bar +// (the "find_bar" GioEditor inside the find-bar container). +func frameHasFindBar(h *e2e.Harness) bool { + for _, el := range e2e.GetLastFrame(h) { + c, ok := el.(ui.Container) + if !ok { + continue + } + for _, ch := range c.Children { + if ge, ok := ch.(ui.GioEditor); ok && ge.ID() == "find_bar" { + return true + } + } + } + return false +} + +// waitForFindMatches polls the logic state until the find scan has produced +// n matches (the scan runs on the worker pool and the result lands on the +// logic goroutine asynchronously). +func waitForFindMatches(t *testing.T, h *e2e.Harness, n int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + v, err := h.Inspect(func(st *editor.State) any { + return len(st.Editor.Find.Matches) + }) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if v.(int) == n { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %d find matches", n) +} + +func TestRealFile_FindBarAndNavigation(t *testing.T) { + h, _ := realFileHarness(t, "find.txt", findContent) + defer h.Cleanup() + + h.SendConfig(780, 1688) + if _, err := h.WaitForFrame(5 * time.Second); err != nil { + t.Fatalf("wait for frame: %v", err) + } + if ok, _ := h.FileLoaded(); !ok { + t.Fatal("file not loaded") + } + + // Open the find bar via the top-bar icon handler. + h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}}) + if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil { + t.Fatalf("wait for find-bar frame: %v", err) + } + if !frameHasFindBar(h) { + t.Fatal("find bar not in frame after ToggleFind") + } + focus, _ := h.Inspect(func(st *editor.State) any { return st.FocusedElementID }) + if focus.(string) != "find_bar" { + t.Fatalf("focus %q, want find_bar", focus) + } + + // Type the query through the same channel main uses. + h.Logic().FindQueryChan() <- "NEEDLE" // case-insensitive + waitForFindMatches(t, h, 3) + + // The first discovery already selects the first match (Cur=0). + sel, _ := h.Inspect(func(st *editor.State) any { + return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd} + }) + if sel.([2]int) != [2]int{0, 6} { + t.Fatalf("selection after first discovery %v, want [0,6)", sel) + } + if got := findContent[:6]; got != "needle" { + t.Fatalf("test content drift: %q", got) + } + + // Next -> the second match (byte 38); the selection text must be a + // match, whatever the offset. + h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}}) + sel, _ = h.Inspect(func(st *editor.State) any { + return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd} + }) + if want := strings.Index(findContent[6:], "needle") + 6; sel.([2]int) != [2]int{want, want + 6} { + t.Fatalf("selection %v, want [%d,%d)", sel, want, want+6) + } + + // Next -> the third; next again wraps to the FIRST. + h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}}) + h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}}) + sel, _ = h.Inspect(func(st *editor.State) any { + return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd} + }) + if sel.([2]int) != [2]int{0, 6} { + t.Fatalf("selection %v, want [0,6) (wrapped to first)", sel) + } + + // Prev from the first wraps to the LAST. + h.SendInput([]ui.InputEvent{{Handler: editor.FindPrev, Data: ui.Point{}}}) + nMatches, _ := h.Inspect(func(st *editor.State) any { return len(st.Editor.Find.Matches) }) + last := nMatches.(int) - 1 + cur, _ := h.Inspect(func(st *editor.State) any { return st.Editor.Find.Cur }) + if cur.(int) != last { + t.Fatalf("cur %d, want last (wrapped)", cur) + } + + // A query with no hits reports zero matches. + h.Logic().FindQueryChan() <- "no-such-text" + waitForFindMatches(t, h, 0) + + // Close the find bar: it leaves the frame and focus returns to the + // editor. + h.SendInput([]ui.InputEvent{{Handler: editor.FindClose, Data: ui.Point{}}}) + if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil { + t.Fatalf("wait for close frame: %v", err) + } + if frameHasFindBar(h) { + t.Fatal("find bar still in frame after close") + } + focus, _ = h.Inspect(func(st *editor.State) any { return st.FocusedElementID }) + if focus.(string) != "editor_text" { + t.Fatalf("focus %q, want editor_text after close", focus) + } +} + +func TestRealFile_FindMatchesSurviveEdits(t *testing.T) { + h, _ := realFileHarness(t, "find_edit.txt", findContent) + defer h.Cleanup() + + h.SendConfig(780, 1688) + if ok, _ := h.FileLoaded(); !ok { + t.Fatal("file not loaded") + } + + h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}}) + h.Logic().FindQueryChan() <- "needle" + waitForFindMatches(t, h, 3) + + // Insert 5 bytes at the start: ALL matches shift by 5, none is dropped + // (the edit is outside every match), and no re-scan happens. Clear the + // first-discovery selection first: HandleInsert REPLACES an active + // selection, which would eat match 0. + if err := h.WithState(func(st *editor.State) { + editor.ClearSelection() + st.Editor.CursorPosition = 0 + }); err != nil { + t.Fatalf("WithState: %v", err) + } + h.SendInput([]ui.InputEvent{{Handler: func(data any) { editor.HandleInsert(data.(string)) }, Data: "12345"}}) + m, _ := h.Inspect(func(st *editor.State) any { + return st.Editor.Find.Matches + }) + got := m.([][2]int) + want := [][2]int{} + for _, mm := range initialNeedleMatches(t) { + want = append(want, [2]int{mm[0] + 5, mm[1] + 5}) + } + if len(got) != len(want) { + t.Fatalf("matches %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("match[%d] = %v, want %v (shifted by the insert)", i, got[i], want[i]) + } + } + + // An edit INSIDE the first (now shifted) match drops only that match. + // Match 0 is now at [5,11); put the caret in its middle and insert. + if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 7 }); err != nil { + t.Fatalf("WithState: %v", err) + } + h.SendInput([]ui.InputEvent{{Handler: func(data any) { editor.HandleInsert(data.(string)) }, Data: "X"}}) + waitForFindMatches(t, h, 2) + m, _ = h.Inspect(func(st *editor.State) any { return st.Editor.Find.Matches }) + got = m.([][2]int) + // The surviving matches (at 43 and 84 after the +5 shift) sit after the + // edit at byte 7, so they shift by 1 more. + if got[0] != [2]int{44, 50} || got[1] != [2]int{85, 91} { + t.Fatalf("surviving matches %v, want [44,50) and [85,91)", got) + } +} + +// initialNeedleMatches computes the expected byte ranges of "needle" in +// findContent (the pre-edit ground truth). +func initialNeedleMatches(t *testing.T) [][2]int { + t.Helper() + var out [][2]int + for i := 0; i+len("needle") <= len(findContent); i++ { + if strings.EqualFold(findContent[i:i+len("needle")], "needle") { + out = append(out, [2]int{i, i + len("needle")}) + i += len("needle") + } + } + return out +} diff --git a/internal/ui/icons/chevron_down.png b/internal/ui/icons/chevron_down.png new file mode 100644 index 0000000000000000000000000000000000000000..e73fc6b7d52c1f2bc3c95560714a15b0e5b617e6 GIT binary patch literal 219 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUtMV>B>Ar*7pTzBMaQQ&d7n5K99 z>(28!CWbo{wKQ0x{`y2r-17hJvH2GEK*b0Uz|5f}beZ?&>|-x&>$&4E{nggz%zS2N zpZ5M0gRhI+ecpmCD?~3QKiklH__3+DfA@8c8FKf9lGOfrPT2XwIx+2WHq+VT(*^V% ztry69<{9 literal 0 HcmV?d00001 diff --git a/internal/ui/icons/chevron_down.svg b/internal/ui/icons/chevron_down.svg new file mode 100644 index 0000000..9b087b0 --- /dev/null +++ b/internal/ui/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/internal/ui/icons/chevron_up.png b/internal/ui/icons/chevron_up.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd90385698410b95f4389d959afd13ebbcb3292 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUt1)eUBAr*7pTzBL;qQKMo&|v!M zuf_7>#ymT?yfUS;gX#|Y_Ws(p?vZ;JKTtIc+~W)o+-P@o?Na&w2Yg-j)o%0Rkl!bg zB({$wNo1ezzRal~sy0ls`!jRamWS)FEaR?E_n-AauzGuYzfE%V57G48-5&y5ba!R6 z7=#P+b^P;|OBB8R_|n_*Q|!Dj8$fgsa~(6o-EIB6pSa%$g9JQX{an^LB{Ts5a0W~A literal 0 HcmV?d00001 diff --git a/internal/ui/icons/chevron_up.svg b/internal/ui/icons/chevron_up.svg new file mode 100644 index 0000000..cd2608e --- /dev/null +++ b/internal/ui/icons/chevron_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/internal/ui/icons/close.png b/internal/ui/icons/close.png new file mode 100644 index 0000000000000000000000000000000000000000..7c324c8101d3754da3d40311ecd38aacb3b6d399 GIT binary patch literal 256 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpUt#Ar*7p-0Qw{1#Ng@b=d#Wzp$Pz! C&1b6s literal 0 HcmV?d00001 diff --git a/internal/ui/icons/close.svg b/internal/ui/icons/close.svg new file mode 100644 index 0000000..85e02a5 --- /dev/null +++ b/internal/ui/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index c64b918..ff54c29 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -6,7 +6,7 @@ import ( "embed" ) -//go:embed back.svg copy.svg cut.svg paste.svg +//go:embed back.svg chevron_down.svg chevron_up.svg close.svg copy.svg cut.svg paste.svg search.svg var svgFS embed.FS // backSVG contains the back icon SVG source. @@ -15,6 +15,25 @@ var backSVG = ` ` +// chevron_downSVG contains the chevron_down icon SVG source. +var chevron_downSVG = ` + + +` + +// chevron_upSVG contains the chevron_up icon SVG source. +var chevron_upSVG = ` + + +` + +// closeSVG contains the close icon SVG source. +var closeSVG = ` + + + +` + // copySVG contains the copy icon SVG source. var copySVG = ` @@ -40,3 +59,10 @@ var pasteSVG = ` ` + +// searchSVG contains the search icon SVG source. +var searchSVG = ` + + + +` diff --git a/internal/ui/icons/search.png b/internal/ui/icons/search.png new file mode 100644 index 0000000000000000000000000000000000000000..5f66b4a5e6370fa4ba3d32d34d6fbb089b2b64fa GIT binary patch literal 420 zcmV;V0bBlwP)v$-pgo0T^?NUWAc_B{u+d=-$0KLXnCh`ANaJq$I?#!}~p1 zt=spk_r-tu+#3>?0w{n2D8Re`xAJC$a$pUtZ2t)CfrITwX&F%2M9&x6-uh%t?CjbA zjVIwtt+6ptXzNMv+O~DZ3cd&01RkZ+(K}Fg)_l7s?tweo7n9g0`x?|Xo+tVSl;Yq~ zX??me=Q);B=2`R4fDwluWUOyDwh6tlE6kI_Z^ zkR@MAL|x38faSF419$*l<%3i%G~IRr3K=w&E9EIBh#{ByVhRZJ`;bw8tF+0!I9J(O z_rwsJWF{0sArvA$h@E_yDnxPs0 + + + diff --git a/internal/ui/render.go b/internal/ui/render.go index 20bc4ab..f7609b7 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -203,7 +203,7 @@ func (r *Renderer) GioEditor(id string) (*widget.Editor, bool) { // loadIcons loads PNG icons from the embedded filesystem. func (r *Renderer) loadIcons() { - for _, name := range []string{"back", "cut", "copy", "paste"} { + for _, name := range []string{"back", "cut", "copy", "paste", "search", "close", "chevron_up", "chevron_down"} { data, err := iconFS.ReadFile("icons/" + name + ".png") if err != nil { continue