fix(qt): move the cortinilla debounce from QML into Rust

The 220ms debounce wrapped the WHOLE searchLive invokable in QML, so live()
- which opens the dropdown and raises the skeleton - only ran 220ms after
the LAST keystroke. Continuous typing therefore showed nothing at all. The
reference opens the panel and raises the skeleton on the FIRST keystroke of
2+ characters and debounces only the LOAD.

live() is now two halves. The synchronous half runs on the first poll of the
spawned task, i.e. at the keystroke: gates, selection reset, open, query,
version bump, loading flag. Then it sleeps 220ms and re-checks the version,
so a superseded task exits without touching the network. This is the shape
the immersive arm already uses (imm_live), which is the precedent in both
respects: raise synchronously, debounce the load.

cortinillaLoading gets exactly one owner in the synchronous half and is
written once more at the publish - never in both halves for the same
keystroke.

Two consequences ride along:

  - dismiss() becomes a real cancellation point. It only flipped the open
    flag, so an in-flight load survived Esc, published into a closed
    dropdown and rewrote LAST_CORT. It now bumps the version too. This
    matters more now that the version bumps per keystroke.
  - the idle-close timer restarts on the QUERY rather than the payload. A
    burst of typing publishes one payload but many queries, and the
    reference restarts per keystroke.

HeaderBar.qml's header claimed the cortinilla and live-search were "out of
scope" in the file that wires the whole input path. Corrected here, beside
the code that falsifies it, rather than in a later docs sweep.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C3
(H1/H2/H3/H8, D-BUG-8, R7-4).

Gate: build clean, 256/256 tests, both QML audits OK, offscreen smoke
0 complaints + home published.

cc
This commit is contained in:
vicrodh committed 2026-08-03 16:23:06 -06:00
1 parent dae6c3bb4f
commit 5a194d2499
3 files changed
+52 -18

No files matched your search

+5 -2
View File
@@ -61,8 +61,11 @@ Item {
}
Connections {
target: QbzSearch
// Activity restarts the countdown (keystroke or arrow move).
function onCortinillaJsonChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
// Activity restarts the countdown (keystroke or arrow move). The
// keystroke probe is the QUERY, not the payload: the reference
// restarts per keystroke, and now that the debounce lives in Rust a
// burst of typing publishes one payload but many queries.
function onCortinillaQueryChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
function onCortinillaSelectedIndexChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
// Keyboard scroll-into-view: the controller publishes the selected
// row's content-space top-y; nudge the Flickable so it is visible.
+14 -13
View File
@@ -15,8 +15,12 @@
//
// The search field gives up 60px whenever the nav is in the header, and
// re-centers with a 220ms animation (HeaderBar.slint:569).
// Center: the search field, absolutely centered on the window (VISUAL
// replica — the cortinilla/live-search is out of scope; POC-NOTE).
// Center: the search field, absolutely centered on the window. It owns the
// live-search input path: the >= 2-char gate and the dismiss below it, the
// arrow/Enter/Escape rules, the ↵ affordance and clearSearch(). The 220ms
// debounce is NOT here — it lives in Rust (search_qt::live), so the panel
// opens on the first keystroke instead of 220ms after the last one. The
// dropdown itself is shell/Cortinilla.qml.
// Right: the tri-state offline status badge with its flyout (recovery
// "Sign in" wired to QbzSession.recoveryLogin) and the app menu (user block
// + Keyboard Shortcuts + Documentation + Log Out + Close. Still missing vs
@@ -665,24 +669,21 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
horizontalAlignment: text === "" && !activeFocus ? Text.AlignHCenter : Text.AlignLeft
clip: true
// No QML debounce: the 220ms CORTINILLA_DEBOUNCE lives in Rust
// now (search_qt::live). Debouncing the whole invokable here
// meant the panel and its skeleton only appeared 220ms after the
// LAST keystroke, so continuous typing showed nothing at all;
// the reference opens on the FIRST keystroke >= 2 chars and
// debounces only the LOAD. Rust's version guard is what discards
// the superseded loads.
onTextEdited: {
if (text.trim().length < 2) {
liveDebounce.stop()
QbzSearch.cortinillaDismiss()
} else {
liveDebounce.restart()
QbzSearch.searchLive(text)
}
}
// 220ms live debounce (CORTINILLA_DEBOUNCE — one load per pause,
// not one per keystroke).
Timer {
id: liveDebounce
interval: 220
repeat: false
onTriggered: QbzSearch.searchLive(searchInput.text)
}
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
// a keyboard selection -> activate the row; open + none -> full
// search; closed -> plain submit (also Search > All).
+33 -3
View File
@@ -706,9 +706,24 @@ fn set_selected(index: i32, scroll_y: f64) {
});
}
/// The live header query changed (QML debounced at 220ms, >= 2 chars —
/// mirrors the Slint CORTINILLA_DEBOUNCE + the chars().count() gate).
/// Opens the dropdown, resets the keyboard selection, and loads.
/// The live header query changed — called on EVERY keystroke (>= 2 chars),
/// not on a QML timer.
///
/// Two halves, and the split is load-bearing:
///
/// - **Synchronous half** — everything up to the sleep below. It runs on the
/// first poll of the spawned task, i.e. at the keystroke: gates, selection
/// reset, open, version bump, loading flag. This is what makes the panel and
/// its skeleton appear while the user is still typing, which is the
/// reference's behaviour (`qbz/src/main.rs:9641-9648` opens and raises
/// before starting the timer at `:9673`).
/// - **Debounced half** — after a 220 ms sleep (CORTINILLA_DEBOUNCE). A newer
/// keystroke has bumped the version by then, so the superseded task exits
/// without loading. Same shape as the immersive arm (`imm_live`).
///
/// `cortinillaLoading` has exactly ONE owner in the synchronous half and is
/// written exactly once more at the publish. Never in both halves for the
/// same keystroke.
pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
let q = query.trim().to_string();
if q.chars().count() < 2 {
@@ -731,6 +746,16 @@ pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
b.as_mut().set_cortinilla_loading(true);
});
// ---- end of the synchronous half; the debounce starts here ----------
// 220 ms (CORTINILLA_DEBOUNCE): one load per pause, not one per
// keystroke. A newer keystroke bumps CORT_VERSION while this sleeps, so
// the superseded task wakes, fails the guard and exits without touching
// the network — the same idiom `imm_live` uses.
tokio::time::sleep(std::time::Duration::from_millis(220)).await;
if !is_current_cort_version(version) {
return;
}
let t = std::time::Instant::now();
// Blacklist filtering happens INSIDE search_all (`search.rs:1112-1115`).
let (bl, abl) = blacklist_snapshots();
@@ -805,6 +830,11 @@ pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
/// Dismiss (Esc / click-outside / idle / page change).
pub fn dismiss() {
set_cortinilla_open(false);
// Dismiss is a CANCELLATION POINT. Without the bump an in-flight load
// survives the Esc and still publishes into a closed dropdown — and it
// also rewrites LAST_CORT, which used to be what Enter submitted. The
// bump makes every task in flight fail its version guard and exit.
next_cort_version();
}
/// Arrow-key move (delta -1 up / +1 down; the Slint on_cortinilla_move_selection