refactor(qt): extract search_bridge.rs from the QbzBridge god-object

QbzBridge carried five domains (Settings, Search, Playlist, Genre filter,
Discover config); its own boot() comment called the split "TEMP (phase 23)".
TRACK-RULES section 2 requires one cxx-qt bridge per domain. This moves the
Search domain out verbatim, ahead of the cortinilla parity work, so the
refactor and the behaviour change stay separately reviewable.

QbzSearch (search_bridge.rs) takes 7 properties and 11 invokables. Two
deliberate changes ride the move:

  - selected_index -> cortinilla_selected_index. It is cortinilla-only
    state and the bare name on an app-wide singleton is a collision hazard.
  - cortinilla_scroll_y f32 -> f64, unifying with the immersive twin in
    immersive_bridge.rs, which was already f64.

cortinilla_query is declared here but not yet wired; C2 wires it, which is
what makes Enter carry the text the user actually typed instead of the last
successfully loaded payload's query.

Every Rust writer of a moving property was re-pointed BY HAND, never by
regex over the multi-line ui() closures: the seven crate::ui( sites in
search_qt.rs plus two that live outside it and that the QML-only xref audit
cannot see - settings_qt.rs (the live Settings > Appearance path) and
main.rs toggle_intelligent_search. All 48 QML consumer sites across
Cortinilla.qml, HeaderBar.qml and SearchView.qml were updated, including the
one Connections block, whose onSelectedIndexChanged handler was renamed with
its property.

A stale 6.0 base in flat_index_content_y is left in place and annotated: it
mirrors a Flickable viewport margin rather than content space, so every row
reports 6px low. C4 corrects it with its unit test; this commit is a move.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C0.

Gate: build clean (no new warnings), 256/256 tests, qml_resolution_audit
214 files, qml_singleton_xref 28 bridges with QbzSearch resolving, offscreen
smoke 0 complaints + home published.

cc
This commit is contained in:
vicrodh committed 2026-08-03 15:24:25 -06:00
1 parent 07b0ba818e
commit fa433d465b
11 files changed
+309 -172

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ fn main() {
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
// modules and must NOT be listed — only files that declare a
// #[cxx_qt::bridge] mod belong in this array.
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs"],
qml_files: &[
"qml/LoginScreen.qml",
"qml/Main.qml",
+8
View File
@@ -116,6 +116,14 @@ ApplicationWindow {
// after QbzSession like every domain singleton. The B2 dispatcher
// (AppShell-root Keys.onPressed) is the only other QML side.
QbzHotkeys.boot()
// Search (2026-08-03 cortinilla-parity contract, commit C0): the
// domain extracted from QbzBridge. Its position among the other
// domain singletons is not load-bearing — every boot() here runs
// before the first key or click — but the LINE is: boot() is what
// registers the Qt-thread hop, and without it `search_bridge::ui()`
// is a silent no-op, so the dropdown would never repaint and
// nothing would be logged (TRACK-RULES, singleton boot order).
QbzSearch.boot()
QbzShell.boot()
QbzPlayer.boot()
QbzQueue.boot()
@@ -9,7 +9,7 @@
// translucent panel, white-alpha text) instead of the app theme tokens;
// sections ONLY (Artists / Albums / Playlists — NO top-result hero, NO
// "View more", NO track rows); reads QbzImmersive.immSearch* (never
// QbzBridge.cortinilla*); NO idle auto-close (Slint has no such timer) and
// QbzSearch.cortinilla*); NO idle auto-close (Slint has no such timer) and
// no view-change dismiss — the immersive host never navigates.
//
// Data: QbzImmersive.immSearchJson (search_qt.rs CortinillaData: query /
+18 -18
View File
@@ -4,11 +4,11 @@
// pointer and would kill typing in the header field, the exact reason the
// Slint uses a plain Rectangle too).
//
// Payload: QbzBridge.cortinillaJson (search_qt.rs CortinillaData: query /
// Payload: QbzSearch.cortinillaJson (search_qt.rs CortinillaData: query /
// top / sections with controller flat indices). Keyboard selection rides
// selectedIndex + cortinillaScrollY (content-space top-y of the selected
// row — scrolled into view here). Row activation bubbles to the
// QbzBridge.cortinilla* invokables; the field clear goes through
// QbzSearch.cortinilla* invokables; the field clear goes through
// root.header.clearSearch().
import QtQuick
@@ -19,7 +19,7 @@ import "../theme"
Item {
id: root
anchors.fill: parent
visible: QbzBridge.cortinillaOpen
visible: QbzSearch.cortinillaOpen
// The host HeaderBar (for clearSearch on row activation).
property var headerBar: null
@@ -29,14 +29,14 @@ Item {
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzBridge.cortinillaJson)
return JSON.parse(QbzSearch.cortinillaJson)
} catch (e) {
return ({})
}
}
readonly property var sections: doc.sections || []
readonly property bool hasTop: doc.top !== undefined && doc.top !== null && (doc.top.id || "") !== ""
readonly property bool empty: !hasTop && sections.length === 0 && !QbzBridge.cortinillaLoading
readonly property bool empty: !hasTop && sections.length === 0 && !QbzSearch.cortinillaLoading
// The centered header search box width (HeaderBar's responsive rule) —
// the click-through gaps below leave it free.
@@ -52,17 +52,17 @@ Item {
interval: 4500
repeat: false
running: root.visible && !root.panelHovered
onTriggered: QbzBridge.cortinillaDismiss()
onTriggered: QbzSearch.cortinillaDismiss()
}
Connections {
target: QbzBridge
target: QbzSearch
// Activity restarts the countdown (keystroke or arrow move).
function onCortinillaJsonChanged() { if (root.visible && !root.panelHovered) idleClose.restart() }
function onSelectedIndexChanged() { 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.
function onCortinillaScrollYChanged() {
var y = QbzBridge.cortinillaScrollY
var y = QbzSearch.cortinillaScrollY
if (y < bodyFlick.contentY) {
bodyFlick.contentY = Math.max(0, y)
} else if (y + 56 > bodyFlick.contentY + bodyFlick.height) {
@@ -74,7 +74,7 @@ Item {
Connections {
target: QbzShell
// A page change dismisses (AppShell's tracked-view close hook).
function onCurrentViewChanged() { QbzBridge.cortinillaDismiss() }
function onCurrentViewChanged() { QbzSearch.cortinillaDismiss() }
}
// --- Click-outside scrims (any click outside the panel dismisses) ----
@@ -85,7 +85,7 @@ Item {
y: 42
width: root.width
height: root.height - 42
onClicked: QbzBridge.cortinillaDismiss()
onClicked: QbzSearch.cortinillaDismiss()
}
// 2) header strip LEFT of the search box.
MouseArea {
@@ -94,7 +94,7 @@ Item {
y: 0
width: (root.width - root.searchBoxWidth) / 2
height: 42
onClicked: QbzBridge.cortinillaDismiss()
onClicked: QbzSearch.cortinillaDismiss()
}
// 3) header strip RIGHT of the search box.
MouseArea {
@@ -103,7 +103,7 @@ Item {
y: 0
width: root.width - x
height: 42
onClicked: QbzBridge.cortinillaDismiss()
onClicked: QbzSearch.cortinillaDismiss()
}
// --- The panel ----------------------------------------------------------
@@ -143,7 +143,7 @@ Item {
// --- Loading skeleton (no cached instant-paint — one clean
// apply, Cortinilla.slint) -------------------------------
Column {
visible: QbzBridge.cortinillaLoading
visible: QbzSearch.cortinillaLoading
width: parent.width
topPadding: 4
property bool pulse: false
@@ -195,7 +195,7 @@ Item {
// --- Loaded content ----------------------------------------
Column {
visible: !QbzBridge.cortinillaLoading
visible: !QbzSearch.cortinillaLoading
width: parent.width
// No results.
@@ -269,7 +269,7 @@ Item {
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.headerBar) root.headerBar.clearSearch()
QbzBridge.cortinillaViewMore(modelData.kind)
QbzSearch.cortinillaViewMore(modelData.kind)
}
}
}
@@ -298,7 +298,7 @@ Item {
width: parent ? parent.width : 0
height: 56
radius: theme.radiusSm
readonly property bool active: QbzBridge.selectedIndex === (row.flatIndex ?? -2)
readonly property bool active: QbzSearch.cortinillaSelectedIndex === (row.flatIndex ?? -2)
color: (active || rowArea.containsMouse) ? theme.surfaceHover : "transparent"
// Accent bar on the keyboard-active row (distinct from plain hover).
@@ -354,7 +354,7 @@ Item {
cursorShape: Qt.PointingHandCursor
onClicked: {
if (root.headerBar) root.headerBar.clearSearch()
QbzBridge.cortinillaRowClicked(row.flatIndex)
QbzSearch.cortinillaRowClicked(row.flatIndex)
}
}
}
+16 -16
View File
@@ -613,7 +613,7 @@ Rectangle {
// the row / full search, Esc dismisses. The × clears + closes.
function clearSearch() {
searchInput.text = ""
QbzBridge.cortinillaDismiss()
QbzSearch.cortinillaDismiss()
}
// nav.search / Ctrl+f (2026-08-03 hotkeys-port contract §4.3, divergence
@@ -668,7 +668,7 @@ Rectangle {
onTextEdited: {
if (text.trim().length < 2) {
liveDebounce.stop()
QbzBridge.cortinillaDismiss()
QbzSearch.cortinillaDismiss()
} else {
liveDebounce.restart()
}
@@ -680,7 +680,7 @@ Rectangle {
id: liveDebounce
interval: 220
repeat: false
onTriggered: QbzBridge.searchLive(searchInput.text)
onTriggered: QbzSearch.searchLive(searchInput.text)
}
// The Enter rule (HeaderBar.slint on-enter): cortinilla open +
@@ -688,34 +688,34 @@ Rectangle {
// search; closed -> plain submit (also Search > All).
Keys.onPressed: function (event) {
if (event.key === Qt.Key_Down) {
if (QbzBridge.cortinillaOpen) {
QbzBridge.cortinillaMoveSelection(1)
if (QbzSearch.cortinillaOpen) {
QbzSearch.cortinillaMoveSelection(1)
event.accepted = true
}
} else if (event.key === Qt.Key_Up) {
if (QbzBridge.cortinillaOpen) {
QbzBridge.cortinillaMoveSelection(-1)
if (QbzSearch.cortinillaOpen) {
QbzSearch.cortinillaMoveSelection(-1)
event.accepted = true
}
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (QbzBridge.cortinillaOpen) {
if (QbzBridge.selectedIndex >= 0) {
if (QbzSearch.cortinillaOpen) {
if (QbzSearch.cortinillaSelectedIndex >= 0) {
root.clearSearch()
QbzBridge.cortinillaRowClicked(QbzBridge.selectedIndex)
QbzSearch.cortinillaRowClicked(QbzSearch.cortinillaSelectedIndex)
} else {
root.clearSearch()
QbzBridge.cortinillaSearchAll()
QbzSearch.cortinillaSearchAll()
}
} else {
// Capture BEFORE clearing (clearSearch wipes the input).
var q = searchInput.text
root.clearSearch()
QbzBridge.searchSubmit(q)
QbzSearch.searchSubmit(q)
}
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
if (QbzBridge.cortinillaOpen) {
QbzBridge.cortinillaDismiss()
if (QbzSearch.cortinillaOpen) {
QbzSearch.cortinillaDismiss()
event.accepted = true
}
}
@@ -740,7 +740,7 @@ Rectangle {
// open (Slint: it lives in the box, opposite the magnifier), else
// the × clear.
Text {
visible: QbzBridge.cortinillaOpen
visible: QbzSearch.cortinillaOpen
anchors.right: parent.right
anchors.rightMargin: 10
height: parent.height
@@ -750,7 +750,7 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
}
Rectangle {
visible: !QbzBridge.cortinillaOpen && searchInput.text !== ""
visible: !QbzSearch.cortinillaOpen && searchInput.text !== ""
anchors.right: parent.right
anchors.rightMargin: 5
width: 22
+15 -15
View File
@@ -1,5 +1,5 @@
// Search results view — the QML port of search/SearchResultsView.slint
// (phase 15). One JSON document (QbzBridge.searchJson, search_qt.rs
// (phase 15). One JSON document (QbzSearch.searchJson, search_qt.rs
// SearchPageDoc: query/tab/loading/filterIndex + the four category lists,
// totals, the most-popular hero, the carousel-only artists list).
//
@@ -65,7 +65,7 @@ Rectangle {
readonly property var doc: parseDoc()
function parseDoc() {
try {
return JSON.parse(QbzBridge.searchJson)
return JSON.parse(QbzSearch.searchJson)
} catch (e) {
return ({})
}
@@ -190,7 +190,7 @@ Rectangle {
root.fadeTab = t
root.fadeFrom = root.moreFrom
moreSettle.restart()
QbzBridge.searchLoadMore(t)
QbzSearch.searchLoadMore(t)
}
// `morePending` clears only when the tab's array GROWS — and search_qt::
// load_more never republishes on a failed fetch (main.rs just logs), nor
@@ -404,11 +404,11 @@ Rectangle {
anchors.rightMargin: 32
anchors.verticalCenter: parent.verticalCenter
spacing: 16
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzBridge.searchFilterChanged(1) }
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzBridge.searchFilterChanged(2) }
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzBridge.searchFilterChanged(3) }
FilterRadio { label: QbzSession.tr("Label", QbzSession.trRev); selected: root.filterIndex === 4; onPicked: QbzBridge.searchFilterChanged(4) }
FilterRadio { label: QbzSession.tr("Release Name", QbzSession.trRev); selected: root.filterIndex === 5; onPicked: QbzBridge.searchFilterChanged(5) }
FilterRadio { label: QbzSession.tr("Main Artist", QbzSession.trRev); selected: root.filterIndex === 1; onPicked: QbzSearch.searchFilterChanged(1) }
FilterRadio { label: QbzSession.tr("Performer", QbzSession.trRev); selected: root.filterIndex === 2; onPicked: QbzSearch.searchFilterChanged(2) }
FilterRadio { label: QbzSession.tr("Composer", QbzSession.trRev); selected: root.filterIndex === 3; onPicked: QbzSearch.searchFilterChanged(3) }
FilterRadio { label: QbzSession.tr("Label", QbzSession.trRev); selected: root.filterIndex === 4; onPicked: QbzSearch.searchFilterChanged(4) }
FilterRadio { label: QbzSession.tr("Release Name", QbzSession.trRev); selected: root.filterIndex === 5; onPicked: QbzSearch.searchFilterChanged(5) }
Rectangle {
visible: root.filterIndex !== 0
width: 24
@@ -422,7 +422,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.searchFilterChanged(0)
onClicked: QbzSearch.searchFilterChanged(0)
}
}
}
@@ -460,7 +460,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.searchTabChanged(modelData.t)
onClicked: QbzSearch.searchTabChanged(modelData.t)
}
}
Rectangle {
@@ -583,7 +583,7 @@ Rectangle {
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(3)
onViewAllClicked: QbzSearch.searchTabChanged(3)
}
ListView {
width: parent.width
@@ -612,7 +612,7 @@ Rectangle {
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(3)
onViewAllClicked: QbzSearch.searchTabChanged(3)
}
ListView {
width: parent.width
@@ -641,7 +641,7 @@ Rectangle {
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(1)
onViewAllClicked: QbzSearch.searchTabChanged(1)
}
ListView {
width: parent.width
@@ -772,7 +772,7 @@ Rectangle {
showViewAll: root.tab === 0 && (root.doc.tracksTotal || 0) > root.previewCap
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(2)
onViewAllClicked: QbzSearch.searchTabChanged(2)
}
Repeater {
model: root.tab === 2 ? root.tracks : root.tracks.slice(0, root.previewCap)
@@ -911,7 +911,7 @@ Rectangle {
showViewAll: true
viewAllAccent: true
showChevrons: false
onViewAllClicked: QbzBridge.searchTabChanged(4)
onViewAllClicked: QbzSearch.searchTabChanged(4)
}
ListView {
width: parent.width
+10 -107
View File
@@ -46,20 +46,12 @@ pub mod qbz_bridge {
// for the same reason (SettingsView.slint:121-200).
#[qproperty(i32, settings_section)]
// --- Search (phase 15) ---------------------------------------------
// Cortinilla (live dropdown): open/loading flags, ONE JSON payload
// (search_qt.rs CortinillaData: query/top/sections with controller
// flat indices), keyboard selection + its content-space scroll y.
#[qproperty(bool, cortinilla_open)]
#[qproperty(bool, cortinilla_loading)]
#[qproperty(QString, cortinilla_json)]
#[qproperty(i32, selected_index)]
#[qproperty(f32, cortinilla_scroll_y)]
// Results page: ONE JSON document (search_qt.rs SearchPageDoc).
#[qproperty(QString, search_json)]
// The intelligent-search kill switch (ui_prefs pref state — the
// app-menu check; live-flippable, no restart).
#[qproperty(bool, intelligent_search)]
// --- Search: MOVED (cortinilla-parity contract C0) -----------------
// cortinilla_* + search_json + intelligent_search -> QbzSearch
// (search_bridge.rs). `selected_index` was RENAMED on the way out to
// `cortinilla_selected_index` (it is cortinilla-only state, and the
// bare name on an app-wide singleton is a collision hazard), and
// `cortinilla_scroll_y` went f32 -> f64 to match the immersive twin.
// --- Playlist view (phase 17) --------------------------------------
// ONE JSON document (playlist_qt.rs PlaylistDoc: header + track
@@ -124,41 +116,10 @@ pub mod qbz_bridge {
#[qinvokable]
fn integrations_action(self: Pin<&mut QbzBridge>, action: QString);
// --- Search (phase 15) ---------------------------------------------
/// Header field keystrokes (QML-debounced 220ms, >= 2 chars): drive
/// the cortinilla live query.
#[qinvokable]
fn search_live(self: Pin<&mut QbzBridge>, query: QString);
/// Enter with the cortinilla closed: full results page (All tab).
#[qinvokable]
fn search_submit(self: Pin<&mut QbzBridge>, query: QString);
/// Cortinilla: Esc / click-outside / idle-close / page change.
#[qinvokable]
fn cortinilla_dismiss(self: Pin<&mut QbzBridge>);
/// Arrow keys: delta -1 (up) / +1 (down) through the flat list.
#[qinvokable]
fn cortinilla_move_selection(self: Pin<&mut QbzBridge>, delta: i32);
/// Row click or Enter on the keyboard-selected row.
#[qinvokable]
fn cortinilla_row_clicked(self: Pin<&mut QbzBridge>, index: i32);
/// Section "View more" (kind: album | track | artist | playlist).
#[qinvokable]
fn cortinilla_view_more(self: Pin<&mut QbzBridge>, kind: QString);
/// The Enter affordance with no keyboard selection: Search > All.
#[qinvokable]
fn cortinilla_search_all(self: Pin<&mut QbzBridge>);
/// Results page: the five-tab strip.
#[qinvokable]
fn search_tab_changed(self: Pin<&mut QbzBridge>, tab: i32);
/// Per-type tab "Load more".
#[qinvokable]
fn search_load_more(self: Pin<&mut QbzBridge>, tab: i32);
/// searchType filter radios (0 = none, 1..5 = the chips).
#[qinvokable]
fn search_filter_changed(self: Pin<&mut QbzBridge>, index: i32);
/// App-menu intelligent-search toggle (the 2.0.0 opt-out module).
#[qinvokable]
fn toggle_intelligent_search(self: Pin<&mut QbzBridge>);
// --- Search: MOVED (cortinilla-parity contract C0) -----------------
// search_live / search_submit / cortinilla_* / search_tab_changed /
// search_load_more / search_filter_changed / toggle_intelligent_search
// -> QbzSearch (search_bridge.rs).
// --- Playlist view (phase 17) --------------------------------------
/// Sidebar playlist row / playlist card click: open the detail view.
@@ -268,13 +229,6 @@ use cxx_qt_lib::QString;
pub struct QbzBridgeRust {
settings_json: QString,
settings_section: i32,
cortinilla_open: bool,
cortinilla_loading: bool,
cortinilla_json: QString,
selected_index: i32,
cortinilla_scroll_y: f32,
search_json: QString,
intelligent_search: bool,
playlist_json: QString,
genre_filter_json: QString,
discover_config_json: QString,
@@ -286,13 +240,6 @@ impl Default for QbzBridgeRust {
settings_json: QString::from("{}"),
// 0 = Audio, the section Settings opens on.
settings_section: 0,
cortinilla_open: false,
cortinilla_loading: false,
cortinilla_json: QString::from("{}"),
selected_index: -1,
cortinilla_scroll_y: 0.0,
search_json: QString::from("{}"),
intelligent_search: crate::search_qt::intelligent_search_pref(),
playlist_json: QString::from("{}"),
// Seeded from the PERSISTED selection (no network): a remembered
// filter colors both genre buttons and narrows the Library feed
@@ -348,50 +295,6 @@ impl qbz_bridge::QbzBridge {
crate::refresh_devices();
}
pub fn search_live(self: Pin<&mut Self>, query: QString) {
crate::search_live(query.to_string());
}
pub fn search_submit(self: Pin<&mut Self>, query: QString) {
crate::search_submit(query.to_string());
}
pub fn cortinilla_dismiss(self: Pin<&mut Self>) {
crate::search_qt::dismiss();
}
pub fn cortinilla_move_selection(self: Pin<&mut Self>, delta: i32) {
crate::search_qt::move_selection(delta);
}
pub fn cortinilla_row_clicked(self: Pin<&mut Self>, index: i32) {
crate::search_qt::row_clicked(index);
}
pub fn cortinilla_view_more(self: Pin<&mut Self>, kind: QString) {
crate::cortinilla_view_more(kind.to_string());
}
pub fn cortinilla_search_all(self: Pin<&mut Self>) {
crate::cortinilla_search_all();
}
pub fn search_tab_changed(self: Pin<&mut Self>, tab: i32) {
crate::search_qt::tab_changed(tab);
}
pub fn search_load_more(self: Pin<&mut Self>, tab: i32) {
crate::search_load_more(tab);
}
pub fn search_filter_changed(self: Pin<&mut Self>, index: i32) {
crate::search_filter_changed(index);
}
pub fn toggle_intelligent_search(self: Pin<&mut Self>) {
crate::toggle_intelligent_search();
}
pub fn open_playlist(self: Pin<&mut Self>, playlist_id: QString) {
crate::open_playlist(playlist_id.to_string());
}
+4 -1
View File
@@ -36,6 +36,9 @@ mod suggestions_bridge;
// Hotkeys layer (2026-08-03 hotkeys-port contract, block B1): the
// QbzHotkeys singleton — the QML dispatcher's Rust brain (§1.1 pipeline).
mod hotkeys_bridge;
// Search domain (2026-08-03 cortinilla-parity contract, commit C0): the
// QbzSearch singleton, extracted from the QbzBridge god-object.
mod search_bridge;
mod local_bridge;
mod library_bridge;
mod album_bridge;
@@ -1813,7 +1816,7 @@ pub(crate) fn search_filter_changed(index: i32) {
pub(crate) fn toggle_intelligent_search() {
let next = search_qt::toggle_intelligent_search();
log::info!("[qbz-qt] intelligent_search -> {next}");
ui(move |mut b| b.as_mut().set_intelligent_search(next));
search_bridge::ui(move |mut b| b.as_mut().set_intelligent_search(next));
}
// ============================ Settings (phase 10) =========================
+219
View File
@@ -0,0 +1,219 @@
//! QbzSearch — the search domain bridge (cortinilla + results page).
//!
//! Extracted from the `QbzBridge` god-object per TRACK-RULES §2 (*"un
//! `#[cxx_qt::bridge]` por dominio, nunca un God-object"*) and the
//! 2026-08-03 cortinilla-parity contract §1.2, commit C0. `QbzBridge`
//! carried FIVE domains; this is the Search one, moved verbatim.
//!
//! Surface:
//! - **Cortinilla** (the live as-you-type dropdown): open/loading flags, the
//! live query, ONE JSON payload (`search_qt.rs` `CortinillaData`:
//! query/top/sections with controller-assigned flat indices), the keyboard
//! selection and its content-space scroll y.
//! - **Results page**: ONE JSON document (`search_qt.rs` `SearchPageDoc`),
//! the five-tab strip, per-type "Load more" and the searchType radios.
//! - The **intelligent-search kill switch** (the 2.0.0 opt-out module).
//!
//! Boot order matters: `ui()` below is a SILENT NO-OP until `boot()` has
//! registered this bridge's `CxxQtThread`, so a missing `QbzSearch.boot()`
//! in `qml/Main.qml` does not error — the dropdown simply never repaints
//! (TRACK-RULES §"Orden de boot de los singletons").
//!
//! Two renames landed with the move, both deliberate:
//! - `selected_index` -> `cortinilla_selected_index` (QML
//! `cortinillaSelectedIndex`). It is cortinilla-only state and `selectedIndex`
//! on an app-wide singleton is a collision hazard.
//! - `cortinilla_scroll_y` `f32` -> `f64`, unifying with the immersive twin
//! (`immersive_bridge.rs`), which was already `f64`.
#[cxx_qt::bridge]
pub mod qbz_search {
extern "C++" {
include!("cxx-qt-lib/qstring.h");
type QString = cxx_qt_lib::QString;
}
#[auto_cxx_name]
extern "RustQt" {
#[qobject]
#[qml_element]
#[qml_singleton]
// --- Cortinilla (the live dropdown) --------------------------------
#[qproperty(bool, cortinilla_open)]
#[qproperty(bool, cortinilla_loading)]
#[qproperty(QString, cortinilla_json)]
/// The LIVE header query, published SYNCHRONOUSLY on every keystroke
/// that passes the >= 2-char gate — ahead of the debounce and ahead
/// of any fetch. Slint's twin is `SearchState.cortinilla-query`
/// (`state.slint`), set at `main.rs:9647` before the debounce starts.
///
/// Declared here by the C0 move so the domain's surface lands in one
/// commit; it is WIRED in C2, which is what makes Enter carry the
/// text the user actually typed instead of the last successfully
/// loaded payload's query (contract §4.1 D-BUG-2).
#[qproperty(QString, cortinilla_query)]
/// Keyboard selection over the controller-assigned flat indices.
/// -1 = nothing selected. Written ONLY by the controller: hover is
/// purely visual and must never write it, or the mouse would hijack
/// the row that Enter activates (contract row E8).
#[qproperty(i32, cortinilla_selected_index)]
/// Content-space top-y of the selected row, for scroll-into-view.
#[qproperty(f64, cortinilla_scroll_y)]
// --- Results page --------------------------------------------------
#[qproperty(QString, search_json)]
// --- Intelligent Search kill switch --------------------------------
/// `ui_prefs.intelligent_search` mirror. Live-flippable, no restart.
/// The LIVE path is the Settings > Appearance row
/// (`settings_qt.rs` -> `search_qt::set_enabled`); there is no
/// app-menu surface in either frontend.
#[qproperty(bool, intelligent_search)]
type QbzSearch = super::QbzSearchRust;
/// Registers this bridge's Qt-thread hop. MUST be called from
/// `qml/Main.qml`; before it, `ui()` silently drops every update.
#[qinvokable]
fn boot(self: Pin<&mut QbzSearch>);
/// Header field keystrokes: drive the cortinilla live query.
#[qinvokable]
fn search_live(self: Pin<&mut QbzSearch>, query: QString);
/// Enter with the cortinilla closed: full results page (All tab).
#[qinvokable]
fn search_submit(self: Pin<&mut QbzSearch>, query: QString);
/// Cortinilla: Esc / click-outside / idle-close / page change.
#[qinvokable]
fn cortinilla_dismiss(self: Pin<&mut QbzSearch>);
/// Arrow keys: delta -1 (up) / +1 (down) through the flat list.
#[qinvokable]
fn cortinilla_move_selection(self: Pin<&mut QbzSearch>, delta: i32);
/// Row click or Enter on the keyboard-selected row.
#[qinvokable]
fn cortinilla_row_clicked(self: Pin<&mut QbzSearch>, index: i32);
/// Section "View more" (kind: album | track | artist | playlist).
#[qinvokable]
fn cortinilla_view_more(self: Pin<&mut QbzSearch>, kind: QString);
/// The Enter affordance with no keyboard selection: Search > All.
#[qinvokable]
fn cortinilla_search_all(self: Pin<&mut QbzSearch>);
/// Results page: the five-tab strip.
#[qinvokable]
fn search_tab_changed(self: Pin<&mut QbzSearch>, tab: i32);
/// Per-type tab "Load more".
#[qinvokable]
fn search_load_more(self: Pin<&mut QbzSearch>, tab: i32);
/// searchType filter radios (0 = none, 1..5 = the chips).
#[qinvokable]
fn search_filter_changed(self: Pin<&mut QbzSearch>, index: i32);
/// Intelligent-search toggle. NOTE: this has zero QML callers — the
/// live path is the Settings row. Deleted in C1 (contract DEAD-1);
/// moved here first so every intermediate commit compiles.
#[qinvokable]
fn toggle_intelligent_search(self: Pin<&mut QbzSearch>);
}
impl cxx_qt::Threading for QbzSearch {}
}
use core::pin::Pin;
use std::sync::OnceLock;
use cxx_qt::CxxQtThread;
use cxx_qt::Threading as _;
use cxx_qt_lib::QString;
use qbz_search::QbzSearch;
/// Rust side of the bridge. Plain storage; every field is driven through the
/// generated `set_*` methods on the Qt thread.
pub struct QbzSearchRust {
cortinilla_open: bool,
cortinilla_loading: bool,
cortinilla_json: QString,
cortinilla_query: QString,
cortinilla_selected_index: i32,
cortinilla_scroll_y: f64,
search_json: QString,
intelligent_search: bool,
}
impl Default for QbzSearchRust {
fn default() -> Self {
Self {
cortinilla_open: false,
cortinilla_loading: false,
cortinilla_json: QString::from("{}"),
cortinilla_query: QString::from(""),
cortinilla_selected_index: -1,
cortinilla_scroll_y: 0.0,
search_json: QString::from("{}"),
intelligent_search: crate::search_qt::intelligent_search_pref(),
}
}
}
static QT_THREAD: OnceLock<CxxQtThread<QbzSearch>> = OnceLock::new();
/// Queue a search-bridge mutation onto the Qt event loop. NO-OP before
/// `boot()` registers the thread — by design, and the reason a missing
/// `QbzSearch.boot()` shows up as "the dropdown never updates" rather than
/// as an error.
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzSearch>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);
}
}
impl qbz_search::QbzSearch {
pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] search Qt thread already registered");
}
}
pub fn search_live(self: Pin<&mut Self>, query: QString) {
crate::search_live(query.to_string());
}
pub fn search_submit(self: Pin<&mut Self>, query: QString) {
crate::search_submit(query.to_string());
}
pub fn cortinilla_dismiss(self: Pin<&mut Self>) {
crate::search_qt::dismiss();
}
pub fn cortinilla_move_selection(self: Pin<&mut Self>, delta: i32) {
crate::search_qt::move_selection(delta);
}
pub fn cortinilla_row_clicked(self: Pin<&mut Self>, index: i32) {
crate::search_qt::row_clicked(index);
}
pub fn cortinilla_view_more(self: Pin<&mut Self>, kind: QString) {
crate::cortinilla_view_more(kind.to_string());
}
pub fn cortinilla_search_all(self: Pin<&mut Self>) {
crate::cortinilla_search_all();
}
pub fn search_tab_changed(self: Pin<&mut Self>, tab: i32) {
crate::search_qt::tab_changed(tab);
}
pub fn search_load_more(self: Pin<&mut Self>, tab: i32) {
crate::search_load_more(tab);
}
pub fn search_filter_changed(self: Pin<&mut Self>, index: i32) {
crate::search_filter_changed(index);
}
pub fn toggle_intelligent_search(self: Pin<&mut Self>) {
crate::toggle_intelligent_search();
}
}
+16 -12
View File
@@ -690,10 +690,10 @@ fn is_current_cort_version(v: u64) -> bool {
fn set_cortinilla_open(open: bool) {
CORT_OPEN.store(open, Ordering::SeqCst);
crate::ui(move |mut b| {
crate::search_bridge::ui(move |mut b| {
b.as_mut().set_cortinilla_open(open);
if !open {
b.as_mut().set_selected_index(-1);
b.as_mut().set_cortinilla_selected_index(-1);
}
});
}
@@ -710,10 +710,10 @@ pub fn cortinilla_open() -> bool {
CORT_OPEN.load(Ordering::SeqCst)
}
fn set_selected(index: i32, scroll_y: f32) {
fn set_selected(index: i32, scroll_y: f64) {
CURRENT_SEL.store(index, Ordering::SeqCst);
crate::ui(move |mut b| {
b.as_mut().set_selected_index(index);
crate::search_bridge::ui(move |mut b| {
b.as_mut().set_cortinilla_selected_index(index);
b.as_mut().set_cortinilla_scroll_y(scroll_y);
});
}
@@ -739,7 +739,7 @@ pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
let version = next_cort_version();
// Query echo + loading flag ride the cortinillaLoading property and a
// minimal payload (skeleton rows are pure QML).
crate::ui(move |mut b| {
crate::search_bridge::ui(move |mut b| {
b.as_mut().set_cortinilla_loading(true);
});
@@ -751,7 +751,7 @@ pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
Ok(r) => r,
Err(e) => {
log::error!("[qbz-qt] cortinilla load failed: {e}");
crate::ui(move |mut b| {
crate::search_bridge::ui(move |mut b| {
if is_current_cort_version(version) {
b.as_mut().set_cortinilla_loading(false);
}
@@ -778,7 +778,7 @@ pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
}
let missing = attach_urls(urls);
*LAST_CORT.lock().unwrap() = Some(data.clone());
crate::ui(move |mut b| {
crate::search_bridge::ui(move |mut b| {
if is_current_cort_version(version) {
let json = serde_json::to_string(&data).unwrap_or_else(|_| "{}".into());
b.as_mut().set_cortinilla_json(QString::from(json.as_str()));
@@ -805,7 +805,7 @@ pub async fn live(runtime: &Arc<AppRuntime<LoggingAdapter>>, query: &str) {
let _ = attach_urls(urls);
let data = data.clone();
*LAST_CORT.lock().unwrap() = Some(data.clone());
crate::ui(move |mut b| {
crate::search_bridge::ui(move |mut b| {
let json = serde_json::to_string(&data).unwrap_or_else(|_| "{}".into());
b.as_mut().set_cortinilla_json(QString::from(json.as_str()));
});
@@ -859,8 +859,12 @@ pub fn move_selection(delta: i32) {
set_selected(new_index, scroll_y);
}
fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f32 {
let mut y = 6.0f32;
fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f64 {
// NOTE: the `6.0` base is WRONG — it mirrors a 6 px padding that is a
// Flickable VIEWPORT margin in the QML, not content space, so every row
// is reported 6 px low (contract row E4 / D-BUG-3). C0 is a pure move:
// the base is corrected to 0.0 in C4, together with its unit test.
let mut y = 6.0f64;
if let Some(top) = &data.top {
if top.flat_index == flat_index {
return y + 4.0 + 22.0;
@@ -1818,7 +1822,7 @@ pub(crate) fn apply_favorite_change(kind: &str, id: &str, favorite: bool) {
fn publish_page(doc: &SearchPageDoc) {
let json = serde_json::to_string(doc).unwrap_or_else(|_| "{}".into());
crate::ui(move |mut b| {
crate::search_bridge::ui(move |mut b| {
b.as_mut().set_search_json(QString::from(json.as_str()));
});
}
+1 -1
View File
@@ -1806,7 +1806,7 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
// effect immediately — two switches, one setting, different
// behaviour.
crate::search_qt::set_enabled(value);
crate::ui(move |mut b| b.as_mut().set_intelligent_search(value));
crate::search_bridge::ui(move |mut b| b.as_mut().set_intelligent_search(value));
Ok(Apply::None)
}
// Section-nav placement (Slint ShellState.nav-in-sidebar /