diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 5c12446..f43b3f9 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 diff --git a/internal/editor/frame.go b/internal/editor/frame.go index 45cfb35..d895844 100644 --- a/internal/editor/frame.go +++ b/internal/editor/frame.go @@ -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, diff --git a/internal/editor/search.go b/internal/editor/search.go index 148a777..dc93d6a 100644 --- a/internal/editor/search.go +++ b/internal/editor/search.go @@ -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" diff --git a/internal/editor/search_test.go b/internal/editor/search_test.go index be8d707..ec4b882 100644 --- a/internal/editor/search_test.go +++ b/internal/editor/search_test.go @@ -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 {