Add in-file search (find bar)

- pool: SearchTask + FindSubstring (case-insensitive substring scan with
  query generation for stale-result dropping)
- editor: FindState on EditorState; find bar handlers (ToggleFind/FindNext/
  FindPrev/FindClose); worker-pool scan of the in-memory content; edit-aware
  offset remapping (shift after, drop overlapping); next/prev with
  wrap-around, selection + scroll to match; main-owned find_bar input
  forwarded via FindQueryChan (browser search_bar precedent)
- ui: find-bar layout below the margin-free top bar; search icon on the top
  bar right; new search/close/chevron_up/chevron_down icons
- logic: findQueryChan, TypeSearch result handling, findReset on open
- frame: FindQuery field (main syncs the widget text like Query)
- tests: pool scan tests, find-state unit tests (remap, nav, gen gate,
  focus), e2e find bar + navigation + edit-survival on real files
- docs: spec/architecture/development_plan updated (search implemented,
  channel/task tables)
This commit is contained in:
Greg Pomerantz 2026-08-20 00:02:54 -04:00
parent 49b60a79e8
commit 78d240eedb
22 changed files with 1081 additions and 10 deletions

View File

@ -101,6 +101,11 @@ func run(w *app.Window) error {
var searchEditor widget.Editor
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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View File

@ -0,0 +1,288 @@
package editor
// In-file search (find bar).
//
// Model:
// - FindState is logic-owned (EditorState.Find); only the logic goroutine
// mutates it.
// - The find bar's text field is a MAIN-owned widget.Editor registered as
// "find_bar" (the browser search bar "search_bar" is the precedent,
// architecture.md §1): its text reaches the logic only through
// Logic.findQueryChan. The find icon, counter, and prev/next/close
// buttons are plain elements whose taps run the handlers below on the
// logic goroutine.
// - A query is scanned by a pool SearchTask over a snapshot of the
// FULL in-memory content (in-range files are fully loaded, so the
// snapshot is exact and unsaved edits are searchable). Results carry a
// generation; only the latest generation is applied, so typing fast
// simply supersedes older scans.
// - Edits do NOT invalidate search: matches outside the edited range are
// shifted by the length delta, matches overlapping it are dropped
// (findEdit). The count is therefore "as of the last scan, adjusted for
// edits" — re-typing the query re-scans.
import (
"sort"
"pad/internal/io/pool"
"pad/internal/ui"
)
// FindState holds the in-file search state for the open file.
type FindState struct {
Visible bool // the find bar is shown
Query string // the query the logic last processed
Matches [][2]int // ascending, non-overlapping byte ranges
Cur int // index into Matches; -1 when none is selected
Gen uint64 // generation of the newest dispatched scan
Scanning bool // a scan for Gen is in flight
}
// ToggleFind opens the find bar (or closes it when open). It is the tap
// handler of the find icon in the editor top bar.
func ToggleFind(data any) {
e := &TheState.Editor
if e.Find.Visible {
e.findClose()
} else {
e.findShow()
}
}
// FindNext / FindPrev move to the next/previous match, wrap around, select
// it, and scroll it into view. They are the tap handlers of the find bar's
// chevron buttons.
func FindNext(data any) { findStep(1) }
func FindPrev(data any) { findStep(-1) }
// FindClose is the tap handler of the find bar's close button.
func FindClose(data any) { TheState.Editor.findClose() }
// findShow opens the find bar. The text input is main-owned, so logic takes
// key focus off the editor (FocusedElementID) and, if a previous query has
// no live matches (they are cleared on close and on file open), re-scans it.
func (e *EditorState) findShow() {
e.Find.Visible = true
if e.Filename != "" {
TheState.FocusedElementID = "find_bar"
}
if e.Find.Query != "" && len(e.Find.Matches) == 0 && !e.Find.Scanning {
e.findDispatchScan()
}
}
// findClose hides the find bar. The query is kept so re-opening shows it
// (the main-owned widget keeps its text); matches are dropped — the next
// show re-scans, so they are never stale.
func (e *EditorState) findClose() {
e.Find.Visible = false
e.Find.Matches = nil
e.Find.Cur = -1
e.Find.Scanning = false
if TheState.FocusedElementID == "find_bar" {
TheState.FocusedElementID = "editor_text"
}
}
// findReset discards all find state for the open file (file open, page
// change). The query is kept: the main-owned widget still shows it, and
// keeping it lets the next show() re-scan the NEW file with the old query
// (and a frame with a changed FindQuery re-sends it, re-scanning live).
func (e *EditorState) findReset() {
// Monotonic generation bump (do not zero the counter): a stale
// in-flight scan carrying a HIGH generation must never equal a fresh
// one, or its result would pass the gen gate in applySearchResult.
e.Find = FindState{Query: e.Find.Query, Gen: e.Find.Gen + 1}
}
// findSetQuery processes a query forwarded from the main-owned "find_bar"
// widget. An empty query clears the results; otherwise a fresh scan of the
// full in-memory content is dispatched on the worker pool.
func (e *EditorState) findSetQuery(q string) {
f := &e.Find
f.Query = q
f.Matches = nil
f.Cur = -1
if q == "" || e.TooLarge {
f.Scanning = false
return
}
e.findDispatchScan()
}
// findDispatchScan snapshots the full content and dispatches a SearchTask.
// Must be called on the logic goroutine.
func (e *EditorState) findDispatchScan() {
f := &e.Find
f.Gen++
f.Scanning = true
if TheLogic == nil || TheLogic.workerPool == nil { // unit tests
return
}
text := e.Buffer
if cb := e.ChunkedBuffer; cb != nil {
if full, err := cb.FullContent(); err == nil {
text = full
}
}
TheLogic.workerPool.Dispatch(pool.NewSearchTask(text, f.Query, f.Gen))
}
// applySearchResult applies a completed SearchTask result. Stale generations
// (a newer query superseded this scan) are dropped. Must be called on the
// logic goroutine.
func (e *EditorState) applySearchResult(res pool.Result) {
f := &e.Find
var sd pool.SearchData
switch v := res.Data.(type) {
case pool.SearchData:
sd = v
case *pool.SearchData:
sd = *v
default:
return
}
if sd.Gen != f.Gen || !f.Visible {
return
}
f.Scanning = false
wasEmpty := len(f.Matches) == 0
prevStart := -1
if f.Cur >= 0 && f.Cur < len(f.Matches) {
prevStart = f.Matches[f.Cur][0]
}
f.Matches = sd.Matches
f.Cur = -1
if len(f.Matches) > 0 {
// Keep the previously current match when it survived the
// re-scan; otherwise select the first one.
if prevStart >= 0 {
if i := sort.Search(len(f.Matches), func(i int) bool {
return f.Matches[i][0] >= prevStart
}); i < len(f.Matches) && f.Matches[i][0] == prevStart {
f.Cur = i
}
}
if f.Cur < 0 {
f.Cur = 0
}
// The first discovery of matches (matches went 0 -> N) selects and
// scrolls the view to the current match; while the user keeps
// typing, results update in place and the view moves only on
// explicit next/prev.
if wasEmpty {
SetSelection(f.Matches[f.Cur][0], f.Matches[f.Cur][1])
scrollToFindMatch(f.Matches[f.Cur][0])
}
}
}
// findStep selects the next (dir>0) or previous (dir<0) match, wrapping
// around. With no current match yet, "next" goes to the first match at or
// after the current selection start/caret (wrapping to the first) and
// "prev" to the last match ending before the selection end (wrapping to the
// last).
func findStep(dir int) {
e := &TheState.Editor
f := &e.Find
n := len(f.Matches)
if n == 0 {
return
}
idx := -1
if f.Cur >= 0 {
idx = (f.Cur + dir + n) % n
} else {
ref := e.CursorPosition
if selActive() {
if dir > 0 {
ref = e.SelectionStart
} else {
ref = e.SelectionEnd
}
}
if dir > 0 {
for i, m := range f.Matches {
if m[0] >= ref {
idx = i
break
}
}
if idx < 0 {
idx = 0 // wrap to the first
}
} else {
for i := n - 1; i >= 0; i-- {
if f.Matches[i][1] < ref {
idx = i
break
}
}
if idx < 0 {
idx = n - 1 // wrap to the last
}
}
}
m := f.Matches[idx]
f.Cur = idx
SetSelection(m[0], m[1])
scrollToFindMatch(m[0])
}
// scrollToFindMatch scrolls so the visual line containing absByte sits at
// the top of the editor viewport (clamped to [0, MaxScroll]).
func scrollToFindMatch(absByte int) {
cb := TheState.Editor.ChunkedBuffer
if cb == nil || cb.LineIndex == nil {
return
}
li := cb.LineIndex.FindLogicalLineForByteOffset(absByte)
vl := int64(li)
if w := cb.WrapIndex; w != nil {
vl += int64(w.VisualsBefore(li))
}
lh := float64(EffectiveLineHeight())
target := ui.Dp(float64(vl) * lh)
if target < 0 {
target = 0
}
if target > TheState.MaxScroll {
target = TheState.MaxScroll
}
TheState.ScrollOffset = target
}
// findEdit reports a length-changing edit to the find state: the byte range
// [start, end) of the pre-edit content was replaced by newLen bytes. Matches
// entirely before it are unchanged, matches entirely after it shift by
// newLen-(end-start), and matches overlapping it are dropped (their text no
// longer matches the query — finding text an edit creates requires a
// re-scan, which happens when the query is re-typed).
func (e *EditorState) findEdit(start, end, newLen int) {
f := &e.Find
if len(f.Matches) == 0 {
return
}
delta := newLen - (end - start)
out := make([][2]int, 0, len(f.Matches))
cur := -1
for i, m := range f.Matches {
s, mEnd := m[0], m[1]
var kept [2]int
switch {
case mEnd <= start:
kept = m
case s >= end:
kept = [2]int{s + delta, mEnd + delta}
default:
continue // overlaps the edited range: drop
}
if i == f.Cur {
cur = len(out)
}
out = append(out, kept)
}
f.Matches = out
f.Cur = cur
}

View File

@ -0,0 +1,195 @@
package editor
import (
"testing"
"pad/internal/io/pool"
)
// findTestState builds a small state: 10 lines of "aa\n" (30 bytes) with a
// find query "a" whose matches were scanned before the test's edits.
func findTestState() {
// NewLogic (used by other tests in this package) leaves the global
// TheLogic pointing at a STOPPED logic; the find code must not dispatch
// through it (findDispatchScan guards on nil).
TheLogic = nil
TheState = NewState()
TheState.Editor.Filename = "t.txt"
content := "aa\naa\naa\naa\naa\naa\naa\naa\naa\naa\n"
TheState.Editor.Buffer = content
TheState.Editor.ChunkedBuffer = nil
e := &TheState.Editor
e.Find = FindState{Visible: true, Query: "a", Cur: -1}
// Every byte except newlines matches "a".
for i := 0; i < len(content); i++ {
if content[i] != '\n' {
e.Find.Matches = append(e.Find.Matches, [2]int{i, i + 1})
}
}
}
func TestFindEdit_ShiftAfter(t *testing.T) {
findTestState()
e := &TheState.Editor
// Insert 4 bytes at offset 20: matches at/after 20 shift by 4; the
// current match (pick one after the edit) keeps its identity.
before := e.Find.Matches[len(e.Find.Matches)-1]
e.Find.Cur = len(e.Find.Matches) - 1
e.findEdit(20, 20, 4)
if got := e.Find.Matches[len(e.Find.Matches)-1]; got != [2]int{before[0] + 4, before[1] + 4} {
t.Fatalf("last match %v, want %v shifted by 4", got, [2]int{before[0] + 4, before[1] + 4})
}
if e.Find.Cur != len(e.Find.Matches)-1 {
t.Fatalf("cur %d", e.Find.Cur)
}
// A match before the edit is untouched.
if e.Find.Matches[0] != [2]int{0, 1} {
t.Fatalf("first match %v", e.Find.Matches[0])
}
}
func TestFindEdit_DropOverlapping(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Cur = 2 // byte 2
// Replace [2,4) with nothing: matches 2 and 3 (bytes 2,3) overlap and
// are dropped; later matches shift left by 2; the current match is
// dropped, so Cur resets to -1.
e.findEdit(2, 4, 0)
for _, m := range e.Find.Matches {
if m[0] == 2 && m[1] == 4 {
t.Fatalf("stale match %v survived an edit inside it", m)
}
}
if e.Find.Cur != -1 {
t.Fatalf("cur %d, want -1 (current match was edited away)", e.Find.Cur)
}
// The match that was at byte 4 is now at byte 2.
if e.Find.Matches[0][0] != 0 || e.Find.Matches[1][0] != 1 || e.Find.Matches[2][0] != 2 {
t.Fatalf("matches after delete: %v", e.Find.Matches[:5])
}
}
func TestFindStep_NextPrevWrap(t *testing.T) {
findTestState()
e := &TheState.Editor
// No current match yet: next from caret 0 -> the match AT 0 (index 0).
e.CursorPosition = 0
findStep(1)
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0", e.Find.Cur)
}
if e.SelectionStart != 0 || e.SelectionEnd != 1 {
t.Fatalf("selection [%d,%d), want [0,1)", e.SelectionStart, e.SelectionEnd)
}
// next -> 1, prev -> 0.
findStep(1)
findStep(-1)
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0", e.Find.Cur)
}
// prev from 0 wraps to the LAST match.
findStep(-1)
if e.Find.Cur != len(e.Find.Matches)-1 {
t.Fatalf("cur %d, want last (wrapped)", e.Find.Cur)
}
// next from last wraps to the FIRST.
findStep(1)
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0 (wrapped)", e.Find.Cur)
}
}
func TestFindStep_NoMatches(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Matches = nil
e.CursorPosition = 5
findStep(1)
if e.Find.Cur != -1 {
t.Fatalf("cur %d, want -1 with no matches", e.Find.Cur)
}
}
func TestApplySearchResult_GenGate(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Gen = 3
// Stale generation: dropped, state untouched.
stale := e.Find.Matches
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 2, Matches: [][2]int{}}})
if len(e.Find.Matches) != len(stale) {
t.Fatalf("stale result was applied: %d matches", len(e.Find.Matches))
}
// Current generation: applied; the surviving previous current match
// keeps its position.
e.Find.Cur = 1
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 3, Matches: stale}})
if e.Find.Cur != 1 {
t.Fatalf("cur %d, want 1 (kept)", e.Find.Cur)
}
}
func TestApplySearchResult_FirstDiscoveryScrolls(t *testing.T) {
findTestState()
e := &TheState.Editor
// No ChunkedBuffer/LineIndex in this unit state: scrollToFindMatch is a
// no-op, so this only pins that first discovery selects match 0.
e.Find.Matches = nil
e.Find.Cur = -1
e.Find.Scanning = true
e.Find.Gen = 1
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{
Gen: 1, Matches: [][2]int{{10, 11}, {20, 21}},
}})
if e.Find.Scanning {
t.Fatal("scanning flag not cleared")
}
if e.Find.Cur != 0 {
t.Fatalf("cur %d, want 0 (first discovery)", e.Find.Cur)
}
}
func TestFindShowClose_FocusTransitions(t *testing.T) {
findTestState()
TheState.FocusedElementID = "editor_text"
TheState.Editor.Find.Visible = false
TheState.Editor.findShow()
if !TheState.Editor.Find.Visible {
t.Fatal("find bar not shown")
}
if TheState.FocusedElementID != "find_bar" {
t.Fatalf("focus %q, want find_bar", TheState.FocusedElementID)
}
TheState.Editor.findClose()
if TheState.Editor.Find.Visible {
t.Fatal("find bar still visible")
}
if TheState.FocusedElementID != "editor_text" {
t.Fatalf("focus %q, want editor_text", TheState.FocusedElementID)
}
// The query survives close/reopen.
q := TheState.Editor.Find.Query
TheState.Editor.findShow()
if TheState.Editor.Find.Query != q {
t.Fatal("query lost across close/reopen")
}
}
func TestFindReset_SupersedesInflight(t *testing.T) {
findTestState()
e := &TheState.Editor
e.Find.Gen = 5
e.findReset()
if e.Find.Visible || len(e.Find.Matches) != 0 {
t.Fatal("findReset did not clear state")
}
if e.Find.Gen != 6 {
t.Fatalf("gen %d, want 6 (in-flight scan must be superseded)", e.Find.Gen)
}
// The in-flight (gen 5) result must now be dropped.
e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: 5, Matches: [][2]int{{0, 1}}}})
if len(e.Find.Matches) != 0 {
t.Fatal("superseded result was applied")
}
}

