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)
This commit is contained in:
Greg Pomerantz 2026-08-20 00:02:54 -04:00
parent 49b60a79e8
commit 78d240eedb
22 changed files with 1081 additions and 10 deletions

View File

@ -101,6 +101,11 @@ func run(w *app.Window) error {
var searchEditor widget.Editor var searchEditor widget.Editor
renderer.RegisterGioEditor("search_bar", &searchEditor) 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 // Clipboard plumbing (architecture.md §6.3): the logic goroutine only
// REQUESTS clipboard operations through channels (copy/cut write, paste // REQUESTS clipboard operations through channels (copy/cut write, paste
// read); the main goroutine executes the Gio ops during a frame and // 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. // The logic goroutine handles filtering and triggers a new frame.
newQuery := searchEditor.Text() newQuery := searchEditor.Text()
sendQuery := newQuery != frame.Query sendQuery := newQuery != frame.Query
newFind := findEditor.Text()
sendFind := newFind != frame.FindQuery
events := renderer.CheckGestures(e.Source, gtx.Metric) events := renderer.CheckGestures(e.Source, gtx.Metric)
// Keep frames flowing while a long press is pending: a stationary // Keep frames flowing while a long press is pending: a stationary
// finger generates no pointer events, so without this the window // finger generates no pointer events, so without this the window
@ -305,6 +312,9 @@ func run(w *app.Window) error {
if sendQuery { if sendQuery {
logic.SearchQueryChan() <- newQuery logic.SearchQueryChan() <- newQuery
} }
if sendFind {
logic.FindQueryChan() <- newFind
}
logic.LayoutChan() <- ui.LayoutFeedback{ logic.LayoutChan() <- ui.LayoutFeedback{
GlyphLayout: glyphLayout, GlyphLayout: glyphLayout,
WindowText: frame.WindowText, WindowText: frame.WindowText,

View File

@ -110,6 +110,7 @@ Sole owner of `*State`. `Run()` selects on:
| `configChan` | main | `ConfigUpdate` (pixels or scale) | store scale/pixels; recompute layout | | `configChan` | main | `ConfigUpdate` (pixels or scale) | store scale/pixels; recompute layout |
| `inputChan` | main | `[]ui.InputEvent` | run each `Handler(evt.Data)` on the owner | | `inputChan` | main | `[]ui.InputEvent` | run each `Handler(evt.Data)` on the owner |
| `searchQueryChan` | main | `string` | store `Browser.Query`, re-filter, new frame | | `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) | | `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 | | `layoutChan` | main | `ui.GlyphLayout` | store on editor state; derive `LastLineY` for scroll clamping |
| `retryChan` | logic | `string` filename | autosave retry | | `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. (`io/pool/mock`) for tests.
Task types in active use: `StatFile`, `ReadFile`, `BuildLineIndex`, 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`, types — `ReadChunk` (no-op for fully-loaded in-range files), `ReadDir`,
`StatDir`, `ReadCache`, `WriteCache`, `Invalidate`, `SaveState`, `SaveUndo` `StatDir`, `ReadCache`, `WriteCache`, `Invalidate`, `SaveState`, `SaveUndo`
are candidates for removal in a cleanup round. are candidates for removal in a cleanup round.
@ -139,7 +145,7 @@ are candidates for removal in a cleanup round.
## 3. Channel topology summary ## 3. Channel topology summary
- **main → logic:** `InputChan`, `ConfigChan`, `LayoutChan`, `SearchQueryChan`, - **main → logic:** `InputChan`, `ConfigChan`, `LayoutChan`, `SearchQueryChan`,
`OpenFileChan`. `FindQueryChan`, `OpenFileChan`.
- **timer → logic:** `autosaveChan` (token only). - **timer → logic:** `autosaveChan` (token only).
- **logic → logic:** `retryChan` (self-piped). - **logic → logic:** `retryChan` (self-piped).
- **logic → frameReceiver:** `FrameChan` (the only outbound state carrier). - **logic → frameReceiver:** `FrameChan` (the only outbound state carrier).

View File

@ -720,7 +720,8 @@ that kills `widget.Editor`.
## 8. Non-goals (v1) ## 8. Non-goals (v1)
- File-system watcher / live browser refresh (re-scan on return to browser). - 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). - Desktop/other platforms (Android-first; window `390×844` dp).
- Rewriting on `widget.Editor` (v1 plan) — kept only as fallback (§12). - Rewriting on `widget.Editor` (v1 plan) — kept only as fallback (§12).

View File

@ -75,6 +75,17 @@ elsewhere.
selection is highlighted in the editor and pushed to the IME. (Shift state 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 — is tracked by the app, since Gio's Android bridge drops modifier keys —
architecture.md §2.1.) 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 - **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 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 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. | | 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. | | 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. | | 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. | | Files > 50 MB | **not supported** | `TooLarge` state instead. |
| Desktop / other platforms | **not supported** | Android-first. | | Desktop / other platforms | **not supported** | Android-first. |

View File

@ -28,6 +28,11 @@ type Frame struct {
FontScale float32 // user font-size setting the logic bookkeeping used FontScale float32 // user font-size setting the logic bookkeeping used
FocusedElementID string FocusedElementID string
Query 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 // WindowStartByte / WindowStartLine / EditSeq: the editor window this
// frame's elements describe. The main goroutine forwards them with the // frame's elements describe. The main goroutine forwards them with the
// shaped glyph layout (LayoutFeedback) so the logic goroutine can apply // 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, FontScale: l.state.fontScale,
FocusedElementID: l.state.FocusedElementID, FocusedElementID: l.state.FocusedElementID,
Query: l.state.Browser.Query, Query: l.state.Browser.Query,
FindQuery: l.state.Editor.Find.Query,
WindowStartByte: l.state.Editor.IMEWindowStartByte, WindowStartByte: l.state.Editor.IMEWindowStartByte,
WindowStartLine: l.state.WindowStartLine, WindowStartLine: l.state.WindowStartLine,
WindowText: l.state.Editor.IMEWindowText, WindowText: l.state.Editor.IMEWindowText,

View File

@ -69,6 +69,7 @@ type Logic struct {
layoutChan chan ui.LayoutFeedback layoutChan chan ui.LayoutFeedback
resultChan chan ResultEvent resultChan chan ResultEvent
searchQueryChan chan string searchQueryChan chan string
findQueryChan chan string // in-file search text (main-owned "find_bar")
openFileChan chan string openFileChan chan string
retryChan chan string // auto-save retries retryChan chan string // auto-save retries
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner) 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), layoutChan: make(chan ui.LayoutFeedback),
resultChan: make(chan ResultEvent), resultChan: make(chan ResultEvent),
searchQueryChan: make(chan string), searchQueryChan: make(chan string),
findQueryChan: make(chan string),
openFileChan: make(chan string), openFileChan: make(chan string),
retryChan: make(chan string, 1), // Buffered channel retryChan: make(chan string, 1), // Buffered channel
autosaveChan: make(chan struct{}), autosaveChan: make(chan struct{}),
@ -173,6 +175,13 @@ func (l *Logic) SearchQueryChan() chan<- string {
return l.searchQueryChan 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 // ClipboardSetChan returns the channel the logic goroutine uses to request a
// system clipboard write; the main goroutine executes the Gio clipboard op. // system clipboard write; the main goroutine executes the Gio clipboard op.
func (l *Logic) ClipboardSetChan() <-chan string { func (l *Logic) ClipboardSetChan() <-chan string {
@ -247,7 +256,13 @@ func (l *Logic) Run() {
} }
} }
l.emitFrame() l.emitFrame()
case q := <-l.findQueryChan:
l.state.Editor.findSetQuery(q)
l.emitFrame()
case path := <-l.openFileChan: 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 // Create chunked buffer for virtual scrolling
chunkSize := DefaultChunkSize chunkSize := DefaultChunkSize
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "") 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() l.emitFrame()
} }
// State returns the current state. // State returns the current state.
func (l *Logic) State() *State { func (l *Logic) State() *State {
return l.state return l.state

288
internal/editor/search.go Normal file
View File

@ -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
}

View File

@ -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")
}
}

