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

View File

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

View File

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

View File

@ -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")
}
}

View File

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

View File

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