View File

@ -125,7 +125,9 @@ type EditorState struct {
SelDragPressY float64
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

View File

@ -0,0 +1,107 @@
package pool
import (
"reflect"
"testing"
)
func TestFindSubstring_Basic(t *testing.T) {
text := "hello world hello"
got := FindSubstring(text, "hello")
want := [][2]int{{0, 5}, {12, 17}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestFindSubstring_CaseInsensitive(t *testing.T) {
got := FindSubstring("Hello HELLo hellO", "hEllO")
want := [][2]int{{0, 5}, {6, 11}, {12, 17}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestFindSubstring_NonASCIIFold(t *testing.T) {
// Only the ASCII-foldable "STRASSE" matches "strasse" (lower-cased to
// "strasse"); "Straße" lower-cases to "straße", which does not contain it.
// Byte offset 8: "ß" is two UTF-8 bytes, so "Straße" is 7 bytes.
got := FindSubstring("Straße STRASSE", "strasse")
want := [][2]int{{8, 15}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
// And a query containing a multibyte rune still folds its ASCII parts.
got = FindSubstring("Straße", "STRA")
want = [][2]int{{0, 4}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestFindSubstring_NoMatch(t *testing.T) {
if got := FindSubstring("abc", "xyz"); got != nil {
t.Fatalf("got %v, want nil", got)
}
}
func TestFindSubstring_EmptyQuery(t *testing.T) {
if got := FindSubstring("abc", ""); got != nil {
t.Fatalf("got %v, want nil for empty query", got)
}
}
func TestFindSubstring_OverlappingConsumed(t *testing.T) {
// "aa" in "aaa": one match; the match consumes its range.
got := FindSubstring("aaa", "aa")
want := [][2]int{{0, 2}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
// "...abaaba": matches at 3 and 6, not at 4.
got = FindSubstring("abaaba", "aba")
want = [][2]int{{0, 3}, {3, 6}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestFindSubstring_Multibyte(t *testing.T) {
// Offsets are BYTE offsets. "é" is 2 bytes.
text := "éxéx" // bytes: 0-1 é, 2 x, 3-4 é, 5 x
got := FindSubstring(text, "xé")
want := [][2]int{{2, 5}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestFindSubstring_WholeText(t *testing.T) {
got := FindSubstring("abc", "ABC")
want := [][2]int{{0, 3}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestSearchTask_Execute(t *testing.T) {
task := NewSearchTask("one two One TWO", "two", 7)
res := task.Execute()
if !res.Success {
t.Fatalf("unexpected error: %v", res.Error)
}
if res.TaskType != TypeSearch {
t.Fatalf("type %v", res.TaskType)
}
sd, ok := res.Data.(SearchData)
if !ok {
t.Fatalf("data type %T", res.Data)
}
if sd.Gen != 7 {
t.Fatalf("gen %d", sd.Gen)
}
want := [][2]int{{4, 7}, {12, 15}}
if !reflect.DeepEqual(sd.Matches, want) {
t.Fatalf("matches %v, want %v", sd.Matches, want)
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"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

View File

@ -0,0 +1,216 @@
package e2e_test
import (
"strings"
"testing"
"time"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// findContent is the file the in-file search e2e tests open. "needle"
// occurs 3 times, at byte offsets 0, 25, and 50.
const findContent = "needle at start\n" +
"some filler line here\n" +
"needle in the middle\n" +
"another filler line\n" +
"needle at the end"
// frameHasFindBar reports whether the latest frame carries the find bar
// (the "find_bar" GioEditor inside the find-bar container).
func frameHasFindBar(h *e2e.Harness) bool {
for _, el := range e2e.GetLastFrame(h) {
c, ok := el.(ui.Container)
if !ok {
continue
}
for _, ch := range c.Children {
if ge, ok := ch.(ui.GioEditor); ok && ge.ID() == "find_bar" {
return true
}
}
}
return false
}
// waitForFindMatches polls the logic state until the find scan has produced
// n matches (the scan runs on the worker pool and the result lands on the
// logic goroutine asynchronously).
func waitForFindMatches(t *testing.T, h *e2e.Harness, n int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
v, err := h.Inspect(func(st *editor.State) any {
return len(st.Editor.Find.Matches)
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(int) == n {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d find matches", n)
}
func TestRealFile_FindBarAndNavigation(t *testing.T) {
h, _ := realFileHarness(t, "find.txt", findContent)
defer h.Cleanup()
h.SendConfig(780, 1688)
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
t.Fatalf("wait for frame: %v", err)
}
if ok, _ := h.FileLoaded(); !ok {
t.Fatal("file not loaded")
}
// Open the find bar via the top-bar icon handler.
h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}})
if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil {
t.Fatalf("wait for find-bar frame: %v", err)
}
if !frameHasFindBar(h) {
t.Fatal("find bar not in frame after ToggleFind")
}
focus, _ := h.Inspect(func(st *editor.State) any { return st.FocusedElementID })
if focus.(string) != "find_bar" {
t.Fatalf("focus %q, want find_bar", focus)
}
// Type the query through the same channel main uses.
h.Logic().FindQueryChan() <- "NEEDLE" // case-insensitive
waitForFindMatches(t, h, 3)
// The first discovery already selects the first match (Cur=0).
sel, _ := h.Inspect(func(st *editor.State) any {
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
})
if sel.([2]int) != [2]int{0, 6} {
t.Fatalf("selection after first discovery %v, want [0,6)", sel)
}
if got := findContent[:6]; got != "needle" {
t.Fatalf("test content drift: %q", got)
}
// Next -> the second match (byte 38); the selection text must be a
// match, whatever the offset.
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
sel, _ = h.Inspect(func(st *editor.State) any {
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
})
if want := strings.Index(findContent[6:], "needle") + 6; sel.([2]int) != [2]int{want, want + 6} {
t.Fatalf("selection %v, want [%d,%d)", sel, want, want+6)
}
// Next -> the third; next again wraps to the FIRST.
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
h.SendInput([]ui.InputEvent{{Handler: editor.FindNext, Data: ui.Point{}}})
sel, _ = h.Inspect(func(st *editor.State) any {
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
})
if sel.([2]int) != [2]int{0, 6} {
t.Fatalf("selection %v, want [0,6) (wrapped to first)", sel)
}
// Prev from the first wraps to the LAST.
h.SendInput([]ui.InputEvent{{Handler: editor.FindPrev, Data: ui.Point{}}})
nMatches, _ := h.Inspect(func(st *editor.State) any { return len(st.Editor.Find.Matches) })
last := nMatches.(int) - 1
cur, _ := h.Inspect(func(st *editor.State) any { return st.Editor.Find.Cur })
if cur.(int) != last {
t.Fatalf("cur %d, want last (wrapped)", cur)
}
// A query with no hits reports zero matches.
h.Logic().FindQueryChan() <- "no-such-text"
waitForFindMatches(t, h, 0)
// Close the find bar: it leaves the frame and focus returns to the
// editor.
h.SendInput([]ui.InputEvent{{Handler: editor.FindClose, Data: ui.Point{}}})
if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil {
t.Fatalf("wait for close frame: %v", err)
}
if frameHasFindBar(h) {
t.Fatal("find bar still in frame after close")
}
focus, _ = h.Inspect(func(st *editor.State) any { return st.FocusedElementID })
if focus.(string) != "editor_text" {
t.Fatalf("focus %q, want editor_text after close", focus)
}
}
func TestRealFile_FindMatchesSurviveEdits(t *testing.T) {
h, _ := realFileHarness(t, "find_edit.txt", findContent)
defer h.Cleanup()
h.SendConfig(780, 1688)
if ok, _ := h.FileLoaded(); !ok {
t.Fatal("file not loaded")
}
h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}})
h.Logic().FindQueryChan() <- "needle"
waitForFindMatches(t, h, 3)
// Insert 5 bytes at the start: ALL matches shift by 5, none is dropped
// (the edit is outside every match), and no re-scan happens. Clear the
// first-discovery selection first: HandleInsert REPLACES an active
// selection, which would eat match 0.
if err := h.WithState(func(st *editor.State) {
editor.ClearSelection()
st.Editor.CursorPosition = 0
}); err != nil {
t.Fatalf("WithState: %v", err)
}
h.SendInput([]ui.InputEvent{{Handler: func(data any) { editor.HandleInsert(data.(string)) }, Data: "12345"}})
m, _ := h.Inspect(func(st *editor.State) any {
return st.Editor.Find.Matches
})
got := m.([][2]int)
want := [][2]int{}
for _, mm := range initialNeedleMatches(t) {
want = append(want, [2]int{mm[0] + 5, mm[1] + 5})
}
if len(got) != len(want) {
t.Fatalf("matches %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("match[%d] = %v, want %v (shifted by the insert)", i, got[i], want[i])
}
}
// An edit INSIDE the first (now shifted) match drops only that match.
// Match 0 is now at [5,11); put the caret in its middle and insert.
if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 7 }); err != nil {
t.Fatalf("WithState: %v", err)
}
h.SendInput([]ui.InputEvent{{Handler: func(data any) { editor.HandleInsert(data.(string)) }, Data: "X"}})
waitForFindMatches(t, h, 2)
m, _ = h.Inspect(func(st *editor.State) any { return st.Editor.Find.Matches })
got = m.([][2]int)
// The surviving matches (at 43 and 84 after the +5 shift) sit after the
// edit at byte 7, so they shift by 1 more.
if got[0] != [2]int{44, 50} || got[1] != [2]int{85, 91} {
t.Fatalf("surviving matches %v, want [44,50) and [85,91)", got)
}
}
// initialNeedleMatches computes the expected byte ranges of "needle" in
// findContent (the pre-edit ground truth).
func initialNeedleMatches(t *testing.T) [][2]int {
t.Helper()
var out [][2]int
for i := 0; i+len("needle") <= len(findContent); i++ {
if strings.EqualFold(findContent[i:i+len("needle")], "needle") {
out = append(out, [2]int{i, i + len("needle")})
i += len("needle")
}
}
return out
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

View File

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

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

View File

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

After

Width:  |  Height:  |  Size: 228 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

View File

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

After

Width:  |  Height:  |  Size: 269 B

View File

@ -6,7 +6,7 @@ import (
"embed"
)
//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>
`

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

View File

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

After

Width:  |  Height:  |  Size: 269 B

View File

@ -203,7 +203,7 @@ func (r *Renderer) GioEditor(id string) (*widget.Editor, bool) {
// loadIcons loads PNG icons from the embedded filesystem.
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