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 0000000..e73fc6b
Binary files /dev/null and b/internal/ui/icons/chevron_down.png differ
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 0000000..fbd9038
Binary files /dev/null and b/internal/ui/icons/chevron_up.png differ
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 0000000..7c324c8
Binary files /dev/null and b/internal/ui/icons/close.png differ
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 = `