View File

@ -125,7 +125,9 @@ type EditorState struct {
SelDragPressY float64 SelDragPressY float64
SelDragAnchorX float64 SelDragAnchorX float64
SelDragAnchorY 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 // TooLarge is set when an opened file exceeds MaxEditableFileSize. The
// editor shows a "too large to edit" notice instead of content (the // editor shows a "too large to edit" notice instead of content (the
// browser can still list the file). // browser can still list the file).
@ -376,6 +378,7 @@ func GoToBrowser(data any) {
if TheLogic != nil { if TheLogic != nil {
TheLogic.FlushAll() TheLogic.FlushAll()
} }
TheState.Editor.findClose()
TheState.page = BrowserPage TheState.page = BrowserPage
} }
@ -638,6 +641,7 @@ func deleteRange(start, end int) {
str := TheState.Editor.Buffer str := TheState.Editor.Buffer
TheState.Editor.Buffer = str[:start] + str[end:] TheState.Editor.Buffer = str[:start] + str[end:]
} }
TheState.Editor.findEdit(start, end, 0)
} }
// --- Touch selection (v1) --------------------------------------------------- // --- Touch selection (v1) ---------------------------------------------------
@ -1630,6 +1634,7 @@ func HandleDelete() {
if w := utf8AdvanceWidth(seg); w > 0 { if w := utf8AdvanceWidth(seg); w > 0 {
buf.Delete(pos, w) buf.Delete(pos, w)
buf.UpdateLineIndexAfterDelete(pos, pos+w) buf.UpdateLineIndexAfterDelete(pos, pos+w)
e.findEdit(pos, pos+w, 0)
markDirty() markDirty()
} }
return return
@ -1643,7 +1648,9 @@ func HandleDelete() {
if pos+4 < end { if pos+4 < end {
end = pos + 4 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() markDirty()
} }
@ -1668,6 +1675,7 @@ func HandleInsert(s string) {
str := e.Buffer str := e.Buffer
e.Buffer = str[:pos] + s + str[pos:] e.Buffer = str[:pos] + s + str[pos:]
} }
e.findEdit(pos, pos, len(s))
e.CursorPosition = pos + len(s) e.CursorPosition = pos + len(s)
markDirty() markDirty()
} }
@ -1697,6 +1705,7 @@ func HandleBackspace() {
buf.Delete(pos-w, w) buf.Delete(pos-w, w)
buf.UpdateLineIndexAfterDelete(pos-w, pos) buf.UpdateLineIndexAfterDelete(pos-w, pos)
TheState.Editor.CursorPosition = pos - w TheState.Editor.CursorPosition = pos - w
e.findEdit(pos-w, pos, 0)
markDirty() markDirty()
return return
} }
@ -1709,6 +1718,7 @@ func HandleBackspace() {
w := utf8BackspaceWidth(str[segStart:pos]) w := utf8BackspaceWidth(str[segStart:pos])
TheState.Editor.Buffer = str[:pos-w] + str[pos:] TheState.Editor.Buffer = str[:pos-w] + str[pos:]
TheState.Editor.CursorPosition = pos - w TheState.Editor.CursorPosition = pos - w
e.findEdit(pos-w, pos, 0)
markDirty() markDirty()
} }
@ -1807,6 +1817,7 @@ func HandleReplaceRange(startRune, endRune int, text string) {
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:] TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
newCursor = absStart + len(text) newCursor = absStart + len(text)
} }
TheState.Editor.findEdit(absStart, absEnd, len(text))
TheState.Editor.CursorPosition = newCursor TheState.Editor.CursorPosition = newCursor
// A commit consumed any selection it overlapped (see the union above). // A commit consumed any selection it overlapped (see the union above).
ClearSelection() ClearSelection()
@ -1846,6 +1857,54 @@ func markDirty() {
} }
// EditorLayout computes the element tree for the editor page. // 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 { func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
margin := ui.Dp(10) margin := ui.Dp(10)
@ -1870,7 +1929,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
[]ui.Element{ []ui.Element{
ui.NewIcon("back", ui.Region{X: margin, Y: ui.Dp(4), W: ui.IconSize, H: ui.IconSize}, 0, 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.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 --- // --- Editor text area ---
editorY := statusBarRegion.Y + statusBarRegion.H editorY := statusBarRegion.Y + statusBarRegion.H
editorH := bottomBarRegion.Y - editorY editorH := bottomBarRegion.Y - editorY
@ -2137,6 +2205,15 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt}, {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 // Set Focused so TextField.Draw() issues key.FocusCmd, which is required
// for Gio to deliver key events to this element. ShowIMESeq carries the // for Gio to deliver key events to this element. ShowIMESeq carries the
// keyboard-raise pulse (see TextField.ShowIMESeq). // 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 editorElem.CaretDrag = TheState.Editor.CaretDrag
elems := []ui.Element{statusBar, editorElem, bottomBar} 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. // 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 // 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 // follows the text while scrolling (it used to stay where it was first

View File

@ -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)
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -71,6 +72,8 @@ const (
// State persistence types // State persistence types
TypeSaveState TypeSaveState
TypeSaveUndo TypeSaveUndo
// In-file search (scans an in-memory content snapshot on a worker)
TypeSearch
) )
func (t TaskType) String() string { func (t TaskType) String() string {
@ -105,6 +108,8 @@ func (t TaskType) String() string {
return "save_state" return "save_state"
case TypeSaveUndo: case TypeSaveUndo:
return "save_undo" return "save_undo"
case TypeSearch:
return "search"
default: default:
return "unknown" 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) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadFileTask) Cancel() { if t.cancel != nil { t.cancel() } } 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. // WriteFileTask writes content to a file.
type WriteFileTask struct { type WriteFileTask struct {
taskID string taskID string

View File

@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 15 12 9 18 15"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

BIN
internal/ui/icons/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" y1="6" x2="18" y2="18"/>
<line x1="18" y1="6" x2="6" y2="18"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -6,7 +6,7 @@ import (
"embed" "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 var svgFS embed.FS
// backSVG contains the back icon SVG source. // backSVG contains the back icon SVG source.
@ -15,6 +15,25 @@ var backSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width
</svg> </svg>
` `
// chevron_downSVG contains the chevron_down icon SVG source.
var chevron_downSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
`
// chevron_upSVG contains the chevron_up icon SVG source.
var chevron_upSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 15 12 9 18 15"/>
</svg>
`
// closeSVG contains the close icon SVG source.
var closeSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" y1="6" x2="18" y2="18"/>
<line x1="18" y1="6" x2="6" y2="18"/>
</svg>
`
// copySVG contains the copy icon SVG source. // copySVG contains the copy icon SVG source.
var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"> var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<rect x="7" y="7" width="12" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/> <rect x="7" y="7" width="12" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
@ -40,3 +59,10 @@ var pasteSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" widt
<line x1="8" y1="14" x2="16" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> <line x1="8" y1="14" x2="16" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg> </svg>
` `
// searchSVG contains the search icon SVG source.
var searchSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"/>
<line x1="16.5" y1="16.5" x2="21" y2="21"/>
</svg>
`

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7"/>
<line x1="16.5" y1="16.5" x2="21" y2="21"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -203,7 +203,7 @@ func (r *Renderer) GioEditor(id string) (*widget.Editor, bool) {
// loadIcons loads PNG icons from the embedded filesystem. // loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() { 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") data, err := iconFS.ReadFile("icons/" + name + ".png")
if err != nil { if err != nil {
continue continue