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:
4 files changed
+98
-63
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user