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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
13
doc/spec.md
|
|
@ -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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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,6 +125,8 @@ type EditorState struct {
|
||||||
SelDragPressY float64
|
SelDragPressY float64
|
||||||
SelDragAnchorX float64
|
SelDragAnchorX float64
|
||||||
SelDragAnchorY float64
|
SelDragAnchorY float64
|
||||||
|
// Find is the in-file search state (find bar); see search.go.
|
||||||
|
Find FindState
|
||||||
Filename string
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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"
|
"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
|
||||||
|
|
|
||||||
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"
|
"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>
|
||||||
|
`
|
||||||
|
|
|
||||||
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.
|
// 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
|
||||||
|
|
|
||||||