fix(qt): nav flyout entries land on their own tab

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
This commit is contained in:
vicrodh committed 2026-08-10 18:57:02 -06:00
1 parent f24da6c784
commit 98dfcd8173
4 files changed
+98 -63

No files matched your search

+28
View File
@@ -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
}
}
+9 -63
View File
@@ -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 }
+35
View File
@@ -1818,6 +1818,14 @@ static LIBRARY_LOADED: Mutex<bool> = 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
+26
View File
@@ -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);
}