From 98dfcd81733b35902d33ce5a2c1951dbaec5843d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:55:10 -0600 Subject: [PATCH] fix(qt): nav flyout entries land on their own tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every flyout entry (Discover > For You, Library > Albums, Local > Folders...) navigated to the section but landed on its default tab: the port handed the tab to the mounted view through a depth-capped scene search (NavFlyout.findTabHost, cap 7), and the ContentRouter extraction (kiosk port D3) added a mount level the walk never reached — flagged as a latent desktop regression in the kiosk audit (2026-08-02 AUDIT-REPORTS.md, finding 11) and confirmed by smoke test. The tab now travels the way this codebase crosses documents: the bridge. NavFlyout.activate calls the new QbzShell.navigateToTab(view, tab), which publishes navTab/navTabView/navTabSeq, and a Binding in ContentRouter applies it to the mounted view's activeTab — the same pattern as the MyQBZ kind discriminator. The seq counter re-applies on a repeated click of the same entry, the navTabView guard keeps a cross-view request off the outgoing view, and a plain navigateTo clears the request so section navigation never drags a stale tab along. A click for the already-mounted view skips navigation (no duplicate history entry) and re-applies the tab live. Verified by smoke: cross-section, same-section, repeated-click and plain-navigation cases all land where Slint lands. km --- crates/qbz-qt/qml/shell/ContentRouter.qml | 28 +++++++++ crates/qbz-qt/qml/shell/NavFlyout.qml | 72 +++-------------------- crates/qbz-qt/src/main.rs | 35 +++++++++++ crates/qbz-qt/src/shell_bridge.rs | 26 ++++++++ 4 files changed, 98 insertions(+), 63 deletions(-) diff --git a/crates/qbz-qt/qml/shell/ContentRouter.qml b/crates/qbz-qt/qml/shell/ContentRouter.qml index 3d9a6d89e..4dbce9395 100644 --- a/crates/qbz-qt/qml/shell/ContentRouter.qml +++ b/crates/qbz-qt/qml/shell/ContentRouter.qml @@ -145,4 +145,32 @@ Item { || QbzShell.currentView === "collections") restoreMode: Binding.RestoreNone } + + // NavFlyout landing tab (Discover > For You, Library > Albums, Local > + // Folders...). The request rides the bridge (QbzShell.navigateToTab -> + // navTab/navTabView/navTabSeq) because a QML id cannot cross documents; + // this Binding is the ONLY writer. It replaces NavFlyout's depth-capped + // scene search (findTabHost), which the ContentRouter extraction (kiosk + // port D3) silently outran — every flyout entry landed on the section's + // default tab instead of its own. + // + // `navTabSeq` in the value expression forces re-application when the + // same entry is clicked twice in a row (a bare navTab would not + // renotify). `navTabView === currentView` keeps a cross-view request + // from being stamped on the outgoing view before the route lands. The + // typeof guard leaves non-tabbed views alone rather than warning about + // a non-existent property. RestoreNone, like the `kind` Binding above: + // the target is destroyed on unload. An in-view tab-bar click writes + // `activeTab` directly and simply wins until the next flyout click — + // this Binding does not refire without a dependency change. + Binding { + target: viewLoader.item + property: "activeTab" + when: viewLoader.item !== null + && QbzShell.navTab !== "" + && QbzShell.navTabView === QbzShell.currentView + && typeof viewLoader.item.activeTab === "string" + value: (QbzShell.navTabSeq, QbzShell.navTab) + restoreMode: Binding.RestoreNone + } } diff --git a/crates/qbz-qt/qml/shell/NavFlyout.qml b/crates/qbz-qt/qml/shell/NavFlyout.qml index 0eb1fd4ef..26cdd6556 100644 --- a/crates/qbz-qt/qml/shell/NavFlyout.qml +++ b/crates/qbz-qt/qml/shell/NavFlyout.qml @@ -259,72 +259,18 @@ Item { // Entry activation // --------------------------------------------------------------------- - // The tab a just-navigated view must land on, held until the view mounts. - property string pendingTab: "" - property string pendingView: "" - + // Entries with a tab go through the bridge seam (navigateToTab), which + // both navigates and hands the tab to ContentRouter's Binding — the + // Slint behaviour (on_header_menu_navigate selects the landing tab + // per route, crates/qbz/src/main.rs:18026-18046). Entries without one + // (Collections, Mixtapes) are a plain section navigation. function activate(entry) { panel.close() if (!entry || entry.enabled === false || entry.view === "") return - if (QbzShell.currentView === entry.view) { - // Already mounted — no currentViewChanged will fire. - applyTab(entry.tab) - return - } - nav.pendingTab = entry.tab - nav.pendingView = entry.view - QbzShell.navigateTo(entry.view) - } - - Connections { - target: QbzShell - function onCurrentViewChanged() { - if (nav.pendingTab === "") return - var wanted = nav.pendingView - var tab = nav.pendingTab - nav.pendingTab = "" - nav.pendingView = "" - // A navigation somewhere else (back/forward, a card click) beat us - // to it — drop the request instead of stamping a stale tab later. - if (QbzShell.currentView !== wanted) return - // The content Loader instantiates the view from its own binding on - // the same property; binding order between it and this handler is - // undefined, so apply after the current pass. - Qt.callLater(function () { nav.applyTab(tab) }) - } - } - - // Selects a tab INSIDE the mounted view. - // - // TEMPORARY BRIDGE (the permanent fix is in the handoff report): the shell - // router only carries a view id, and the three tabbed views own their tab - // as plain QML state (HomeView/LibraryView/LocalLibraryView `activeTab`). - // There is no bridge property to hand a tab through yet, so the mounted - // view is located in the scene and its `activeTab` assigned directly. The - // search is depth-capped (the view sits 5 levels under the window content - // item: screenLoader > AppShell > contentFrame > Loader > view), so it - // never descends into delegate forests, and it degrades to a plain - // section navigation if nothing is found. - function applyTab(tab) { - if (!tab || tab === "") return - var top = nav - while (top.parent) top = top.parent - var view = findTabHost(top, 0) - if (view) view.activeTab = tab - } - - function findTabHost(item, depth) { - if (!item || depth > 7) return null - // Only HomeView / LibraryView / LocalLibraryView expose `activeTab`, - // and exactly one of them is mounted at a time (one content Loader). - if (typeof item.activeTab === "string") return item - var kids = item.children - if (!kids) return null - for (var i = 0; i < kids.length; i++) { - var hit = findTabHost(kids[i], depth + 1) - if (hit) return hit - } - return null + if (entry.tab && entry.tab !== "") + QbzShell.navigateToTab(entry.view, entry.tab) + else + QbzShell.navigateTo(entry.view) } QbzTheme { id: theme } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index bd7286894..3e23ed6d6 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -1818,6 +1818,14 @@ static LIBRARY_LOADED: Mutex = Mutex::new(false); /// Sidebar navigation: record the view and lazy-load its data. pub(crate) fn navigate_to(view: &str) { nav_qt::record(view); + // A plain section navigation carries no landing tab: clear any request + // a flyout click left behind, or the next mount of a tabbed view would + // be dragged onto a stale tab. + shell_bridge::ui(|mut b| { + if !b.as_ref().nav_tab().is_empty() { + b.as_mut().set_nav_tab(QString::default()); + } + }); LAST_DETAIL.lock().unwrap().0.clear(); if view == "library" { load_library_once(); @@ -1842,6 +1850,33 @@ pub(crate) fn navigate_to(view: &str) { // blacklist_qt::open_manager), never through the sidebar. } +/// NavFlyout entry activation: navigate to `view` AND land on its internal +/// `tab` (Slint's `on_header_menu_navigate` per-route tab selection, e.g. +/// `discover-forYou` → `home::select_tab`, crates/qbz/src/main.rs:18026). +/// +/// The tab travels on the bridge (navTab/navTabView/navTabSeq) and is +/// applied by a Binding in ContentRouter.qml — NOT by locating the view in +/// the scene: the pre-bridge port did that with a depth-capped tree walk +/// (NavFlyout's findTabHost), which the ContentRouter extraction (kiosk +/// port D3) silently outran, leaving every flyout entry navigating to the +/// section's default tab. +/// +/// A click for the view already mounted skips `navigate_to`: it would +/// record a duplicate history entry, and the property bump below is enough +/// to re-apply the tab live. +pub(crate) fn navigate_to_tab(view: &str, tab: &str) { + if nav_qt::current_view() != view { + navigate_to(view); + } + let (view, tab) = (view.to_string(), tab.to_string()); + shell_bridge::ui(move |mut b| { + let seq = b.as_ref().nav_tab_seq() + 1; + b.as_mut().set_nav_tab_view(QString::from(view)); + b.as_mut().set_nav_tab(QString::from(tab)); + b.as_mut().set_nav_tab_seq(seq); + }); +} + // ============================ i18n (phase 20) ============================== /// The active detail view ("album"/"artist" + id) — re-published on a live diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 3e1b79753..114ede8d0 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -50,6 +50,16 @@ pub mod qbz_shell { // Nav history (src/nav_qt.rs): #[qproperty(bool, can_back)] #[qproperty(bool, can_forward)] + // --- Flyout landing tab (NavFlyout rows carry view + tab) ---------- + // The tab a nav-flyout click must land on: Discover > For You is + // nav_tab_view="home" + nav_tab="forYou". Written by + // `navigate_to_tab`, cleared by a plain `navigate_to`, applied to the + // mounted view by a Binding in ContentRouter.qml. `nav_tab_seq` + // bumps per request so re-clicking the SAME entry re-applies the tab + // (a bare string would not renotify). + #[qproperty(QString, nav_tab)] + #[qproperty(QString, nav_tab_view)] + #[qproperty(i32, nav_tab_seq)] // Now-playing bar mode (phase 18): 0 New / 1 Classic / 2 Small / // 3 Large — the ui_prefs npb_mode key, live-switchable. @@ -326,6 +336,12 @@ pub mod qbz_shell { /// and lazy-load its data on first visit. #[qinvokable] fn navigate_to(self: Pin<&mut QbzShell>, view: QString); + /// NavFlyout entry activation: navigate AND land on a view-internal + /// tab ("home" + "forYou"). The tab rides the bridge (navTab / + /// navTabView / navTabSeq) because a QML id cannot cross documents — + /// ContentRouter applies it to the mounted view through a Binding. + #[qinvokable] + fn navigate_to_tab(self: Pin<&mut QbzShell>, view: QString, tab: QString); /// Open a url in the system browser (the Slint /// AlbumActions.open-external-link). Spawns xdg-open detached. #[qinvokable] @@ -525,6 +541,9 @@ pub struct QbzShellRust { current_view: QString, can_back: bool, can_forward: bool, + nav_tab: QString, + nav_tab_view: QString, + nav_tab_seq: i32, npb_mode: i32, large_visualizer_on: bool, large_spectrum_mode: i32, @@ -594,6 +613,9 @@ impl Default for QbzShellRust { queue_open: false, current_view: QString::from("home"), can_back: false, + nav_tab: QString::default(), + nav_tab_view: QString::default(), + nav_tab_seq: 0, can_forward: false, npb_mode: crate::settings_qt::npb_mode_index(), large_visualizer_on: crate::settings_qt::large_visualizer_on(), @@ -805,6 +827,10 @@ impl qbz_shell::QbzShell { crate::navigate_to(&view.to_string()); } + pub fn navigate_to_tab(self: Pin<&mut Self>, view: QString, tab: QString) { + crate::navigate_to_tab(&view.to_string(), &tab.to_string()); + } + pub fn npb_set_mode(self: Pin<&mut Self>, mode: i32) { crate::npb_set_mode(mode); }