Stop the find input clearing itself while typing

The main-loop mirror of the X-button clear was level-triggered:
'FindQuery=="" && widget has text'. But the widget updates on every
keystroke while the logic only stores the query a round-trip later, so any
frame drawn in that window still carried FindQuery=="" and wiped the input
- the periodic self-clearing.

Make it edge-triggered: FindState.ClearSeq bumps once per clear; the frame
carries it as FindClearSeq; main wipes the widget input once per NEW value
(lastFindClearSeq handshake). findClear bumps it; findReset preserves it
(main tracks it monotonically, so a reset to 0 would swallow a later clear).

Tests: unit asserts the bump, monotonicity, and close/reopen preservation.
This commit is contained in:
Greg Pomerantz 2026-08-20 13:22:50 -04:00
parent d726ea7244
commit c789de4b44
4 changed files with 44 additions and 6 deletions

View File

@ -132,6 +132,10 @@ func run(w *app.Window) error {
// main-owned input, so main hands key focus to it exactly once per open
// (see the focus handoff below).
var findBarFocused bool
// lastFindClearSeq is the FindClearSeq value whose wipe has already been
// applied to the main-owned find input (edge-triggered, see
// Frame.FindClearSeq).
var lastFindClearSeq int
for {
switch e := w.Event().(type) {
@ -218,12 +222,15 @@ 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() != "" {
// Mirror each NEW find-clear (the bar's X button) into the main-
// owned widget input exactly once. Edge-triggered on ClearSeq, NOT
// "FindQuery==\"\" && widget has text": that level check also fired on
// stale frames between the user's typing and the logic storing it,
// wiping the input while typing.
if frame.FindClearSeq > lastFindClearSeq {
lastFindClearSeq = frame.FindClearSeq
findEditor.SetText("")
w.Invalidate()
}
newFind := findEditor.Text()
sendFind := newFind != frame.FindQuery

View File

@ -33,6 +33,9 @@ type Frame struct {
// "find_bar" widget's text and forwards changes via FindQueryChan — the
// same contract as Query/searchQueryChan.
FindQuery string
// FindClearSeq mirrors EditorState.Find.ClearSeq: main wipes the widget
// input once per NEW value (the X button cleared the logic-side query).
FindClearSeq int
// 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
@ -54,6 +57,7 @@ func (l *Logic) frameOf(elems []ui.Element) Frame {
FocusedElementID: l.state.FocusedElementID,
Query: l.state.Browser.Query,
FindQuery: l.state.Editor.Find.Query,
FindClearSeq: l.state.Editor.Find.ClearSeq,
WindowStartByte: l.state.Editor.IMEWindowStartByte,
WindowStartLine: l.state.WindowStartLine,
WindowText: l.state.Editor.IMEWindowText,

View File

@ -46,6 +46,15 @@ type FindState struct {
SettleByte int
SettleScroll ui.Dp
SettlePasses int
// ClearSeq bumps once per clear (the bar's X button, findClear). Main
// mirrors each NEW value into the main-owned widget input exactly once —
// an edge-triggered handshake, deliberately NOT a level check: a
// "FindQuery==\"\" && the widget has text" level check would also fire on
// any stale frame between the user's typing (widget updates immediately)
// and the logic storing it (a round-trip away), wiping the input while
// typing.
ClearSeq int
}
// ToggleFind opens the find bar (or closes it when open). It is the tap
@ -95,6 +104,7 @@ func (e *EditorState) findClear() {
f.Cur = -1
f.Scanning = false
f.Gen++
f.ClearSeq++
f.SettleByte = -1
}
@ -120,7 +130,10 @@ 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, SettleByte: -1}
// ClearSeq is preserved: main tracks it monotonically, so resetting it
// to 0 would make a later clear (seq 1) indistinguishable from an old
// one already mirrored.
e.Find = FindState{Query: e.Find.Query, Gen: e.Find.Gen + 1, ClearSeq: e.Find.ClearSeq, SettleByte: -1}
}
// findSetQuery processes a query forwarded from the main-owned "find_bar"

View File

@ -212,6 +212,20 @@ func TestFindClear_EmptiesQueryKeepsBarOpen(t *testing.T) {
if e.Find.Gen != oldGen+1 {
t.Fatalf("gen %d, want %d (in-flight scan must be superseded)", e.Find.Gen, oldGen+1)
}
if e.Find.ClearSeq != 1 {
t.Fatalf("ClearSeq %d, want 1 (main mirrors wipes on the edge)", e.Find.ClearSeq)
}
e.findClear()
if e.Find.ClearSeq != 2 {
t.Fatalf("ClearSeq %d after second clear, want 2 (monotonic)", e.Find.ClearSeq)
}
// findReset (bar close + reopen) preserves the sequence.
TheState.FocusedElementID = "editor_text"
e.findClose()
e.findShow()
if e.Find.ClearSeq != 2 {
t.Fatalf("ClearSeq %d after close/reopen, want 2 (preserved)", e.Find.ClearSeq)
}
// 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 {