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).
This commit is contained in:
Greg Pomerantz 2026-08-20 13:00:21 -04:00
parent 1cbb367b1f
commit d726ea7244
6 changed files with 75 additions and 11 deletions

View File

@ -218,6 +218,13 @@ 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
// 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() newFind := findEditor.Text()
sendFind := newFind != frame.FindQuery sendFind := newFind != frame.FindQuery
events := renderer.CheckGestures(e.Source, gtx.Metric) events := renderer.CheckGestures(e.Source, gtx.Metric)

View File

@ -75,9 +75,12 @@ 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 - **In-file search:** a find icon on the top bar (right side) toggles a
bar directly below it, with a text input, an "N / M" counter, next/prev find bar directly below it, with a text input, an "N / M" counter,
navigation, and a close button. The query is a plain substring, 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 case-insensitive, no regex; each change is scanned over the entire
in-memory content by a worker (`Search` task), so typing never blocks the 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 UI and stale scans are dropped by generation. Next/prev select and scroll

View File

@ -66,8 +66,12 @@ func FindNext(data any) { findStep(1) }
func FindPrev(data any) { findStep(-1) } func FindPrev(data any) { findStep(-1) }
// FindClose is the tap handler of the find bar's close button. // FindClear is the tap handler of the find bar's X button: it empties the
func FindClose(data any) { TheState.Editor.findClose() } // 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 // 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 // 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 // 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 // (the main-owned widget keeps its text); matches are dropped — the next
// show re-scans, so they are never stale. // show re-scans, so they are never stale.

View File

@ -193,3 +193,28 @@ func TestFindReset_SupersedesInflight(t *testing.T) {
t.Fatal("superseded result was applied") 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")
}
}

View File

@ -1862,7 +1862,7 @@ func markDirty() {
const FindBarHeight = ui.Dp(40) const FindBarHeight = ui.Dp(40)
// buildFindBar lays out the in-file search bar: [input][counter][prev][next] // 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 // 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 // input is a MAIN-owned GioEditor registered as "find_bar" (the browser
// "search_bar" precedent, architecture.md §1); the counter and buttons are // "search_bar" precedent, architecture.md §1); the counter and buttons are
@ -1873,7 +1873,7 @@ func buildFindBar(screenWidth ui.Dp) ui.Element {
h := FindBarHeight h := FindBarHeight
topY := ui.Dp(32) // top bar height (see EditorLayout) 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 iconW := ui.IconSize
closeX := screenWidth - margin - iconW closeX := screenWidth - margin - iconW
nextX := closeX - gap - 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.NewIcon("chevron_down", ui.Region{X: nextX, Y: ui.Dp(8), W: iconW, H: iconW}, 0,
[]ui.Interaction{{Gesture: ui.Tap, Handler: FindNext}}), []ui.Interaction{{Gesture: ui.Tap, Handler: FindNext}}),
ui.NewIcon("close", ui.Region{X: closeX, Y: ui.Dp(8), W: iconW, H: iconW}, 0, 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}}),
}, },
) )
} }

View File

@ -177,13 +177,26 @@ func TestRealFile_FindBarAndNavigation(t *testing.T) {
t.Fatalf("cur %d, want last (wrapped)", cur) 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. // A query with no hits reports zero matches.
h.Logic().FindQueryChan() <- "no-such-text" h.Logic().FindQueryChan() <- "no-such-text"
waitForFindMatches(t, h, 0) waitForFindMatches(t, h, 0)
// Close the find bar: it leaves the frame and focus returns to the // Close the find bar via the top-bar icon toggle: it leaves the frame
// editor. // and focus returns to the editor.
h.SendInput([]ui.InputEvent{{Handler: editor.FindClose, Data: ui.Point{}}}) h.SendInput([]ui.InputEvent{{Handler: editor.ToggleFind, Data: ui.Point{}}})
if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil { if _, err := h.WaitForFrameCount(h.FrameCount()+1, 5*time.Second); err != nil {
t.Fatalf("wait for close frame: %v", err) t.Fatalf("wait for close frame: %v", err)
} }