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)
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
13
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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
288
internal/editor/search.go
Normal 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
|
||||
}
|
||||
195
internal/editor/search_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
107
internal/io/pool/search_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
216
internal/test/e2e/find_test.go
Normal 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
|
||||
}
|
||||
BIN
internal/ui/icons/chevron_down.png
Normal file
|
After Width: | Height: | Size: 219 B |
3
internal/ui/icons/chevron_down.svg
Normal 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 |
BIN
internal/ui/icons/chevron_up.png
Normal file
|
After Width: | Height: | Size: 217 B |
3
internal/ui/icons/chevron_up.svg
Normal 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
|
After Width: | Height: | Size: 256 B |
4
internal/ui/icons/close.svg
Normal 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 |
|
|
@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width
|
|||
</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.
|
||||
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"/>
|
||||
|
|
@ -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"/>
|
||||
</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>
|
||||
`
|
||||
|
|
|
|||
BIN
internal/ui/icons/search.png
Normal file
|
After Width: | Height: | Size: 420 B |
4
internal/ui/icons/search.svg
Normal 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 |
|
|
@ -203,7 +203,7 @@ func (r *Renderer) GioEditor(id string) (*widget.Editor, bool) {
|
|||
|
||||
// loadIcons loads PNG icons from the embedded filesystem.
|
||||
func (r *Renderer) loadIcons() {
|
||||
for _, name := range []string{"back", "cut", "copy", "paste"} {
|
||||
for _, name := range []string{"back", "cut", "copy", "paste", "search", "close", "chevron_up", "chevron_down"} {
|
||||
data, err := iconFS.ReadFile("icons/" + name + ".png")
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
|
|||