From d726ea7244ce36bc4fda54672d973c3c3412baec Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 20 Aug 2026 13:00:21 -0400 Subject: [PATCH] Find bar X clears the query instead of closing the bar The X was redundant with the top-bar search icon (both closed the bar). Now the X empties the query and results but leaves the bar open; closing is the search icon's toggle. - editor: new findClear (empties query/matches, bumps Gen so an in-flight scan of the old query is dropped, disarms the settle) and FindClear handler; the X icon now runs FindClear. - main: mirrors the logic-side clear into the main-owned widget input (frame.FindQuery=="" while the widget still has text -> SetText("")). - tests: unit findClear (stays open, supersedes in-flight scan); e2e X clears but keeps the bar open and focus, close now via ToggleFind. - doc: spec updated (clear button vs icon toggle). --- cmd/pad/main.go | 7 +++++++ doc/spec.md | 9 ++++++--- internal/editor/search.go | 20 ++++++++++++++++++-- internal/editor/search_test.go | 25 +++++++++++++++++++++++++ internal/editor/state.go | 6 +++--- internal/test/e2e/find_test.go | 19 ++++++++++++++++--- 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 1bb9457..5c12446 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -218,6 +218,13 @@ func run(w *app.Window) error { // The logic goroutine handles filtering and triggers a new frame. newQuery := searchEditor.Text() sendQuery := newQuery != frame.Query + // The find bar's X button (editor.FindClear) empties the LOGIC-side + // query; the main-owned widget still shows the old text, so mirror + // the clear into it before the change comparison below (which then + // sees "" == "" and forwards nothing). + if frame.FindQuery == "" && findEditor.Text() != "" { + findEditor.SetText("") + } newFind := findEditor.Text() sendFind := newFind != frame.FindQuery events := renderer.CheckGestures(e.Source, gtx.Metric) diff --git a/doc/spec.md b/doc/spec.md index 8553f2b..9024302 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -75,9 +75,12 @@ 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, +- **In-file search:** a find icon on the top bar (right side) toggles a + find bar directly below it, with a text input, an "N / M" counter, + next/prev navigation, and a clear (X) button. The X empties the query and + the results but leaves the bar open; the top-bar icon closes it (and + re-opens it). Opening the bar moves key focus to the search input and + raises the soft keyboard. 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 diff --git a/internal/editor/search.go b/internal/editor/search.go index 8cbab15..148a777 100644 --- a/internal/editor/search.go +++ b/internal/editor/search.go @@ -66,8 +66,12 @@ 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() } +// FindClear is the tap handler of the find bar's X button: it empties the +// query (and the results) but leaves the bar open. Closing the bar is the +// search icon's toggle (ToggleFind). The main-owned widget's text is +// mirrored to empty by the main loop (frame.FindQuery == "" while the +// widget still has text, see cmd/pad/main.go). +func FindClear(data any) { TheState.Editor.findClear() } // 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 @@ -82,6 +86,18 @@ func (e *EditorState) findShow() { } } +// findClear empties the query and the results (find bar X button). Gen is +// bumped so an in-flight scan of the old query is dropped when it lands. +func (e *EditorState) findClear() { + f := &e.Find + f.Query = "" + f.Matches = nil + f.Cur = -1 + f.Scanning = false + f.Gen++ + f.SettleByte = -1 +} + // 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. diff --git a/internal/editor/search_test.go b/internal/editor/search_test.go index f64b717..be8d707 100644 --- a/internal/editor/search_test.go +++ b/internal/editor/search_test.go @@ -193,3 +193,28 @@ func TestFindReset_SupersedesInflight(t *testing.T) { t.Fatal("superseded result was applied") } } + +func TestFindClear_EmptiesQueryKeepsBarOpen(t *testing.T) { + findTestState() + e := &TheState.Editor + e.Find.Scanning = true // a scan of the old query is in flight + e.Find.SettleByte = 5 + oldGen := e.Find.Gen + + e.findClear() + + if !e.Find.Visible { + t.Fatal("findClear closed the bar; it must stay open") + } + if e.Find.Query != "" || len(e.Find.Matches) != 0 || e.Find.Cur != -1 || e.Find.Scanning || e.Find.SettleByte != -1 { + t.Fatalf("state after clear: %+v", e.Find) + } + if e.Find.Gen != oldGen+1 { + t.Fatalf("gen %d, want %d (in-flight scan must be superseded)", e.Find.Gen, oldGen+1) + } + // The in-flight scan of the old query must now be dropped. + e.applySearchResult(pool.Result{TaskType: pool.TypeSearch, Success: true, Data: pool.SearchData{Gen: oldGen, Matches: [][2]int{{0, 1}}}}) + if len(e.Find.Matches) != 0 { + t.Fatal("cleared query's in-flight scan was applied") + } +} diff --git a/internal/editor/state.go b/internal/editor/state.go index 4449dbc..ae590f6 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -1862,7 +1862,7 @@ func markDirty() { const FindBarHeight = ui.Dp(40) // buildFindBar lays out the in-file search bar: [input][counter][prev][next] -// [close], full screen width, directly below the margin-free top bar (the +// [clear-X], 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 @@ -1873,7 +1873,7 @@ func buildFindBar(screenWidth ui.Dp) ui.Element { h := FindBarHeight topY := ui.Dp(32) // top bar height (see EditorLayout) - // Right-hand button column: prev, next, close. + // Right-hand button column: prev, next, clear (X). iconW := ui.IconSize closeX := screenWidth - margin - iconW nextX := closeX - gap - iconW @@ -1904,7 +1904,7 @@ func buildFindBar(screenWidth ui.Dp) ui.Element { 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}}), + []ui.Interaction{{Gesture: ui.Tap, Handler: FindClear}}), }, ) } diff --git a/internal/test/e2e/find_test.go b/internal/test/e2e/find_test.go index 3045059..ccafd07 100644 --- a/internal/test/e2e/find_test.go +++ b/internal/test/e2e/find_test.go @@ -177,13 +177,26 @@ func TestRealFile_FindBarAndNavigation(t *testing.T) { t.Fatalf("cur %d, want last (wrapped)", cur) } + // The X button clears the query but LEAVES the bar open (closing is the + // top-bar icon's toggle). + h.SendInput([]ui.InputEvent{{Handler: editor.FindClear, Data: ui.Point{}}}) + if !frameHasFindBar(h) { + t.Fatal("find bar closed by the clear button; it should stay open") + } + clear, _ := h.Inspect(func(st *editor.State) any { + return [3]any{st.Editor.Find.Query, len(st.Editor.Find.Matches), st.FocusedElementID} + }) + if clear.([3]any) != [3]any{"", 0, "find_bar"} { + t.Fatalf("after clear: query/matches/focus = %v, want \"\"/0/find_bar", clear) + } + // 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{}}}) + // Close the find bar via the top-bar icon toggle: it leaves the frame + // and focus returns to the editor. + 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 close frame: %v", err) }