From 7f609a55e5de9175b1b96765cc1b206996c51eb9 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:31:59 -0600 Subject: [PATCH 01/27] feat(qt): Copy submenus open on hover and hold their parent open CardMenu entries declare a submenu as data (`submenu: [ {label, icon, action} ]`); the row builds a child CardMenu on first use, opens it when the pointer rests on the entry (or clicks it), and closes it when the pointer moves to another entry. While either menu is hovered the pair stays open; leaving both for 350 ms, pressing Escape or clicking outside closes both. Picking in the child closes the parent too. The track row, the Popular Tracks row and the player bar's "+" menu declare their Copy entries this way instead of re-opening a second CardMenu at the row's saved click position. cc --- crates/qbz-qt/qml/controls/CardMenu.qml | 106 +++++++++++++++++- crates/qbz-qt/qml/controls/QbzContextMenu.qml | 5 + crates/qbz-qt/qml/rows/TrackRow.qml | 39 ++----- crates/qbz-qt/qml/shell/PlayerBar.qml | 39 +++---- crates/qbz-qt/qml/views/ArtistView.qml | 36 +++--- 5 files changed, 143 insertions(+), 82 deletions(-) diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index fb78981f1..a00182bba 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -34,6 +34,87 @@ QbzContextMenu { property var entries: [] signal picked(string action) + // --- Submenus (2026-09-13) ------------------------------------------ + // An entry with `submenu: [ { label, icon, action }, … ]` opens a + // second CardMenu beside its row ON HOVER (a click does the same and + // never closes this one). The child forwards its pick here, so hosts + // handle sub-actions in the same `onPicked`. Both menus stay open + // while the pointer is over EITHER; once it has left both for a + // moment they close together, as does a press outside or Escape. + property var _sub: null + property var _subRow: null + // The child is the same type; QML refuses a type that contains itself + // ("instantiated recursively"), so it is created from its file at run + // time instead of through a Component. + function _makeSubmenu() { + var component = Qt.createComponent("CardMenu.qml") + if (component.status !== Component.Ready) { + console.warn("[CardMenu] submenu component: " + component.errorString()) + return null + } + var sub = component.createObject(cmRoot.contentItem, { + "kioskHost": cmRoot.kioskHost, + "menuWidth": cmRoot.menuWidth + }) + if (sub) + sub.picked.connect(function (a) { + cmRoot.close() + cmRoot.picked(a) + }) + return sub + } + function hasSubmenu(entry) { + return entry && entry.submenu !== undefined && entry.submenu !== null + && entry.submenu.length !== undefined && entry.submenu.length > 0 + } + function openSubmenu(row) { + if (cmRoot._subRow === row && cmRoot._sub && cmRoot._sub.opened) + return + if (!cmRoot._sub) + cmRoot._sub = cmRoot._makeSubmenu() + if (!cmRoot._sub) + return + cmRoot._subRow = row + cmRoot._sub.entries = row.modelData.submenu + // Beside the row: to the right, or to the left when the window + // has no room there. A 4px overlap leaves no gap to cross. + var win = row.Window.window + var g = row.mapToItem(null, 0, 0) + var gx = cmRoot.x + cmRoot.width - 4 + if (win && gx + cmRoot._sub.width > win.width - 8) + gx = cmRoot.x - cmRoot._sub.width + 4 + cmRoot._sub._place(gx, g.y - cmRoot._sub.topPadding, win) + leaveTimer.stop() + } + function closeSubmenu() { + cmRoot._subRow = null + if (cmRoot._sub && cmRoot._sub.opened) + cmRoot._sub.close() + leaveTimer.stop() + } + readonly property bool subOpen: cmRoot._sub !== null && cmRoot._sub.opened + readonly property bool pairHovered: cmRoot.menuHovered + || (cmRoot._sub !== null && cmRoot._sub.menuHovered) + onPairHoveredChanged: { + if (!cmRoot.subOpen) + return + if (cmRoot.pairHovered) + leaveTimer.stop() + else + leaveTimer.restart() + } + Timer { + id: leaveTimer + interval: 350 + onTriggered: { + if (cmRoot.subOpen && !cmRoot.pairHovered) { + cmRoot.closeSubmenu() + cmRoot.close() + } + } + } + onClosed: closeSubmenu() + QbzTheme { id: theme } Repeater { @@ -43,9 +124,10 @@ QbzContextMenu { required property var modelData readonly property bool isSep: modelData.sep === true + readonly property bool hasSub: cmRoot.hasSubmenu(modelData) readonly property bool rowEnabled: modelData.enabled !== false readonly property bool isDanger: modelData.danger === true - readonly property bool hot: cmiArea.containsMouse && rowEnabled + readonly property bool hot: (cmiArea.containsMouse || cmRoot._subRow === row) && rowEnabled width: parent ? parent.width : 0 height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33) @@ -80,7 +162,7 @@ QbzContextMenu { } Text { height: parent.height - width: parent.width - 23 - (row.modelData.submenu === true ? 22 : 0) + width: parent.width - 23 - (row.hasSub ? 22 : 0) text: row.modelData.label || "" color: row.isDanger ? theme.danger : (row.hot ? theme.textPrimary : theme.textSecondary) @@ -88,10 +170,9 @@ QbzContextMenu { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } - // Submenu marker (`submenu: true`): the host answers the pick - // by opening a second menu on the same anchor. + // Submenu marker: the row opens its child menu on hover. QbzIcon { - visible: row.modelData.submenu === true + visible: row.hasSub name: "chevron-right" width: 14 height: 14 @@ -110,9 +191,24 @@ QbzContextMenu { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { + if (row.hasSub) { + cmRoot.openSubmenu(row) + return + } cmRoot.close() cmRoot.picked(row.modelData.action) } + // Hovering a submenu row opens its child; hovering any other + // row puts it away. Leaving the row itself does nothing — + // the pointer is usually on its way into the child. + onContainsMouseChanged: { + if (!containsMouse) + return + if (row.hasSub) + cmRoot.openSubmenu(row) + else if (cmRoot.subOpen) + cmRoot.closeSubmenu() + } } } } diff --git a/crates/qbz-qt/qml/controls/QbzContextMenu.qml b/crates/qbz-qt/qml/controls/QbzContextMenu.qml index ea97ed870..14f2b2890 100644 --- a/crates/qbz-qt/qml/controls/QbzContextMenu.qml +++ b/crates/qbz-qt/qml/controls/QbzContextMenu.qml @@ -49,11 +49,16 @@ Popup { QbzTheme { id: theme } + /// The pointer is over the panel (padding included). CardMenu's + /// submenus read it on both menus to decide when leaving closes them. + readonly property bool menuHovered: panelHover.hovered + background: Rectangle { color: theme.surfaceMain radius: theme.radiusSm border.width: 1 border.color: theme.borderMuted + HoverHandler { id: panelHover } } implicitHeight: col.implicitHeight + topPadding + bottomPadding height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 36d82da3f..f602df425 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -1275,43 +1275,19 @@ Rectangle { /// Build the popup on first use, then open it. Called by `openRowMenu` /// below, which owns the empty-menu guard. function openRowMenuLazy(anchor, x, y) { - root._menuAnchor = anchor - root._menuX = x - root._menuY = y rowMenuLoader.active = true rowMenuLoader.item.openAtCursor(anchor, x, y) } - // --- Copy submenu + clipboard (2026-09-13) -------------------------------- - // Both lazy, like the row menu: a list row must not carry a Popup tree - // or a TextEdit per delegate. Reopened where the row menu was. - property Item _menuAnchor: null - property real _menuX: 0 - property real _menuY: 0 - Loader { - id: copyMenuLoader - active: false - sourceComponent: CardMenu { - kioskHost: root.kioskHost - menuWidth: 224 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { root.copyRow(a) } - } - } + // --- Copy (2026-09-13) ---------------------------------------------------- + // The Copy entry is a CardMenu submenu (hover-opened, declared as data + // in menuModel); the clipboard carrier stays lazy, like the row menu — + // a list row must not carry a TextEdit per delegate. Loader { id: clipboardLoader active: false sourceComponent: QbzClipboard { } } - function openCopyMenu() { - if (!root._menuAnchor) - return - copyMenuLoader.active = true - copyMenuLoader.item.openAtCursor(root._menuAnchor, root._menuX, root._menuY) - } function copyRow(which) { var parts = [root.item.title || ""] if (which === "copy-full") { @@ -1426,7 +1402,10 @@ Rectangle { m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) // Copy (2026-09-13): a submenu — the track name, or "Track - Album - Artist". if ((root.item.title || "") !== "") - m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [ + { "label": t("Track name", r), "icon": "copy", "action": "copy-title" }, + { "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" } + ] }) return m } @@ -1453,7 +1432,7 @@ Rectangle { else QbzAlbum.openAlbumFrom(root.item.albumId, root.item.album || "", root.item.artist || "") } else if (a === "buy") QbzAlbum.buyTrack(root.item.id || "") - else if (a === "copy") root.openCopyMenu() + else if (a === "copy-title" || a === "copy-full") root.copyRow(a) else if (a === "favorite") root.toggleFavorite() else if (a === "mixtape") root.mixtapeRequested() // Add to playlist. The internal arm is the CATALOG one and nothing diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 01fc58619..4f478b35b 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -657,7 +657,7 @@ Rectangle { favorite: root.npFavorite ephemeral: root.npEphemeral tooltip: root.tooltip - onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -680,7 +680,7 @@ Rectangle { playCircle: true favorite: root.npFavorite tooltip: root.tooltip - onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -908,25 +908,8 @@ Rectangle { // "Add to…" flyout behind the transport "+" (TransportControls.slint's // add-menu), on the shared CardMenu surface. Same seven entries, same // order, same icons. - // The "+" button that opened the add-menu: the Copy submenu reopens on it. - property Item addAnchor: null + // The Copy submenu of the add-menu writes through this carrier. QbzClipboard { id: npClipboard } - CardMenu { - id: npCopyMenu - menuWidth: 232 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { - var parts = [QbzPlayer.npTitle || ""] - if (a === "copy-full") { - if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) - if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) - } - npClipboard.copy(parts.join(" - ")) - } - } CardMenu { id: addMenu @@ -992,13 +975,16 @@ Rectangle { "action": "buy" }) } - // Copy (2026-09-13): a submenu — the track name, or + // Copy (2026-09-13): a hover-opened submenu — the track name, or // "Track - Album - Artist". m.push({ "label": QbzSession.tr("Copy", QbzSession.trRev), "icon": "copy", "action": "copy", - "submenu": true + "submenu": [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] }) return m.concat(root.abEntries()) } @@ -1017,8 +1003,13 @@ Rectangle { QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) } else if (a === "buy") { if (id !== "") QbzAlbum.buyTrack(id) - } else if (a === "copy") { - if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor) + } else if (a === "copy-title" || a === "copy-full") { + var parts = [QbzPlayer.npTitle || ""] + if (a === "copy-full") { + if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) + if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) + } + npClipboard.copy(parts.join(" - ")) } else if (a === "ab-mark") { QbzPlayer.abMark() } else if (a === "ab-clear") { diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index c9ef633f0..19171aba8 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -1695,7 +1695,7 @@ Rectangle { enabled: !popRow.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: function (mouse) { popRow._menuAnchor = moreArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } } } } @@ -1779,7 +1779,10 @@ Rectangle { m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) if (!popRow.pulledDead) m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) - m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [ + { "label": t("Track name", r), "icon": "copy", "action": "copy-title" }, + { "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" } + ] }) return m } onPicked: function (a) { @@ -1814,27 +1817,14 @@ Rectangle { else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) else if (a === "track-info") root.openTrackInfo(id) else if (a === "buy") QbzAlbum.buyTrack(id) - else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY) - } - } - // Copy submenu (2026-09-13), reopened where the row menu was. - property Item _menuAnchor: null - property real _menuX: 0 - property real _menuY: 0 - CardMenu { - id: popCopyMenu - menuWidth: 224 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { - var parts = [popRow.row.title || ""] - if (a === "copy-full") { - if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) - if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + else if (a === "copy-title" || a === "copy-full") { + var parts = [popRow.row.title || ""] + if (a === "copy-full") { + if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) + if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + } + artistClipboard.copy(parts.join(" - ")) } - artistClipboard.copy(parts.join(" - ")) } } @@ -1860,7 +1850,7 @@ Rectangle { id: popRcArea anchors.fill: parent acceptedButtons: Qt.RightButton - onClicked: function (mouse) { popRow._menuAnchor = popRcArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } } } From e546e6614eb40400dd591c2da04a25460166e9b5 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:31:59 -0600 Subject: [PATCH 02/27] fix(qt): catalog placeholder names never render as artists or composers Qobuz fills missing credits with strings such as "Not Documented", "Unknown Artist" or "N/A". `qbz_text_utils::names::is_placeholder_name` recognises them (case-insensitive, hyphen/underscore folded) and the album document, the track info modal and the album info modal drop those names from credits, composers, featured lists and the header's artist fallback, dropping a row entirely when nothing legible is left. cc --- crates/qbz-qt/src/album_info_qt.rs | 2 + crates/qbz-qt/src/album_qt.rs | 46 +++++++++++++++++---- crates/qbz-qt/src/track_info_qt.rs | 7 +++- crates/qbz-text-utils/src/lib.rs | 1 + crates/qbz-text-utils/src/names.rs | 66 ++++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 crates/qbz-text-utils/src/names.rs diff --git a/crates/qbz-qt/src/album_info_qt.rs b/crates/qbz-qt/src/album_info_qt.rs index 5f4c2dce6..14541880a 100644 --- a/crates/qbz-qt/src/album_info_qt.rs +++ b/crates/qbz-qt/src/album_info_qt.rs @@ -121,6 +121,8 @@ fn map_track(index: usize, t: &Track, album_artist: &str) -> InfoTrack { let performers: Vec = parse_performers(t.performers.as_deref().unwrap_or_default()) .into_iter() + // Catalog fillers ("Not Documented") are not people (2026-09-13). + .filter(|p| !qbz_text_utils::names::is_placeholder_name(&p.name)) .map(|p| PerformerRow { roles: roles_suffix(&p.roles), primary_role: p diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index 3190eaebb..14bba645f 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -485,10 +485,14 @@ fn credit_role(roles: Option<&Vec>) -> String { /// album.rs `build_credits` (releaseArtistsMapper parity, minus the /// "VARIOUS"-composer drop it also applies). fn build_credits(album: &Album) -> Vec<(String, String, String)> { + use qbz_text_utils::names::is_placeholder_name; + // Catalog fillers ("Not Documented") are not credits — dropped here so + // the header never prints them beside the real artist (2026-09-13). let mut credits: Vec<(String, String, String)> = match album.artists.as_ref().filter(|v| !v.is_empty()) { Some(list) => list .iter() + .filter(|a| !is_placeholder_name(&a.name)) .map(|a| { ( a.name.clone(), @@ -497,15 +501,22 @@ fn build_credits(album: &Album) -> Vec<(String, String, String)> { ) }) .collect(), - None => vec![( - album.artist.name.clone(), - album.artist.id.to_string(), - String::new(), - )], + None => Vec::new(), }; + if credits.is_empty() && !is_placeholder_name(&album.artist.name) { + credits.push(( + album.artist.name.clone(), + album.artist.id.to_string(), + String::new(), + )); + } // Album-level composer appended last, unless it's the localized // "Various Composers" placeholder (releaseArtistsMapper's VARIOUS drop). - if let Some(composer) = album.composer.as_ref().filter(|c| !c.name.is_empty()) { + if let Some(composer) = album + .composer + .as_ref() + .filter(|c| !c.name.is_empty() && !is_placeholder_name(&c.name)) + { if !composer.name.to_uppercase().contains("VARIOUS") { credits.push(( composer.name.clone(), @@ -542,6 +553,7 @@ pub(crate) fn featured_list( }; qbz_qobuz::performers::featured_artists(performers, artist, title) .into_iter() + .filter(|name| !qbz_text_utils::names::is_placeholder_name(name)) .map(|name| { let id = credits .iter() @@ -636,8 +648,22 @@ pub async fn load_album( other => other.to_string(), })?; - let artist = album.artist.name.clone(); - let artist_id = album.artist.id.to_string(); + // The header's artist: never a catalog filler — fall back to the first + // real name in `artists`, else nothing (the credits line still shows + // whatever real names exist). + let (artist, artist_id) = if qbz_text_utils::names::is_placeholder_name(&album.artist.name) { + album + .artists + .as_ref() + .and_then(|list| { + list.iter() + .find(|a| !qbz_text_utils::names::is_placeholder_name(&a.name)) + }) + .map(|a| (a.name.clone(), a.id.to_string())) + .unwrap_or_default() + } else { + (album.artist.name.clone(), album.artist.id.to_string()) + }; let credits = build_credits(&album); let date_display = format_release_date( album @@ -1168,7 +1194,9 @@ fn album_to_card(album: &Album) -> AlbumCardData { } fn card_artist(album: &Album) -> (String, String) { - if !album.artist.name.is_empty() { + if !album.artist.name.is_empty() + && !qbz_text_utils::names::is_placeholder_name(&album.artist.name) + { return (album.artist.name.clone(), album.artist.id.to_string()); } if let Some(list) = album.artists.as_ref() { diff --git a/crates/qbz-qt/src/track_info_qt.rs b/crates/qbz-qt/src/track_info_qt.rs index 27dae48c8..8007fb610 100644 --- a/crates/qbz-qt/src/track_info_qt.rs +++ b/crates/qbz-qt/src/track_info_qt.rs @@ -65,8 +65,13 @@ fn map(track: Track) -> TrackInfoDoc { .map(|(role, names)| CreditRow { role: format_role_label(&role).to_uppercase(), role_raw: role, - names, + // Catalog fillers ("Not Documented") are not people (2026-09-13). + names: names + .into_iter() + .filter(|name| !qbz_text_utils::names::is_placeholder_name(name)) + .collect(), }) + .filter(|row| !row.names.is_empty()) .collect(); TrackInfoDoc { diff --git a/crates/qbz-text-utils/src/lib.rs b/crates/qbz-text-utils/src/lib.rs index 708d3eb46..9687636d6 100644 --- a/crates/qbz-text-utils/src/lib.rs +++ b/crates/qbz-text-utils/src/lib.rs @@ -1,5 +1,6 @@ //! Frontend-agnostic text/date utilities extracted from the qbz binary. pub mod dates; +pub mod names; pub mod sleep; pub mod strip_html; diff --git a/crates/qbz-text-utils/src/names.rs b/crates/qbz-text-utils/src/names.rs new file mode 100644 index 000000000..a3f5e71b5 --- /dev/null +++ b/crates/qbz-text-utils/src/names.rs @@ -0,0 +1,66 @@ +//! Placeholder names the catalog uses where a real credit is missing. +//! +//! Qobuz ships literal fillers as artist / composer / performer names — +//! "Not Documented" is the one seen on album headers (2026-09-13), and the +//! usual "Unknown" / "N/A" family exists too. They are not people: a UI +//! that shows them prints junk beside the real artist and links to an +//! artist page that does not exist. Every credit list runs through +//! [`is_placeholder_name`] before it reaches a document. + +/// True for a name the catalog uses as "no data": matched case-insensitively +/// on the trimmed text, punctuation-insensitive for the hyphenated forms. +pub fn is_placeholder_name(name: &str) -> bool { + let folded: String = name + .trim() + .chars() + .map(|c| if c == '-' || c == '_' { ' ' } else { c.to_ascii_lowercase() }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + matches!( + folded.as_str(), + "" | "not documented" + | "undocumented" + | "unknown" + | "unknown artist" + | "unknown composer" + | "n/a" + | "na" + | "none" + | "null" + | "no artist" + | "no name" + | "[unknown]" + | "(unknown)" + ) +} + +#[cfg(test)] +mod tests { + use super::is_placeholder_name; + + #[test] + fn the_catalog_fillers_are_placeholders() { + for name in [ + "Not Documented", + "not documented", + "NOT-DOCUMENTED", + " Undocumented ", + "Unknown", + "Unknown Artist", + "N/A", + "", + " ", + ] { + assert!(is_placeholder_name(name), "{name:?}"); + } + } + + #[test] + fn real_names_pass() { + for name in ["Stream Of Passion", "Various Artists", "Roy Orbison", "Non Documenté"] { + assert!(!is_placeholder_name(name), "{name:?}"); + } + } +} From b8683192331e52628ce46abf589f06f0a8bde506 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:32:00 -0600 Subject: [PATCH 03/27] feat(qt): search boxes clear on Escape, drop focus, and offer a clear cross Every search field follows one contract: Escape empties the text AND hands the keyboard back to the shell root, and a clickable cross clears the text while keeping the caret in the field. The header search shows the cross left of the Enter glyph while the cortinilla is open; QbzLineEdit's search mode and the new QbzSearchField (the album track filter and the playlist filter) float it at the right edge. Each field also keeps its focus after an edit unless another text input took it, and logs when it had to reclaim it. cc --- crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/QbzLineEdit.qml | 48 +++++- crates/qbz-qt/qml/controls/QbzSearchField.qml | 154 ++++++++++++++++++ crates/qbz-qt/qml/shell/HeaderBar.qml | 39 ++++- crates/qbz-qt/qml/views/AlbumView.qml | 41 +---- crates/qbz-qt/qml/views/PlaylistView.qml | 35 +--- 6 files changed, 245 insertions(+), 73 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/QbzSearchField.qml diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 366377303..ecb4ae03a 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -597,6 +597,7 @@ fn main() { "qml/controls/ScrollMemory.qml", "qml/controls/ScopePanel.qml", "qml/controls/QbzSegToggle.qml", + "qml/controls/QbzSearchField.qml", "qml/controls/QbzSelect.qml", "qml/controls/QbzSelectableText.qml", "qml/controls/QbzSplitButton.qml", diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index f43ccf9c5..5eda4ce41 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -19,6 +19,7 @@ // detail, queue) is this control. import QtQuick +import QtQuick.Window import com.blitzfc.qbz import "../theme" @@ -110,6 +111,39 @@ Rectangle { root.open = false input.focus = false } + /// Hand the keyboard back to the shell root (the walk the header search + /// does); plain unfocus when no shell root is above (previews, tests). + function blurToShell() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + input.focus = false + } + // FOCUS RETENTION (2026-09-13, every search box): a field that filters a + // list on each keystroke sits above a surface that rebuilds on each + // keystroke, and must not lose the keyboard to that rebuild. After an + // edit, once the event loop settles, reclaim focus unless another text + // input took it (a click elsewhere is a decision, a rebuilt list is not). + property bool _reclaim: false + function _reclaimFocus() { + if (!root._reclaim) + return + root._reclaim = false + if (input.activeFocus) + return + var win = root.Window.window + var current = win ? win.activeFocusItem : null + if (current instanceof TextInput || current instanceof TextEdit) + return + console.log("[QbzLineEdit] focus left the field after an edit; reclaimed (holder was " + + (current ? current.toString() : "none") + ")") + input.forceActiveFocus() + } // Immersive-port contract D16 (2026-08-02, additive one-liner): the ONE // read-only seam into the inner TextInput's focus. The immersive root @@ -237,11 +271,23 @@ Rectangle { text: root.text onAccepted: { root.accepted(text); root.committed(text) } onActiveFocusChanged: if (!activeFocus) root.committed(text) - onTextEdited: root.edited(text) + onTextEdited: { + root.edited(text) + if (root.searchMode) { + root._reclaim = true + Qt.callLater(root._reclaimFocus) + } + } + // Escape on a search box: empty it AND hand the keyboard + // back (2026-09-13). Form fields keep propagating it. Keys.onEscapePressed: function (event) { if (root.expandable) { root.closeSearch() event.accepted = true + } else if (root.searchMode) { + root.clearSearch() + root.blurToShell() + event.accepted = true } else { event.accepted = false } diff --git a/crates/qbz-qt/qml/controls/QbzSearchField.qml b/crates/qbz-qt/qml/controls/QbzSearchField.qml new file mode 100644 index 000000000..263474576 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzSearchField.qml @@ -0,0 +1,154 @@ +// QbzSearchField — the inline search box the views share (2026-09-13): the +// rounded elevated field with a leading search glyph, a placeholder, a clear +// cross floating at the right while there is text, and one key contract — +// Escape clears the text AND hands the keyboard back to the shell root (the +// walk the header search does), so no search box keeps a stale query or a +// blinking cursor after Escape. +// +// FOCUS RETENTION. A search box that filters a list on every keystroke sits +// above a surface that rebuilds on every keystroke, and the field must never +// lose the keyboard mid-word to that rebuild. After each edit, once the event +// loop has settled, the field checks it still has focus and reclaims it +// unless another text input took it: a click elsewhere is a decision, a +// rebuilt list is not. + +import QtQuick +import QtQuick.Window +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + property alias text: input.text + property string placeholder: "" + property int glyphSize: 14 + property int fontPx: 13 + property int sidePadding: 10 + /// Fired on every keystroke and on clear, with the live text. + signal edited(string text) + readonly property bool fieldActive: input.activeFocus + + implicitWidth: 168 + implicitHeight: 34 + radius: 6 + color: theme.surfaceElevated + border.width: 1 + border.color: input.activeFocus ? theme.accent : theme.borderSubtle + + QbzTheme { id: theme } + + function clear() { + if (input.text === "") + return + input.text = "" + root.edited("") + } + /// Escape's contract: empty AND hand the keyboard to the shell root. + function clearAndBlur() { + root.clear() + root.blur() + } + function blur() { + var p = root + while (p.parent) { + if (p.parent.isQbzShellRoot === true) { + p.parent.forceActiveFocus() + return + } + p = p.parent + } + input.focus = false + } + function focusField() { input.forceActiveFocus() } + + property bool _reclaim: false + function _reclaimFocus() { + if (!root._reclaim) + return + root._reclaim = false + if (input.activeFocus) + return + var win = root.Window.window + var current = win ? win.activeFocusItem : null + if (current instanceof TextInput || current instanceof TextEdit) + return + console.log("[QbzSearchField] focus left the field after an edit; reclaimed (holder was " + + (current ? current.toString() : "none") + ")") + input.forceActiveFocus() + } + + QbzIcon { + name: "search" + width: root.glyphSize + height: root.glyphSize + x: root.sidePadding + anchors.verticalCenter: parent.verticalCenter + tintName: "muted" + } + TextInput { + id: input + QbzTextEditMenu { } + anchors.left: parent.left + anchors.right: clearSlot.left + anchors.leftMargin: root.sidePadding + root.glyphSize + 7 + anchors.rightMargin: 4 + height: parent.height + color: theme.textPrimary + font.pixelSize: root.fontPx + verticalAlignment: Text.AlignVCenter + clip: true + onTextEdited: { + root.edited(text) + root._reclaim = true + Qt.callLater(root._reclaimFocus) + } + Keys.onEscapePressed: function (event) { + root.clearAndBlur() + event.accepted = true + } + Text { + visible: parent.text === "" + anchors.fill: parent + text: root.placeholder + color: theme.textMuted + font.pixelSize: root.fontPx + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + // The clear cross: floats at the right while there is text; a click + // empties the field and keeps the keyboard in it. + Item { + id: clearSlot + anchors.right: parent.right + anchors.rightMargin: 5 + width: input.text !== "" ? 22 : 0 + height: parent.height + visible: input.text !== "" + Rectangle { + anchors.centerIn: parent + width: 22 + height: 22 + radius: 11 + color: clearArea.containsMouse ? theme.surfaceHover : "transparent" + QbzIcon { + name: "x" + width: 12 + height: 12 + anchors.centerIn: parent + tintName: clearArea.containsMouse ? "textPrimary" : "muted" + } + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.clear() + input.forceActiveFocus() + } + } + } + } +} diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index a3ff4331d..c5a994f5b 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -942,6 +942,24 @@ Rectangle { function focusSearch() { searchInput.forceActiveFocus() } + // FOCUS RETENTION (2026-09-13, every search box): after an edit, once the + // event loop settles, reclaim the keyboard unless another text input took + // it — the dropdown opening or closing must never steal it mid-word. + property bool _searchReclaim: false + function _reclaimSearchFocus() { + if (!root._searchReclaim) + return + root._searchReclaim = false + if (searchInput.activeFocus) + return + var win = root.Window.window + var current = win ? win.activeFocusItem : null + if (current instanceof TextInput || current instanceof TextEdit) + return + console.log("[HeaderBar] search lost focus after an edit; reclaimed (holder was " + + (current ? current.toString() : "none") + ")") + searchInput.forceActiveFocus() + } Rectangle { id: searchBox @@ -1001,7 +1019,11 @@ Rectangle { QbzSearch.searchLive(text) } } - onTextEdited: applyLiveQuery() + onTextEdited: { + applyLiveQuery() + root._searchReclaim = true + Qt.callLater(root._reclaimSearchFocus) + } // The Enter rule (HeaderBar.slint on-enter): cortinilla open + // a keyboard selection -> activate the row; open + none -> full @@ -1045,8 +1067,9 @@ Rectangle { // the modals use, not merely cleared: a null // activeFocusItem passes the gate but leaves AppShell's // Keys handler receiving nothing at all. - if (QbzSearch.cortinillaOpen) - QbzSearch.cortinillaDismiss() + // …and it empties the box too (2026-09-13, every search + // box): clearSearch() drops the text and the dropdown. + root.clearSearch() event.accepted = true var p = searchInput while (p.parent) { @@ -1078,6 +1101,7 @@ Rectangle { // open (Slint: it lives in the box, opposite the magnifier), else // the × clear. Text { + id: enterHint visible: QbzSearch.cortinillaOpen anchors.right: parent.right anchors.rightMargin: 10 @@ -1087,10 +1111,13 @@ Rectangle { font.pixelSize: 12 verticalAlignment: Text.AlignVCenter } + // The clear cross: always there while there is text (2026-09-13); it + // sits to the LEFT of the ↵ hint while the dropdown is open, at the + // right edge otherwise. Rectangle { - visible: !QbzSearch.cortinillaOpen && searchInput.text !== "" - anchors.right: parent.right - anchors.rightMargin: 5 + visible: searchInput.text !== "" + anchors.right: QbzSearch.cortinillaOpen ? enterHint.left : parent.right + anchors.rightMargin: QbzSearch.cortinillaOpen ? 4 : 5 width: 22 height: 22 anchors.verticalCenter: parent.verticalCenter diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index df98929ec..95205bb32 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -1560,45 +1560,14 @@ Rectangle { - 168 - 30 - 2 * 16) height: 1 } - Rectangle { + // Track filter (QbzSearchField: Escape clears + blurs, + // clear cross, focus kept across the list rebuild). + QbzSearchField { width: 168 height: 34 - radius: 6 anchors.verticalCenter: parent.verticalCenter - color: theme.surfaceElevated - border.width: 1 - border.color: theme.borderSubtle - Row { - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 7 - QbzIcon { - name: "search" - width: 14 - height: 14 - anchors.verticalCenter: parent.verticalCenter - tintName: "muted" - } - TextInput { - QbzTextEditMenu { } - width: parent.width - 21 - height: parent.height - color: theme.textPrimary - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter - clip: true - onTextEdited: root.trackQuery = text - Text { - visible: parent.text === "" - anchors.fill: parent - text: QbzSession.tr("Search tracks...", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter - } - } - } + placeholder: QbzSession.tr("Search tracks...", QbzSession.trRev) + onEdited: function (text) { root.trackQuery = text } } // Multi-select toggle (AlbumPageView.slint:752-787): // accent border + tint while active; leaving the mode diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index eb384fa40..455d80800 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -722,39 +722,14 @@ Rectangle { } // In-playlist search. - Rectangle { + QbzSearchField { width: 220 height: 30 - radius: 6 + sidePadding: 9 + glyphSize: 13 anchors.verticalCenter: parent.verticalCenter - color: theme.surfaceElevated - border.width: 1 - border.color: theme.borderSubtle - Row { - anchors.fill: parent - anchors.leftMargin: 9 - anchors.rightMargin: 9 - spacing: 6 - QbzIcon { name: "search"; width: 13; height: 13; anchors.verticalCenter: parent.verticalCenter; tintName: "muted" } - TextInput { - QbzTextEditMenu { } - width: parent.width - 19 - height: parent.height - color: theme.textPrimary - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter - clip: true - onTextEdited: QbzBridge.playlistSetSearch(text) - Text { - visible: parent.text === "" - anchors.fill: parent - text: QbzSession.tr("Search tracks", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 13 - verticalAlignment: Text.AlignVCenter - } - } - } + placeholder: QbzSession.tr("Search tracks", QbzSession.trRev) + onEdited: function (text) { QbzBridge.playlistSetSearch(text) } } // Sort dropdown. Rectangle { From dae03e38343ce56a1b6125ee5bfb09df7f3800f7 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:32:00 -0600 Subject: [PATCH 04/27] fix(qt): list rebuilds no longer steal the header search box's focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing in the album track filter lost the keyboard on every keystroke. A qt.quick.focus trace named the thief: a fresh track delegate took focus "in scope pageFlick" right after each rebuild. In Qt 6.11 assigning a new array to ListView.model after completion runs QQuickItemViewPrivate::connectModel, which forces currentIndex to 0, and updateCurrent() then focuses delegate 0 — inside the view's focus scope, where the header's TextInput lives. `currentIndex: -1` does not survive the assignment. QbzArrayModel (a DelegateModel) now owns such arrays: a swap reaches the view as remove+insert changes, which honour currentIndex -1 and keep the rows. Because ListView re-seats inserted rows around the viewport, the model re-expresses the viewport relative to the content origin and restores it after layout; a page replacement calls scrollToTop(). The album page, the kiosk album and the kiosk playlist swap their rows on it, their ScrollMemory stores origin-relative offsets, and opening another album resets the track filter (the box mirrors `trackQuery`). scripts/test-array-model-qml.sh pins the behaviour (focus kept, viewport kept, top on request) and joins the runtime gate and CI. cc --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/QbzArrayModel.qml | 87 ++++++++++++++++ crates/qbz-qt/qml/controls/ScrollMemory.qml | 10 +- crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml | 20 +++- crates/qbz-qt/qml/kiosk/KioskPlaylist.qml | 11 +- crates/qbz-qt/qml/views/AlbumView.qml | 70 ++++++++----- scripts/qml-tests/tst_array_model.qml | 103 +++++++++++++++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-array-model-qml.sh | 19 ++++ 10 files changed, 288 insertions(+), 36 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/QbzArrayModel.qml create mode 100644 scripts/qml-tests/tst_array_model.qml create mode 100755 scripts/test-array-model-qml.sh diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index 2d42b924d..ba0f420df 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -86,6 +86,7 @@ on: - "scripts/test-text-edit-menu-qml.sh" - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" + - "scripts/test-array-model-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" push: @@ -114,6 +115,7 @@ on: - "scripts/test-text-edit-menu-qml.sh" - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" + - "scripts/test-array-model-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" workflow_dispatch: diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index ecb4ae03a..53dd03f98 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -574,6 +574,7 @@ fn main() { "qml/controls/PlaylistImportModal.qml", "qml/controls/PlaylistPickerModal.qml", "qml/controls/PmFolderIcon.qml", + "qml/controls/QbzArrayModel.qml", "qml/controls/QbzCircleAction.qml", "qml/controls/QbzClipboard.qml", "qml/controls/QbzColorPicker.qml", diff --git a/crates/qbz-qt/qml/controls/QbzArrayModel.qml b/crates/qbz-qt/qml/controls/QbzArrayModel.qml new file mode 100644 index 000000000..b5fe9677a --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzArrayModel.qml @@ -0,0 +1,87 @@ +// QbzArrayModel — a DelegateModel over a JS array that a view can SWAP +// (2026-09-13). Assigning a fresh array straight to `ListView.model` after +// the view has completed has two side effects in Qt 6.11 +// (qquickitemview.cpp, QQuickItemViewPrivate::connectModel): +// +// 1. `currentIndex` is forced to 0 and updateCurrent() calls +// setFocus(true) on delegate 0. The view is a focus scope, so a search +// box living in its header loses the keyboard on every rebuild — the +// album track filter lost focus on each keystroke (qt.quick.focus +// trace: "focus trackCell in scope pageFlick", activeFocusItem +// TextInput -> trackCell). A `currentIndex: -1` on the view does not +// survive the assignment. +// 2. setModel() clears the view and resets contentY to the content start. +// +// Swapping the array HERE instead reaches the view as remove+insert changes: +// `currentIndex: -1` stays honoured (nothing is ever focused) and the view +// keeps its rows. ListView re-seats the inserted rows around the viewport, +// which drifts the content origin, so the viewport is re-expressed relative +// to that origin (0 = the header's top edge) before the change and restored +// once it is laid out. A swap that replaces the page calls `scrollToTop()`. +// +// Probe that measured both behaviours against Qt 6.11.2: +// scripts/qml-tests/tst_array_model.qml. + +import QtQuick +import QtQml.Models + +DelegateModel { + id: swap + + /// The rows. Replace the array freely; the view keeps its place. + property var rows: [] + /// The ListView (or GridView) this model feeds — needed for the restore. + property Item view: null + + model: rows + + property bool _live: false + property int _rowsBefore: 0 + property real _offset: 0 + property int _epoch: 0 + property bool _toTop: false + + Component.onCompleted: { + swap._live = true + swap._rowsBefore = swap.count + } + + onModelChanged: { + if (!swap._live || !swap.view) + return + var had = swap._rowsBefore + swap._rowsBefore = swap.count + // Nothing to keep: the page had no rows (first document, or the + // empty document a detail publishes while the next one loads), or a + // page replacement already asked for the top. + if (had === 0 || swap._toTop) + return + // Before the pending remove+insert is laid out: how far the viewport + // sits below the content origin. + swap._offset = swap.view.contentY - swap.view.originY + var epoch = ++swap._epoch + Qt.callLater(function () { + if (epoch !== swap._epoch || !swap.view) + return + swap.view.forceLayout() + var minY = swap.view.originY + var maxY = minY + Math.max(0, swap.view.contentHeight - swap.view.height) + swap.view.contentY = Math.max(minY, Math.min(minY + swap._offset, maxY)) + }) + } + + /// A swap that replaces the page (a different document): the viewport + /// goes to the top once the new rows are laid out. Works whether it is + /// called before or after the `rows` assignment in the same turn. + function scrollToTop() { + swap._toTop = true + var epoch = ++swap._epoch + Qt.callLater(function () { + swap._toTop = false + if (epoch !== swap._epoch || !swap.view) + return + swap.view.forceLayout() + swap.view.positionViewAtBeginning() + }) + } +} diff --git a/crates/qbz-qt/qml/controls/ScrollMemory.qml b/crates/qbz-qt/qml/controls/ScrollMemory.qml index cb6925528..037218467 100644 --- a/crates/qbz-qt/qml/controls/ScrollMemory.qml +++ b/crates/qbz-qt/qml/controls/ScrollMemory.qml @@ -44,10 +44,12 @@ // every one of those snaps the list back to the top — over a restore that had // already run correctly. // -// (The same reset also throws a plain forward visit to the top if a rail lands -// while you are reading. The re-assert below only stops it from eating the -// restore; fixing that properly means giving those rails a model of their own, -// which is an AlbumView change and not this file's business.) +// (The album page no longer resets: since 2026-09-13 its tape is swapped on a +// QbzArrayModel, which reaches the ListView as remove+insert changes and keeps +// the viewport — see that control for the Qt 6.11 focus theft that forced the +// move. Those swaps drift `originY` instead, which is what `relativeToOrigin` +// is for. The re-assert below stays for every page that still assigns +// `model` directly.) // // So the offset is held in `_wanted` and re-applied on every contentHeight // change, every count change and every contentY move we did not make, until diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml b/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml index 863163a22..45bc0a017 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml @@ -53,14 +53,28 @@ Rectangle { function toggleSelected(id, mods) { selected = selection.next(selected, id, visibleTracks, mods === undefined ? Qt.NoModifier : mods) } readonly property string navigationStateJson: JSON.stringify({trackQuery: trackQuery}) function restoreNavigationState(state) { trackQuery = state.trackQuery || "" } - onDocChanged: exitMultiSelectMode() + // An in-place album switch starts at the top; the first document of a + // mount does not touch the viewport (ScrollMemory may be restoring it). + property string loadedAlbumId: "" + onDocChanged: { + exitMultiSelectMode() + var id = root.album ? String(root.album.id || "") : "" + if (id === root.loadedAlbumId) + return + if (root.loadedAlbumId !== "") + rowsModel.scrollToTop() + root.loadedAlbumId = id + } ListView { id: list anchors.fill: parent anchors.margins: 12 clip: true - model: root.visibleTracks + // Swapped on a QbzArrayModel, never on `model:` — a fresh array there + // makes Qt 6.11 focus delegate 0 and the header's search box loses the + // keyboard on every keystroke (see the control's header). + model: QbzArrayModel { id: rowsModel; view: list; rows: root.visibleTracks } cacheBuffer: height reuseItems: true boundsBehavior: Flickable.StopAtBounds @@ -168,6 +182,6 @@ Rectangle { ] onPicked: function(a) { if (root.selectedCount > 0) root.bulkAction(a) } } - ScrollMemory { target: list; scope: "localalbum" } + ScrollMemory { target: list; scope: "localalbum"; relativeToOrigin: true } QbzScrollBar { target: list; anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom } } diff --git a/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml b/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml index 19db08f59..37eb41fe8 100644 --- a/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml +++ b/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml @@ -84,6 +84,10 @@ Rectangle { var id = doc.id || "" if (id === loadedPlaylistId) return + // An in-place playlist switch starts at the top; the first document + // of a mount leaves the viewport to ScrollMemory. + if (loadedPlaylistId !== "") + rowsModel.scrollToTop() loadedPlaylistId = id setMultiSelect(false) } @@ -396,7 +400,10 @@ Rectangle { cacheBuffer: height reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: root.tracks + // Swapped on a QbzArrayModel, never on `model:` — a fresh array there + // makes Qt 6.11 focus delegate 0, and the header's search box lost the + // keyboard whenever a filtered document landed (see the control). + model: QbzArrayModel { id: rowsModel; view: trackList; rows: root.tracks } header: Column { width: trackList.width spacing: 12 @@ -586,6 +593,6 @@ Rectangle { ] onPicked:function(a){if(root.selectedCount>0)root.bulkAction(a)} } - ScrollMemory { target:trackList; scope:"playlist" } + ScrollMemory { target:trackList; scope:"playlist"; relativeToOrigin:true } QbzScrollBar { target:trackList; anchors.right:parent.right; anchors.top:parent.top; anchors.bottom:parent.bottom } } diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index 95205bb32..5b50fcc2f 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -59,14 +59,15 @@ Rectangle { // The view's album + url-keyed cover map (artwork pipeline). // - // Applied imperatively so a SAME-ALBUM deferred rail publish can preserve - // the ListView offset. Binding `model` to a freshly parsed JS array makes - // QQuickItemView reset contentY whenever Similar albums / Suggestions / More - // from this artist lands — the forced jump the owner caught while reading. + // Applied imperatively so the page can tell a SAME-ALBUM deferred rail + // publish (Similar albums / Suggestions / More from this artist landing + // while the user reads: the viewport stays put) from a NEW album (the + // track filter resets and the page starts at the top). The tape itself is + // swapped on `cellsModel`, a QbzArrayModel: reassigning the ListView's + // `model` would reset contentY on every publish and, on Qt 6.11, focus a + // delegate — see that control's header for both. property var album: ({}) property string documentAlbumId: "" - property int albumDocumentEpoch: 0 - property real albumRestoreY: 0 function parseAlbumDocument() { try { @@ -82,27 +83,18 @@ Rectangle { var nextId = nextHeader.id !== undefined && nextHeader.id !== null ? String(nextHeader.id) : "" var sameAlbum = nextId !== "" && nextId === root.documentAlbumId - var savedY = sameAlbum ? pageFlick.contentY : 0 - root.albumDocumentEpoch += 1 - var epoch = root.albumDocumentEpoch + if (!sameAlbum && root.documentAlbumId !== "") { + // A different release replacing the one on screen (the empty + // document a load publishes first counts): its track list starts + // unfiltered — the header's search box mirrors `trackQuery` — and + // at the top. The first document of a mount leaves the viewport + // alone: ScrollMemory may be restoring a Back navigation. + root.trackQuery = "" + cellsModel.scrollToTop() + } root.album = next if (nextId !== "") root.documentAlbumId = nextId - if (!sameAlbum || savedY <= pageFlick.originY) - return - root.albumRestoreY = savedY - // The model reset and its content-height polish happen after the - // document assignment. One deferred restore coalesces a publish burst - // and clamps if the replacement document is genuinely shorter. - Qt.callLater(function () { - if (epoch !== root.albumDocumentEpoch - || nextId !== root.documentAlbumId) - return - pageFlick.forceLayout() - var minY = pageFlick.originY - var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height) - pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY)) - }) } readonly property var albumHeader: album.header || ({}) @@ -850,7 +842,17 @@ Rectangle { anchors.fill: parent clip: true boundsBehavior: Flickable.StopAtBounds - model: root.listCells + // The tape is swapped on this model, never on `model:` itself: a + // fresh array assigned to the ListView after completion makes Qt 6.11 + // force currentIndex 0 and focus delegate 0, which robbed the header's + // track filter of the keyboard on every keystroke (2026-09-13). The + // wrapper also keeps the viewport across a same-album rail publish; + // applyAlbumDocument() asks for the top when the album changes. + model: QbzArrayModel { + id: cellsModel + view: pageFlick + rows: root.listCells + } reuseItems: true currentIndex: -1 // The track tape is cheap enough that a fast wheel can consume one @@ -1561,13 +1563,25 @@ Rectangle { height: 1 } // Track filter (QbzSearchField: Escape clears + blurs, - // clear cross, focus kept across the list rebuild). + // clear cross). The keyboard survives the tape rebuild + // because the tape lives on a QbzArrayModel. QbzSearchField { + id: trackSearch width: 168 height: 34 anchors.verticalCenter: parent.verticalCenter placeholder: QbzSession.tr("Search tracks...", QbzSession.trRev) onEdited: function (text) { root.trackQuery = text } + // A new release resets `trackQuery` + // (applyAlbumDocument); the box follows, so no + // stale query outlives the album it was typed on. + Connections { + target: root + function onTrackQueryChanged() { + if (trackSearch.text !== root.trackQuery) + trackSearch.text = root.trackQuery + } + } } // Multi-select toggle (AlbumPageView.slint:752-787): // accent border + tint while active; leaving the mode @@ -2029,7 +2043,9 @@ Rectangle { // Back/forward scroll memory (controls/ScrollMemory.qml): reports // this container's offset while it is the live page, and restores it // when a back/forward step arms this route. - ScrollMemory { target: pageFlick; scope: "album" } + // Origin-relative: the tape swaps on cellsModel drift ListView.originY, + // so an absolute contentY would restore a different row after Back. + ScrollMemory { target: pageFlick; scope: "album"; relativeToOrigin: true } QbzScrollBar { anchors.right: parent.right anchors.rightMargin: 4 diff --git a/scripts/qml-tests/tst_array_model.qml b/scripts/qml-tests/tst_array_model.qml new file mode 100644 index 000000000..50dde6d25 --- /dev/null +++ b/scripts/qml-tests/tst_array_model.qml @@ -0,0 +1,103 @@ +// QbzArrayModel: swapping the rows array must not rob the view's header of +// the keyboard (Qt 6.11 forces currentIndex 0 on a ListView.model assignment +// and focuses delegate 0), must keep the viewport where it was relative to +// the content origin, and scrollToTop() must land on the origin. +import QtQuick +import QtQml.Models +import Qt.labs.qmlmodels +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 600; height: 400 + property var cells: [] + function makeCells(n) { + var out = [] + for (var i = 0; i < n; i++) + out.push({ "kind": (i % 5 === 0) ? "track" : "gap", "i": i }) + return out + } + + ListView { + id: view + width: 600; height: 400 + clip: true + reuseItems: true + currentIndex: -1 + cacheBuffer: 800 + model: QbzArrayModel { id: rowsModel; view: view; rows: root.cells } + header: Item { + width: 600; height: 300 + TextInput { objectName: "field"; width: 200; height: 30; y: 250; text: "x" } + } + delegate: DelegateChooser { + role: "kind" + DelegateChoice { roleValue: "gap"; delegate: Item { width: 600; height: 10 } } + DelegateChoice { + roleValue: "track" + delegate: Item { + required property var modelData + width: 600; height: 10 + Rectangle { width: 600; height: 50; color: "#333" } + } + } + } + } + + TestCase { + name: "ArrayModel" + when: windowShown + function field() { return view.headerItem.children[0] } + function settle() { wait(0); waitForRendering(view); wait(0) } + + function test_a_swap_keeps_header_focus_and_current_index() { + root.cells = root.makeCells(200) + settle() + compare(view.count, 200) + var input = field() + input.forceActiveFocus() + verify(input.activeFocus) + root.cells = root.makeCells(150) + settle() + compare(view.count, 150) + compare(view.currentIndex, -1) + verify(input.activeFocus, "the header's field keeps the keyboard across a swap") + } + + function test_b_swap_keeps_viewport_relative_to_origin() { + root.cells = root.makeCells(200) + settle() + view.contentY = view.originY + 1000 + settle() + var before = view.contentY - view.originY + root.cells = root.makeCells(300) + settle() + fuzzyCompare(view.contentY - view.originY, before, 0.5) + compare(view.count, 300) + } + + function test_c_scroll_to_top_lands_on_the_origin() { + root.cells = root.makeCells(200) + settle() + view.contentY = view.originY + 900 + settle() + rowsModel.scrollToTop() + root.cells = root.makeCells(120) + settle() + fuzzyCompare(view.contentY, view.originY, 0.5) + compare(view.count, 120) + } + + function test_d_first_rows_do_not_move_the_viewport() { + root.cells = [] + settle() + compare(view.count, 0) + var y = view.contentY + root.cells = root.makeCells(80) + settle() + compare(view.count, 80) + fuzzyCompare(view.contentY, y, 0.5) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index c6d17b6dc..5af3d6841 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -9,6 +9,7 @@ bash scripts/test-playback-cache-qml.sh bash scripts/test-text-edit-menu-qml.sh bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh +bash scripts/test-array-model-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs node scripts/test_qt_library.mjs diff --git a/scripts/test-array-model-qml.sh b/scripts/test-array-model-qml.sh new file mode 100755 index 000000000..cb8a1c85d --- /dev/null +++ b/scripts/test-array-model-qml.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# QbzArrayModel regression: a rows swap keeps the header's keyboard focus and +# the viewport (Qt 6.11 ListView.model assignment side effects). Same run in +# CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_array_model_bins="$QT_ROOT_DIR/bin" +else + qt_array_model_qmake="${QMAKE:-}" + if [[ -z "$qt_array_model_qmake" ]]; then + qt_array_model_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_array_model_bins="$("$qt_array_model_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_array_model_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_array_model.qml \ + -import scripts/qml-tests/imports From e278d931b73519a9fa5fcb18e3f7e928c4429b2d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:56:10 -0600 Subject: [PATCH 05/27] feat(qt): "Where you left off" reopens the exact page, its arguments and its tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup pref only knew the last ROOT view: `last_view` lives in the ui_prefs.json the Slint build shares, whose vocabulary has no room for a detail, so a session closed on an album came back on Library > Releases. page_restore_qt keeps the page itself in a per-profile document (last_page_qt.json: root, view, args, state), written as the user moves. Openers that can rebuild their page record their arguments through the new nav_qt::record_with (album, artist, playlist, label and its releases, mixtape/collection detail, award and its albums, mix, artist releases, discover browse); the Settings section, the search query and tab and the purchased album are noted where they live; the mounted view's tab comes from QbzShell.reportNavState. At startup the shell mounts that page's view (no Home flash), the history is seeded with the page's root so Back leads there, and the session entry re-runs the opener — one-shot, behind the same gates as the local album restore (remember pref, crash chain, launcher link, kiosk), skipping catalog pages while offline and Purchases unless it is switched on. Pages only their opener could rebuild (scene, musician, disco builder, metadata editor) keep the previous restorable page on record; local albums stay with local_restore_qt. ContentRouter now reports the active tab of any view that has one and no richer state of its own (Discover, Library) and applies a Back/Forward destination's saved tab, so tabs survive both a restart and the history. cc --- crates/qbz-qt/qml/shell/ContentRouter.qml | 41 ++ crates/qbz-qt/src/artist_releases_qt.rs | 9 +- crates/qbz-qt/src/award_qt.rs | 4 +- crates/qbz-qt/src/bridge.rs | 8 +- crates/qbz-qt/src/browse_qt.rs | 5 +- crates/qbz-qt/src/foryou_qt.rs | 2 +- crates/qbz-qt/src/label_qt.rs | 4 +- crates/qbz-qt/src/main.rs | 23 +- crates/qbz-qt/src/myqbz_detail_qt.rs | 2 +- crates/qbz-qt/src/nav_qt.rs | 77 ++- crates/qbz-qt/src/page_restore_qt.rs | 650 ++++++++++++++++++++++ crates/qbz-qt/src/purchases_qt.rs | 3 + crates/qbz-qt/src/search_qt.rs | 8 + 13 files changed, 801 insertions(+), 35 deletions(-) create mode 100644 crates/qbz-qt/src/page_restore_qt.rs diff --git a/crates/qbz-qt/qml/shell/ContentRouter.qml b/crates/qbz-qt/qml/shell/ContentRouter.qml index b29ce1a74..000532a9d 100644 --- a/crates/qbz-qt/qml/shell/ContentRouter.qml +++ b/crates/qbz-qt/qml/shell/ContentRouter.qml @@ -44,6 +44,46 @@ Item { } } + // THE PAGE'S TAB, LIVE (2026-09-13). Any mounted view with an `activeTab` + // string that does not report a richer state of its own (Local Library + // does, and restores it itself) reports its tab on every change: nav_qt + // stamps it on the history entry, so Back/Forward land on the tab the + // user left, and page_restore_qt keeps it in the per-profile page + // document, so "Where you left off" reopens on it. Never during the + // two-phase route commit, when the item on screen is still the outgoing + // view while `currentView` already names the next one. + Connections { + target: viewLoader.item + ignoreUnknownSignals: true + function onActiveTabChanged() { root._reportTab() } + } + function _reportTab() { + var item = viewLoader.item + if (!item || root._armed || root.kiosk) + return + if (typeof item.activeTab !== "string" || typeof item.navigationStateJson === "string") + return + QbzShell.reportNavState(QbzShell.currentView, JSON.stringify({ "activeTab": item.activeTab })) + } + // The other direction: a Back/Forward destination whose entry carries a + // tab (armed by nav_qt::step before `currentView` is published) mounts + // on it. Views that restore their own state consume the arming + // themselves and are left alone. + function _restoreTab() { + var item = viewLoader.item + if (!item || root.kiosk || QbzShell.restoreStateScope === "") + return + if (QbzShell.restoreStateScope !== QbzShell.currentView) + return + if (typeof item.activeTab !== "string" || typeof item.restoreNavigationState === "function") + return + var saved = null + try { saved = JSON.parse(QbzShell.stateRestore) } catch (e) { saved = null } + QbzShell.restoreStateScope = "" + if (saved && typeof saved.activeTab === "string" && saved.activeTab !== "") + item.activeTab = saved.activeTab + } + // The mounted view. The replacement for AppShell's four `viewLoader.item` // references (multi-select: the two Ctrl+A / Escape routers and the two // duck-typed reporters) — a QML `id` is scoped to its own document, so @@ -595,6 +635,7 @@ Item { && typeof viewLoader.item.activeTab === "string" root._itemTabRequest = viewLoader.item !== null && typeof viewLoader.item.tabNavigationRequest !== "undefined" + root._restoreTab() } } diff --git a/crates/qbz-qt/src/artist_releases_qt.rs b/crates/qbz-qt/src/artist_releases_qt.rs index 1a751f072..927afc2c2 100644 --- a/crates/qbz-qt/src/artist_releases_qt.rs +++ b/crates/qbz-qt/src/artist_releases_qt.rs @@ -276,7 +276,14 @@ pub fn open(artist_id: String, artist_name: String, release_type: String) { // The route is a TWO-FILE contract (nav_qt.rs:9-16): this records the id, // AppShell.qml's Loader ternary mounts it. `QbzShell.navigateTo` is NOT // usable — it carries no payload and clears LAST_DETAIL (main.rs:1607). - crate::nav_qt::record("artistreleases"); + crate::nav_qt::record_with( + "artistreleases", + serde_json::json!({ + "artistId": &artist_id, + "artistName": &artist_name, + "releaseType": &release_type + }), + ); publish(); fetch(generation, artist_id, release_type, 0, true); } diff --git a/crates/qbz-qt/src/award_qt.rs b/crates/qbz-qt/src/award_qt.rs index b74892395..04b0afb86 100644 --- a/crates/qbz-qt/src/award_qt.rs +++ b/crates/qbz-qt/src/award_qt.rs @@ -484,7 +484,7 @@ pub fn open_award(award_id: String, fallback_name: String) { if id.is_empty() { return; } - crate::nav_qt::record("award"); + crate::nav_qt::record_with("award", serde_json::json!({ "id": &id, "name": &fallback_name })); let generation = with_state(|s| { s.generation = s.generation.wrapping_add(1); s.id = id.clone(); @@ -721,7 +721,7 @@ pub fn open_albums() { if id.is_empty() { return; } - crate::nav_qt::record("awardalbums"); + crate::nav_qt::record_with("awardalbums", serde_json::json!({ "id": &id, "name": &name })); let generation = with_state(|s| { s.generation = s.generation.wrapping_add(1); s.all_albums.clear(); diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 0fd46c632..ebaefafd0 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -293,11 +293,13 @@ impl qbz_bridge::QbzBridge { crate::settings_string(key.to_string(), value.to_string()); } - /// Purely bridge-local state — no crate handler, nothing to persist. The - /// Slint global is not persisted either: Settings always opens on Audio, - /// the section only has to survive a Loader unmount WITHIN a session. + /// Bridge-local state with one reader outside: the section only has to + /// survive a Loader unmount WITHIN a session (Settings opens on Audio), + /// and it rides to page_restore_qt so "Where you left off" reopens + /// Settings on the same section. pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) { if index == 11 && !crate::orbit_qt::enabled() { return; } + crate::page_restore_qt::note_args("settings", serde_json::json!({ "section": index })); self.as_mut().set_settings_section(index); } diff --git a/crates/qbz-qt/src/browse_qt.rs b/crates/qbz-qt/src/browse_qt.rs index 741b2d97c..a883ec441 100644 --- a/crates/qbz-qt/src/browse_qt.rs +++ b/crates/qbz-qt/src/browse_qt.rs @@ -151,7 +151,10 @@ pub fn open_discover_browse(endpoint: String, title: String) { log::warn!("[qbz-qt] open_discover_browse with an empty endpoint; ignoring"); return; } - crate::nav_qt::record("discoverbrowse"); + crate::nav_qt::record_with( + "discoverbrowse", + serde_json::json!({ "endpoint": &endpoint, "title": &title }), + ); let generation = { let Ok(mut s) = DISCOVER.lock() else { return; diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index f8f29f697..2d0726568 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -710,7 +710,7 @@ pub(crate) fn open_mix(kind: String) { return; } }; - crate::nav_qt::record("mix"); + crate::nav_qt::record_with("mix", serde_json::json!({ "kind": &kind })); let (title, subtitle) = mix_meta(&kind); let generation = with_mix(|s| { s.generation = s.generation.wrapping_add(1); diff --git a/crates/qbz-qt/src/label_qt.rs b/crates/qbz-qt/src/label_qt.rs index 9bf345f5f..8f164f96b 100644 --- a/crates/qbz-qt/src/label_qt.rs +++ b/crates/qbz-qt/src/label_qt.rs @@ -231,7 +231,7 @@ pub fn open_label(label_id: String) { log::warn!("[qbz-qt] open_label: not a Qobuz label id: {label_id}"); return; }; - crate::nav_qt::record("label"); + crate::nav_qt::record_with("label", serde_json::json!({ "id": id.to_string() })); let generation = { let Ok(mut s) = LABEL.lock() else { return; @@ -770,7 +770,7 @@ pub fn open_releases() { s.query.clear(); (s.releases_generation, id) }; - crate::nav_qt::record("labelreleases"); + crate::nav_qt::record_with("labelreleases", serde_json::json!({ "id": id.to_string() })); publish_releases(); fetch_releases(generation, id); } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 67219117d..b14b6614a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -290,6 +290,7 @@ mod disc_meta_qt; mod label_qt; mod local_media_info_qt; mod nav_qt; +mod page_restore_qt; mod now_playing; mod offline_fwd; mod output_labels; @@ -822,6 +823,11 @@ fn on_session_entered() { let ordinary_entry = nav_qt::shell_entry_view(); let logged_off = offline_fwd::engine().status().offline_session; let local_album = local_restore_qt::take_startup_album(); + // The exact page (page_restore_qt: a detail, a root's tab, a Settings + // section, a search). Consumed here even when another door wins, so a + // re-entry in the same process never replays it. `ordinary_entry` already + // names its entry view — the shell mounted that view at construction. + let page = page_restore_qt::take_startup_page(); let entry_view = if local_album.is_some() { "localalbum".to_string() } else if logged_off { @@ -841,6 +847,15 @@ fn on_session_entered() { } else if logged_off { let landing = settings_qt::local_landing_tab(kiosk_profile_qt::active()); navigate_to_tab("local", &landing); + } else if let Some(page) = page { + // Re-run the page's opener on the view the shell already mounted. A + // page that cannot come back (a catalog page while offline, a hidden + // Purchases surface, stale arguments) steps back onto the root that + // was seeded beneath it. + if !page_restore_qt::restore(&page) { + nav_qt::back(); + hydrate_view(&nav_qt::current_view()); + } } now_playing::publish_current(); // Phase 3: fetch Discover > Home (online sessions only — the offline @@ -1431,7 +1446,7 @@ pub(crate) fn open_album(album_id: String) { if offline_fwd::engine().status().is_offline() { return; } - nav_qt::record("album"); + nav_qt::record_with("album", serde_json::json!({ "id": &album_id })); *LAST_DETAIL.lock().unwrap() = ("album".to_string(), album_id.clone()); let runtime = app(); album_bridge::ui(move |mut b| { @@ -1465,7 +1480,7 @@ pub(crate) fn open_artist(artist_id: String) { if offline_fwd::engine().status().is_offline() { return; } - nav_qt::record("artist"); + nav_qt::record_with("artist", serde_json::json!({ "id": &artist_id })); *LAST_DETAIL.lock().unwrap() = ("artist".to_string(), artist_id.clone()); // Warm the Library feed in the background: the page's "In library" tab is // built off it, and on a cold session (or right after an account switch — @@ -1903,7 +1918,7 @@ pub(crate) fn open_playlist(playlist_id: String) { // QBZ as a player without Qobuz — refusing them while offline would gate // local files behind a network the user does not have. if local_playlist_qt::is_local_id(&playlist_id) { - nav_qt::record("playlist"); + nav_qt::record_with("playlist", serde_json::json!({ "id": &playlist_id })); ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); let runtime = app(); spawn(async move { @@ -1917,7 +1932,7 @@ pub(crate) fn open_playlist(playlist_id: String) { log::warn!("[qbz-qt] open_playlist: invalid id {playlist_id}"); return; }; - nav_qt::record("playlist"); + nav_qt::record_with("playlist", serde_json::json!({ "id": &playlist_id })); // Clear the previous playlist before the fetch — same stale-render as the // album and artist views had. ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index 2cc7ff804..fef26fc17 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -2325,7 +2325,7 @@ pub(crate) async fn load(runtime: &Arc>, id: String) /// Grid card click: push the route, then load. pub(crate) fn open(id: String) { - crate::nav_qt::record("mixtapedetail"); + crate::nav_qt::record_with("mixtapedetail", serde_json::json!({ "id": &id })); let runtime = crate::app(); crate::spawn(async move { load(&runtime, id).await; diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index f55796e79..448c3a640 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -32,16 +32,18 @@ //! only the `contextual` branch navigates; `weak`/`none` publish a document //! that a global overlay renders (`musician_qt.rs`). //! -//! `purchase-album` is in that same context-carrying class (its album id lives -//! in the purchases controller, not in the id), and `purchases` is left out of -//! the restore set for a DIFFERENT reason: it is opt-in and ships hidden -//! (`show_purchases`, default false), so restoring onto it could open the app -//! on a surface whose own entry point is switched off. Neither is in -//! `VIEW_TO_PREF` below, so `startup_view()` can never resolve to either — and -//! neither is offered by the Startup page dropdown, whose own list -//! (`settings_qt::STARTUP_PAGE_VALUES`) would have to grow a matching entry -//! first. The Slint build DOES restore `purchases` (`qbz/src/main.rs:637`); -//! bringing that across is an additive change that needs both halves. +//! `purchase-album` and `purchases` are not in `VIEW_TO_PREF` below either: +//! the album id lives in the purchases controller, and the surface is opt-in +//! (`show_purchases`, default false). Since 2026-09-13 page_restore_qt brings +//! both back — the controller notes the album id, and the restore checks the +//! opt-in first, so a hidden surface never reopens the app. +//! +//! WHERE YOU LEFT OFF, EXACTLY (2026-09-13): every route push goes through +//! `record` / `record_with` (the latter carries the opener's arguments, kept +//! on the entry), and page_restore_qt keeps the current page, its arguments +//! and its tab in a per-profile document. `startup_view()` answers with that +//! page's entry view, the history is seeded with the page's root, and the +//! session entry re-runs the opener. //! //! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the //! NavRail's fifth tile and the route the kiosk's full-screen player lives on. @@ -76,6 +78,10 @@ struct Entry { /// scroll scope, Rust stores and returns it without interpreting it. state_scope: String, state_json: String, + /// The opener's arguments (`record_with`), Null for a plain route push: + /// what page_restore_qt keeps for a restart, re-noted when a step lands + /// back on the entry. + args: serde_json::Value, } struct NavHistory { @@ -125,6 +131,9 @@ pub fn set_live_state(scope: &str, json: &str) { live.0.push_str(scope); live.1.clear(); live.1.push_str(json); + drop(live); + // The page's tab rides along to the page document (page_restore_qt). + crate::page_restore_qt::note_state(scope, json); } fn live_state() -> (String, String) { @@ -154,11 +163,12 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { // restored view with a history rooted at Home would offer a Back to a // page the user never opened this session. entries: vec![Entry { - view: startup_view(), + view: startup_root(), scope: String::new(), scroll: 0.0, state_scope: String::new(), state_json: String::new(), + args: serde_json::Value::Null, }], index: 0, }); @@ -192,11 +202,12 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { /// owner's own prefs today read `last_view = "local-library"`, written by Slint. /// Unknown values in either direction resolve to Home rather than guessing. /// -/// This root-only fallback does not carry detail IDs. Local albums now have -/// a separate per-profile context in local_restore_qt, restored at session -/// entry with a missing-entity fallback. Other details, Search and Settings are -/// excluded too — transient and config respectively, and reopening the app -/// inside either is a surprise, not "where you left off". +/// This root-only value carries no detail ids and never will: it is the +/// SHARED file. The exact page — the album, the artist, the playlist, the +/// search, the Settings section, and the tab a root view was on — lives in +/// page_restore_qt's per-profile document since 2026-09-13 (local albums in +/// local_restore_qt's), restored at session entry with this root seeded +/// beneath it and used as the fallback when the page cannot come back. const VIEW_TO_PREF: &[(&str, &str)] = &[ ("home", "home"), ("library", "favorites"), @@ -291,6 +302,12 @@ pub fn crash_level() -> u8 { /// on Home every time. That is the "renders, persists, drives nothing" defect /// class this port has a name for. pub fn startup_view() -> String { + // The exact page, when page_restore_qt has one to reopen (it applies the + // same "remember" and crash-chain gates, plus the launcher-link and kiosk + // ones): its entry view is what the shell mounts. + if let Some(view) = crate::page_restore_qt::startup_view() { + return view; + } if crate::settings_qt::pref_str("startup_page", "home") != "remember" { return "home".to_string(); } @@ -306,6 +323,12 @@ pub fn startup_view() -> String { resolved.to_string() } +/// The history seed: the root beneath a restored page (so Back leads there), +/// else the startup view itself. +fn startup_root() -> String { + crate::page_restore_qt::startup_root().unwrap_or_else(startup_view) +} + /// The view the SHELL should mount on at this session entry. /// /// `init_shell_for_user` is MULTI-ENTRY (login, session restore, "Start @@ -325,7 +348,14 @@ pub fn shell_entry_view() -> String { } pub fn record(view: &str) { - record_entry(view, false); + record_entry(view, false, serde_json::Value::Null); +} + +/// `record` for an opener that can rebuild its page from arguments: they +/// travel with the entry and into page_restore_qt's document. `args` is a +/// JSON object of plain values (an id, a name, a kind). +pub fn record_with(view: &str, args: serde_json::Value) { + record_entry(view, false, args); } /// A user-selected Local Library tab is a distinct history destination even @@ -356,12 +386,12 @@ fn record_view_tab(view: &str, tab: &str, state: &str) { return; } set_live_state(view, state); - record_entry(view, true); + record_entry(view, true, serde_json::Value::Null); next["activeTab"] = serde_json::json!(tab); set_live_state(view, &next.to_string()); } -fn record_entry(view: &str, force: bool) { +fn record_entry(view: &str, force: bool, args: serde_json::Value) { // Persist BEFORE the history mutation so the write reflects the view the // user actually reached, and only for the safe set — a detail view leaves // the stored value on the last safe root, which is exactly what the @@ -369,6 +399,8 @@ fn record_entry(view: &str, force: bool) { if let Some(pref) = pref_for_view(view) { crate::settings_qt::save_pref("last_view", serde_json::json!(pref)); } + // The exact page, for "Where you left off" (page_restore_qt). + crate::page_restore_qt::note_page(view, &args); let (can_back, can_forward, current, pushed) = with_history(|h| { let mut pushed = false; if force || h.entries[h.index].view != view { @@ -390,6 +422,7 @@ fn record_entry(view: &str, force: bool) { scroll: 0.0, state_scope: String::new(), state_json: String::new(), + args, }); h.index += 1; pushed = true; @@ -445,7 +478,7 @@ pub fn forward() { /// move the cursor, and hand the destination's saved position to the shell so /// its scroll container can pick it up when it lays out. fn step(delta: isize) { - let (can_back, can_forward, current, scope, scroll, state_scope, state_json) = + let (can_back, can_forward, current, scope, scroll, state_scope, state_json, args) = with_history(|h| { let next = h.index as isize + delta; if next >= 0 && (next as usize) < h.entries.len() { @@ -468,12 +501,16 @@ fn step(delta: isize) { e.scroll, e.state_scope.clone(), e.state_json.clone(), + e.args.clone(), ) }); // The destination is now the live page: seed the live pair with what it is // about to restore to, so leaving it again before it has reported anything // does not stamp the OUTGOING page's offset onto it. set_live_scroll(&scope, scroll); + // The destination is the page again (its arguments travel on the entry); + // note it BEFORE the state, so the tab lands on the right page. + crate::page_restore_qt::note_page(¤t, &args); set_live_state(&state_scope, &state_json); // Arm only for a position worth restoring. A page that was at the top // needs no restore, and arming for 0 would leave a scope standing until diff --git a/crates/qbz-qt/src/page_restore_qt.rs b/crates/qbz-qt/src/page_restore_qt.rs new file mode 100644 index 000000000..9a27505c6 --- /dev/null +++ b/crates/qbz-qt/src/page_restore_qt.rs @@ -0,0 +1,650 @@ +//! Where the user left off — THE PAGE, not just its section (2026-09-13). +//! +//! `nav_qt`'s `last_view` pref remembers the last ROOT view, in the vocabulary +//! the Slint build shares through `ui_prefs.json`, so "Startup page = Where +//! you left off" could only ever reopen on Home, Library, Local Library, +//! Mixtapes or Collections: a session closed on an album came back on +//! Library > Releases. The rule now is the page the user was on — with its +//! arguments (the album, the artist, the playlist, the search query, the +//! Settings section…) and the tab it was showing. +//! +//! Two layers, both written as the user moves: +//! - `last_view` (`nav_qt::record_entry`) keeps its meaning and its shared +//! vocabulary: the last root, which is also the history seeded UNDER a +//! restored page (so Back has somewhere to go) and the fallback when the +//! page cannot come back. +//! - this module's `last_page_qt.json`, per profile beside +//! `local_navigation_qt.json`: `{"page": {root, view, args, state}}`. +//! `view` and `args` come from `nav_qt::record_with` — every restorable +//! opener records its own arguments — or from `note_args` for the pages +//! whose arguments live outside their route push (the Settings section, +//! the search query, the purchased album); `state` is the tab the mounted +//! view reported through `QbzShell.reportNavState`. +//! +//! The restore is ONE-SHOT per process and honours the gates the local album +//! restore honours: the pref must say "remember", a crash chain bypasses it +//! (nav_qt's ladder), an explicit launcher link outranks it, and the kiosk +//! keeps its own navigation model. The shell mounts the page's view at +//! construction (no Home flash), the history is seeded with the page's root, +//! and `restore()` re-runs the opener at session entry. A page that needs the +//! catalog is skipped while the session is offline, Purchases stays behind its +//! opt-in, and a page that cannot come back lands on the root instead. +//! +//! Local albums stay with `local_restore_qt`, which owns their filter context: +//! this module never stores `localalbum`. Leaving the app on a page that only +//! its opener could rebuild (`scene`, `musician`, `discobuilder`, +//! `metadataeditor`) keeps the previous restorable page in the document, so +//! the restart lands one step back rather than on Home. + +use serde_json::{json, Map, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +const FILE: &str = "last_page_qt.json"; +const MAX_ARGS_BYTES: usize = 8 * 1024; +const MAX_STATE_BYTES: usize = 64 * 1024; + +/// Routes whose page can be rebuilt from what is stored. Everything else in +/// nav_qt's id list is left out on purpose (see the module header). +const RESTORABLE: &[&str] = &[ + "home", + "library", + "local", + "mixtapes", + "collections", + "album", + "artist", + "playlist", + "label", + "labelreleases", + "mixtapedetail", + "award", + "awardalbums", + "mix", + "artistreleases", + "discoverbrowse", + "playlistbrowse", + "recentalbums", + "mostplayedalbums", + "purchases", + "purchase-album", + "settings", + "search", + "queue-view", + "blacklist", + "playlistmanager", + "offlinemanager", + "libraryfolders", +]; + +/// Pages that cannot show anything without the catalog: skipped while the +/// session is offline (a Qobuz playlist joins them at restore time; a local +/// one does not). +const NEEDS_CATALOG: &[&str] = &[ + "album", + "artist", + "label", + "labelreleases", + "award", + "awardalbums", + "mix", + "artistreleases", + "discoverbrowse", + "playlistbrowse", + "purchases", + "purchase-album", + "search", +]; + +/// The roots nav_qt persists as `last_view` (its `VIEW_TO_PREF` set). +const ROOTS: &[&str] = &["home", "library", "local", "mixtapes", "collections"]; + +/// The persisted page. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Page { + /// The last root view: the history seed and the fallback. + #[serde(default)] + pub root: String, + /// The route id the user was on. + #[serde(default)] + pub view: String, + /// The opener's arguments. + #[serde(default)] + pub args: Map, + /// The tab the mounted view reported, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option>, +} + +impl Page { + fn valid(&self) -> bool { + RESTORABLE.contains(&self.view.as_str()) + && serde_json::to_string(&self.args).is_ok_and(|s| s.len() <= MAX_ARGS_BYTES) + && self + .state + .as_ref() + .is_none_or(|s| serde_json::to_string(s).is_ok_and(|t| t.len() <= MAX_STATE_BYTES)) + } + + /// The view the shell records at session entry. The two listings that + /// read their parent's state come back THROUGH that parent — the entry + /// records it, `restore` opens the parent and then the listing, and the + /// history reads root -> parent -> listing, the way it was reached. + pub fn entry_view(&self) -> &str { + match self.view.as_str() { + "labelreleases" => "label", + "awardalbums" => "award", + other => other, + } + } + + fn arg(&self, key: &str) -> String { + self.args + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + } + + fn arg_i64(&self, key: &str) -> i64 { + self.args.get(key).and_then(Value::as_i64).unwrap_or(0) + } + + fn tab(&self) -> Option { + self.state + .as_ref() + .and_then(|s| s.get("activeTab")) + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .map(str::to_string) + } +} + +// --------------------------------------------------------------------------- +// The live document (pure — the globals below wrap it) +// --------------------------------------------------------------------------- + +/// What the process currently believes the page is, plus arguments noted +/// for a route that has not been recorded yet (`note_args` before the QML +/// side pushes the route — the purchased album). +#[derive(Default)] +struct Live { + page: Option, + pending: Option<(String, Map)>, +} + +impl Live { + /// A route was recorded (`nav_qt::record_entry` / `step`). Returns the + /// page to persist when something changed. + fn note_page(&mut self, view: &str, args: &Value) -> Option { + let page = self.page.get_or_insert_with(Page::default); + let before = page.clone(); + if ROOTS.contains(&view) { + page.root = view.to_string(); + } + if RESTORABLE.contains(&view) { + let pending = self + .pending + .take() + .filter(|(pending_view, _)| pending_view == view) + .map(|(_, args)| args); + let explicit = match args { + Value::Object(map) if !map.is_empty() => Some(map.clone()), + _ => None, + }; + page.view = view.to_string(); + page.args = explicit.or(pending).unwrap_or_default(); + page.state = None; + } + (*page != before).then(|| page.clone()) + } + + /// Arguments for a route: applied in place when it is the current page, + /// otherwise kept for its next record. + fn note_args(&mut self, view: &str, args: Map) -> Option { + if serde_json::to_string(&args).is_ok_and(|s| s.len() > MAX_ARGS_BYTES) { + return None; + } + if let Some(page) = self.page.as_mut() { + if page.view == view { + if page.args == args { + return None; + } + page.args = args; + return Some(page.clone()); + } + } + self.pending = Some((view.to_string(), args)); + None + } + + /// The mounted view reported its state: only the tab is restart + /// material (filters and selections are history material, and Local + /// Library persists its own richer set). + fn note_state(&mut self, scope: &str, state: &str) -> Option { + if state.len() > MAX_STATE_BYTES { + return None; + } + let page = self.page.as_mut()?; + if page.view != scope { + return None; + } + let tab = serde_json::from_str::(state) + .ok() + .and_then(|v| v.get("activeTab").cloned()) + .filter(|t| t.as_str().is_some_and(|s| !s.is_empty())); + let next = tab.map(|t| Map::from_iter([("activeTab".to_string(), t)])); + if page.state == next { + return None; + } + page.state = next; + Some(page.clone()) + } +} + +static LIVE: Mutex = Mutex::new(Live { + page: None, + pending: None, +}); +static WRITE: Mutex<()> = Mutex::new(()); +static STARTUP: OnceLock> = OnceLock::new(); +static TAKEN: AtomicBool = AtomicBool::new(false); + +fn path() -> Option { + use qbz_app::user_data::UserDataPaths; + // nav_qt's tests push routes through the real `record`: they must never + // write into the developer's profile. + if cfg!(test) { + return None; + } + // Same identity as local_restore_qt, including the guest profile. + let user = UserDataPaths::load_last_user_id().unwrap_or(0); + Some(UserDataPaths::data_dir_for(user).ok()?.join(FILE)) +} + +fn save_at(path: &Path, page: &Page) { + let _guard = WRITE.lock().unwrap_or_else(|e| e.into_inner()); + let Some(mut doc) = crate::settings_qt::read_json_object(path) else { + return; + }; + let next = json!(page); + if doc.get("page") != Some(&next) { + doc.insert("page".into(), next); + crate::settings_qt::write_json_object_atomic(path, &doc); + } +} + +fn load_at(path: &Path) -> Option { + let doc = crate::settings_qt::read_json_object(path)?; + let page: Page = serde_json::from_value(doc.get("page")?.clone()).ok()?; + page.valid().then_some(page) +} + +fn persist(page: Option) { + if let (Some(page), Some(path)) = (page, path()) { + save_at(&path, &page); + } +} + +fn live(f: impl FnOnce(&mut Live) -> R) -> R { + f(&mut LIVE.lock().unwrap_or_else(|e| e.into_inner())) +} + +// --------------------------------------------------------------------------- +// Writers (nav_qt, the bridges) +// --------------------------------------------------------------------------- + +/// A route reached the top of the history (record) or the cursor moved onto +/// it (back/forward). `args` is the opener's argument object, or Null. +pub fn note_page(view: &str, args: &Value) { + if crate::kiosk_profile_qt::active() { + return; + } + persist(live(|l| l.note_page(view, args))); +} + +/// Arguments for a route that does not carry them on its route push. +pub fn note_args(view: &str, args: Value) { + if crate::kiosk_profile_qt::active() { + return; + } + let Value::Object(args) = args else { return }; + persist(live(|l| l.note_args(view, args))); +} + +/// The mounted view's live state (`nav_qt::set_live_state`). +pub fn note_state(scope: &str, state: &str) { + if crate::kiosk_profile_qt::active() { + return; + } + persist(live(|l| l.note_state(scope, state))); +} + +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +fn startup_page_at( + path: &Path, + remember: bool, + crash_level: u8, + link: bool, + kiosk: bool, +) -> Option { + if !remember || crash_level >= 2 || link || kiosk { + return None; + } + load_at(path) +} + +/// The page this process opens on, decided ONCE (the shell bridge asks at +/// construction, the history seed and the session entry ask later, and all +/// three must agree). `None` = the ordinary `last_view` startup. +fn startup_page() -> Option<&'static Page> { + STARTUP + .get_or_init(|| { + let path = path()?; + let page = startup_page_at( + &path, + crate::settings_qt::pref_str("startup_page", "home") == "remember", + crate::nav_qt::crash_level(), + crate::deep_link_qt::has_pending(), + crate::kiosk_profile_qt::active(), + )?; + log::info!( + "[qbz-qt] startup: remember -> page {:?} args {} tab {:?} (root {:?})", + page.view, + Value::Object(page.args.clone()), + page.tab(), + page.root + ); + Some(page) + }) + .as_ref() +} + +/// The view the shell mounts at construction and records at session entry. +pub fn startup_view() -> Option { + startup_page().map(|p| p.entry_view().to_string()) +} + +/// The root seeded beneath the restored page, so Back leads somewhere. +pub fn startup_root() -> Option { + startup_page() + .map(|p| p.root.clone()) + .filter(|root| ROOTS.contains(&root.as_str())) +} + +/// Only the first session entry of this process restores; a launcher link +/// that arrived after the shell was built still outranks the page. +pub fn take_startup_page() -> Option { + if TAKEN.swap(true, Ordering::AcqRel) { + return None; + } + if crate::deep_link_qt::has_pending() || crate::kiosk_profile_qt::active() { + return None; + } + startup_page().cloned() +} + +/// Re-run the page's opener at session entry. The entry view is already +/// recorded and hydrated by the caller; a detail's opener records the same +/// route again (a no-op push) and loads its document. `false` = the page +/// cannot come back and the caller lands on the root instead. +pub fn restore(page: &Page) -> bool { + let status = crate::offline_fwd::engine().status(); + let offline = status.is_offline(); + let id = page.arg("id"); + let needs_catalog = NEEDS_CATALOG.contains(&page.view.as_str()) + || (page.view == "playlist" && !crate::local_playlist_qt::is_local_id(&id)); + if offline && needs_catalog { + log::info!( + "[qbz-qt] startup: page {:?} needs the catalog and the session is offline; landing on the root", + page.view + ); + return false; + } + let with_id = |open: &dyn Fn(String)| -> bool { + if id.is_empty() { + return false; + } + open(id.clone()); + true + }; + match page.view.as_str() { + "home" | "library" => { + if let Some(tab) = page.tab() { + crate::navigate_to_tab(&page.view, &tab); + } + true + } + // Local Library restores its own tab (local_restore_qt); the MyQBZ + // grids were hydrated with the entry. + "local" | "mixtapes" | "collections" => true, + "album" => with_id(&crate::open_album), + "artist" => with_id(&crate::open_artist), + "playlist" => with_id(&crate::open_playlist), + "label" => with_id(&crate::label_qt::open_label), + "labelreleases" => { + if !with_id(&crate::label_qt::open_label) { + return false; + } + crate::label_qt::open_releases(); + true + } + "mixtapedetail" => with_id(&crate::myqbz_detail_qt::open), + "award" => { + let name = page.arg("name"); + with_id(&|id| crate::award_qt::open_award(id, name.clone())) + } + "awardalbums" => { + let name = page.arg("name"); + if !with_id(&|id| crate::award_qt::open_award(id, name.clone())) { + return false; + } + crate::award_qt::open_albums(); + true + } + "mix" => { + let kind = page.arg("kind"); + if kind.is_empty() { + return false; + } + crate::foryou_qt::open_mix(kind); + true + } + "artistreleases" => { + let artist_id = page.arg("artistId"); + if artist_id.is_empty() { + return false; + } + crate::artist_releases_qt::open( + artist_id, + page.arg("artistName"), + page.arg("releaseType"), + ); + true + } + "discoverbrowse" => { + let endpoint = page.arg("endpoint"); + if endpoint.is_empty() { + return false; + } + crate::browse_qt::open_discover_browse(endpoint, page.arg("title")); + true + } + "playlistbrowse" => { + crate::browse_qt::open_playlist_browse(); + true + } + "recentalbums" => { + crate::browse_qt::open_recent_albums(); + true + } + "mostplayedalbums" => { + crate::browse_qt::open_most_played_albums(); + true + } + // Opt-in surface: never reopen the app on a page whose own entry + // point is switched off. The list loads itself on mount. + "purchases" => crate::settings_qt::pref_bool("show_purchases", false), + "purchase-album" => { + if !crate::settings_qt::pref_bool("show_purchases", false) { + return false; + } + with_id(&crate::purchases_qt::open_album) + } + "settings" => { + // Same guard as the bridge's `settings_set_section`. + let section = page.arg_i64("section").clamp(0, 64) as i32; + let section = if section == 11 && !crate::orbit_qt::enabled() { + 0 + } else { + section + }; + crate::ui(move |mut b| b.as_mut().set_settings_section(section)); + true + } + "search" => { + let query = page.arg("query"); + if query.chars().count() < 2 { + return false; + } + let tab = page.arg_i64("tab").clamp(0, 16) as i32; + let runtime = crate::app(); + crate::spawn(async move { + crate::search_qt::submit(&runtime, &query, Some(tab)).await; + }); + true + } + // Mounted by the entry; reads live bridges or refreshes itself. + "queue-view" | "libraryfolders" => true, + "blacklist" => { + crate::blacklist_qt::open_manager(); + true + } + "playlistmanager" => { + crate::playlist_manager_qt::navigate(); + true + } + "offlinemanager" => { + crate::offline_manager_qt::open(); + true + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(view: &str) -> Page { + Page { + root: "library".into(), + view: view.into(), + args: Map::new(), + state: None, + } + } + + #[test] + fn a_detail_keeps_its_arguments_and_a_root_becomes_the_fallback() { + let mut live = Live::default(); + assert!(live.note_page("library", &Value::Null).is_some()); + let album = live + .note_page("album", &json!({"id": "123"})) + .expect("the page changed"); + assert_eq!(album.view, "album"); + assert_eq!(album.root, "library"); + assert_eq!(album.args.get("id").and_then(Value::as_str), Some("123")); + assert_eq!(album.entry_view(), "album"); + // The same push again changes nothing. + assert!(live.note_page("album", &json!({"id": "123"})).is_none()); + // A page only its opener could rebuild keeps the album on record. + assert!(live.note_page("scene", &Value::Null).is_none()); + assert_eq!(live.page.as_ref().unwrap().view, "album"); + // Back onto Home: root and view move together. + let home = live.note_page("home", &Value::Null).unwrap(); + assert_eq!((home.root.as_str(), home.view.as_str()), ("home", "home")); + assert!(home.args.is_empty()); + } + + #[test] + fn arguments_noted_before_the_route_push_ride_that_push() { + let mut live = Live::default(); + live.note_page("purchases", &Value::Null); + assert!(live + .note_args( + "purchase-album", + json!({"id": "9"}).as_object().unwrap().clone() + ) + .is_none()); + let detail = live.note_page("purchase-album", &Value::Null).unwrap(); + assert_eq!(detail.args.get("id").and_then(Value::as_str), Some("9")); + // On the current page they apply in place; unchanged ones are quiet. + live.note_page("settings", &Value::Null); + let settings = live + .note_args( + "settings", + json!({"section": 4}).as_object().unwrap().clone(), + ) + .unwrap(); + assert_eq!(settings.arg_i64("section"), 4); + assert!(live + .note_args( + "settings", + json!({"section": 4}).as_object().unwrap().clone() + ) + .is_none()); + } + + #[test] + fn only_the_tab_of_the_current_page_is_kept_from_a_state_report() { + let mut live = Live::default(); + live.note_page("library", &Value::Null); + let tabbed = live + .note_state("library", r#"{"activeTab":"albums","filter":"x"}"#) + .unwrap(); + assert_eq!(tabbed.tab().as_deref(), Some("albums")); + assert!(tabbed.state.as_ref().unwrap().get("filter").is_none()); + assert!(live + .note_state("home", r#"{"activeTab":"forYou"}"#) + .is_none()); + assert!(live + .note_state("library", "not json") + .is_some_and(|p| p.state.is_none())); + // A new page starts without a tab. + let album = live.note_page("album", &json!({"id": "1"})).unwrap(); + assert!(album.state.is_none()); + } + + #[test] + fn the_document_round_trips_and_the_gates_bypass_it_without_deleting_it() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("users/1/last_page_qt.json"); + let mut saved = page("labelreleases"); + saved.args.insert("id".into(), json!("77")); + save_at(&file, &saved); + assert_eq!( + startup_page_at(&file, true, 1, false, false), + Some(saved.clone()) + ); + assert_eq!(saved.entry_view(), "label"); + for (remember, crash, link, kiosk) in [ + (false, 1, false, false), + (true, 2, false, false), + (true, 1, true, false), + (true, 1, false, true), + ] { + assert!(startup_page_at(&file, remember, crash, link, kiosk).is_none()); + } + assert_eq!(startup_page_at(&file, true, 1, false, false), Some(saved)); + // An unrestorable or malformed page is ignored, never crashed on. + for bad in [json!({"view": "scene"}), json!({"view": 7}), json!("album")] { + std::fs::write(&file, json!({"page": bad}).to_string()).unwrap(); + assert!(startup_page_at(&file, true, 1, false, false).is_none()); + } + } +} diff --git a/crates/qbz-qt/src/purchases_qt.rs b/crates/qbz-qt/src/purchases_qt.rs index a6a7becab..0f905e3ba 100644 --- a/crates/qbz-qt/src/purchases_qt.rs +++ b/crates/qbz-qt/src/purchases_qt.rs @@ -2366,6 +2366,9 @@ fn begin_open_album(album_id: String, verified_entitlement: Option Date: Mon, 14 Sep 2026 00:39:47 -0600 Subject: [PATCH 06/27] fix(qt): a submenu stays open while the pointer is over its rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardMenu's pair-hover check read the popup's HoverHandler, which lives on the background and never fires over a row: the rows' MouseAreas take the hover events, so the panel only counted as hovered on its 5px padding. Crossing from that padding into the child's first row read as "left both menus" and the 350 ms leave timer closed the pair — the Copy submenu closed the whole context menu as soon as it was entered. A menu is now hovered while its panel OR one of its rows is (one hot row at a time, held by reference so a destroyed row cannot leave it stuck), and the pair check reads that on both menus. scripts/test-card-menu-qml.sh drives the pointer through the parent row, the child's rows, back to a plain row and out, and joins the runtime gate and CI. cc --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/qml/controls/CardMenu.qml | 27 +++++++-- scripts/qml-tests/tst_card_menu.qml | 81 +++++++++++++++++++++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-card-menu-qml.sh | 18 ++++++ 5 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 scripts/qml-tests/tst_card_menu.qml create mode 100755 scripts/test-card-menu-qml.sh diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index ba0f420df..a14dd9e8c 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -87,6 +87,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" push: @@ -116,6 +117,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" workflow_dispatch: diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index a00182bba..c921750ce 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -93,8 +93,16 @@ QbzContextMenu { leaveTimer.stop() } readonly property bool subOpen: cmRoot._sub !== null && cmRoot._sub.opened - readonly property bool pairHovered: cmRoot.menuHovered - || (cmRoot._sub !== null && cmRoot._sub.menuHovered) + // The pointer is over THIS menu: its panel or one of its rows. The rows' + // MouseAreas take the hover events, so the panel's HoverHandler alone + // sees only the 5px padding — the pair looked "left" the moment the + // pointer crossed from that padding into a child row, and the leave + // timer closed both (2026-09-14). One row is hot at a time, so an item + // reference beats a counter that a destroyed row could leave high. + property Item _hotRow: null + readonly property bool hovering: cmRoot.menuHovered || cmRoot._hotRow !== null + readonly property bool pairHovered: cmRoot.hovering + || (cmRoot._sub !== null && cmRoot._sub.hovering) onPairHoveredChanged: { if (!cmRoot.subOpen) return @@ -113,7 +121,10 @@ QbzContextMenu { } } } - onClosed: closeSubmenu() + onClosed: { + cmRoot._hotRow = null + closeSubmenu() + } QbzTheme { id: theme } @@ -133,6 +144,10 @@ QbzContextMenu { height: isSep ? 7 : (cmRoot.kioskHost ? 44 : 33) radius: isSep ? 0 : 5 color: hot ? theme.surfaceHover : "transparent" + Component.onDestruction: { + if (cmRoot._hotRow === row) + cmRoot._hotRow = null + } // ContextMenuItem.slint: `opacity: enabled ? 1.0 : 0.4`. opacity: (isSep || rowEnabled) ? 1.0 : 0.4 @@ -202,8 +217,12 @@ QbzContextMenu { // row puts it away. Leaving the row itself does nothing — // the pointer is usually on its way into the child. onContainsMouseChanged: { - if (!containsMouse) + if (!containsMouse) { + if (cmRoot._hotRow === row) + cmRoot._hotRow = null return + } + cmRoot._hotRow = row if (row.hasSub) cmRoot.openSubmenu(row) else if (cmRoot.subOpen) diff --git a/scripts/qml-tests/tst_card_menu.qml b/scripts/qml-tests/tst_card_menu.qml new file mode 100644 index 000000000..0340542e2 --- /dev/null +++ b/scripts/qml-tests/tst_card_menu.qml @@ -0,0 +1,81 @@ +// CardMenu submenus: hovering the child must keep BOTH menus open (the +// rows' MouseAreas must count as menu hover), moving to a plain row in the +// parent closes the child only, and leaving both closes both. +import QtQuick +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 700; height: 500 + + Item { id: anchorItem; x: 120; y: 80; width: 10; height: 10 } + + CardMenu { + id: menu + entries: [ + { "label": "One", "icon": "", "action": "one" }, + { "label": "Copy", "icon": "", "action": "copy", "submenu": [ + { "label": "Sub A", "icon": "", "action": "sub-a" }, + { "label": "Sub B", "icon": "", "action": "sub-b" } + ] }, + { "label": "Three", "icon": "", "action": "three" } + ] + } + SignalSpy { id: picks; target: menu; signalName: "picked" } + + TestCase { + name: "CardMenuSubmenu" + when: windowShown + // Row i's centre inside a menu (5px padding, 33px rows). + function rowY(m, i) { return m.y + 5 + 33 * i + 16 } + + function test_a_hover_keeps_the_pair_open() { + menu.openAtCursor(anchorItem, 0, 0) + wait(50) + verify(menu.opened) + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + verify(menu.subOpen, "hovering the Copy row opens the child") + var sub = menu._sub + verify(sub.opened) + mouseMove(root, sub.x + 40, rowY(sub, 0)) + wait(600) + verify(menu.opened, "the parent stays open while the child's first row is hovered") + verify(sub.opened, "the child stays open while its first row is hovered") + mouseMove(root, sub.x + 40, rowY(sub, 1)) + wait(600) + verify(menu.opened && sub.opened, "still open on the child's second row") + // Back onto a plain parent row: the child goes away, the parent stays. + mouseMove(root, menu.x + 40, rowY(menu, 0)) + wait(120) + verify(menu.opened) + verify(!menu.subOpen, "a plain row puts the child away") + // Leave both: both close. + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + verify(menu.subOpen) + mouseMove(root, 650, 450) + wait(700) + verify(!menu.opened, "leaving both closes the parent") + } + + function test_b_picking_in_the_child_closes_everything_and_forwards() { + picks.clear() + menu.openAtCursor(anchorItem, 0, 0) + wait(50) + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + var sub = menu._sub + verify(sub && sub.opened) + mouseMove(root, sub.x + 40, rowY(sub, 1)) + wait(100) + mouseClick(root, sub.x + 40, rowY(sub, 1)) + wait(100) + compare(picks.count, 1) + compare(picks.signalArguments[0][0], "sub-b") + verify(!menu.opened) + verify(!sub.opened) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index 5af3d6841..0f7c14ec7 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -10,6 +10,7 @@ bash scripts/test-text-edit-menu-qml.sh bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh +bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs node scripts/test_qt_library.mjs diff --git a/scripts/test-card-menu-qml.sh b/scripts/test-card-menu-qml.sh new file mode 100755 index 000000000..3ea889ae9 --- /dev/null +++ b/scripts/test-card-menu-qml.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# CardMenu submenu regression: hovering the child keeps the pair open, +# leaving both closes both, a pick in the child forwards and closes all. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_card_menu_bins="$QT_ROOT_DIR/bin" +else + qt_card_menu_qmake="${QMAKE:-}" + if [[ -z "$qt_card_menu_qmake" ]]; then + qt_card_menu_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_card_menu_bins="$("$qt_card_menu_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_card_menu_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_card_menu.qml \ + -import scripts/qml-tests/imports From fed0816e9bccf5f316f9beebd3e3e25c1b32f7b7 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:39:47 -0600 Subject: [PATCH 07/27] fix(qt): ambient mode reaches every chrome control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the ambient field some controls still painted the opaque theme token while their neighbours used the half-alpha one: the album track filter and its select toggle, the Library tab strip's host, the Local Library icon selects, the header's active nav items and open playlists button, the queue sidebar's now-playing card and artwork placeholders, the Listen list's floating actions, and the shared SettingsButton, IconTextButton, multi-select bar, text area, load-more pill, tree row selection, sidebar count chips, Purchases tab strip, play-history filter box and Orbit banner. Each now takes the ambient-aware fill the header search and QbzTabBar already use (theme.ambientOn ? …A50 : …). Tooltips, popups and modals stay opaque on purpose: they are read over content, not chrome. cc --- crates/qbz-qt/qml/controls/IconTextButton.qml | 2 +- crates/qbz-qt/qml/controls/QbzLoadMore.qml | 2 +- crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml | 2 +- crates/qbz-qt/qml/controls/QbzSearchField.qml | 3 ++- crates/qbz-qt/qml/controls/QbzTextArea.qml | 2 +- crates/qbz-qt/qml/controls/SettingsButton.qml | 2 +- crates/qbz-qt/qml/shell/HeaderBar.qml | 11 +++++++---- crates/qbz-qt/qml/shell/OrbitBanner.qml | 2 +- crates/qbz-qt/qml/shell/QueuePanel.qml | 7 ++++--- crates/qbz-qt/qml/shell/Sidebar.qml | 4 ++-- crates/qbz-qt/qml/views/AlbumView.qml | 3 ++- crates/qbz-qt/qml/views/PlayHistoryView.qml | 2 +- crates/qbz-qt/qml/views/PurchasesView.qml | 2 +- crates/qbz-qt/qml/views/QueueView.qml | 4 ++-- crates/qbz-qt/qml/views/library/LibraryToolbar.qml | 6 ++++-- crates/qbz-qt/qml/views/local/LocalIconSelect.qml | 2 +- crates/qbz-qt/qml/views/local/TreeRow.qml | 2 +- 17 files changed, 33 insertions(+), 25 deletions(-) diff --git a/crates/qbz-qt/qml/controls/IconTextButton.qml b/crates/qbz-qt/qml/controls/IconTextButton.qml index ae4e4bc42..738ff7763 100644 --- a/crates/qbz-qt/qml/controls/IconTextButton.qml +++ b/crates/qbz-qt/qml/controls/IconTextButton.qml @@ -36,7 +36,7 @@ Rectangle { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: hoverArea.containsMouse && root.btnEnabled ? theme.surfaceHover : theme.surfaceElevated + color: hoverArea.containsMouse && root.btnEnabled ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) opacity: root.btnEnabled ? 1.0 : 0.4 Row { diff --git a/crates/qbz-qt/qml/controls/QbzLoadMore.qml b/crates/qbz-qt/qml/controls/QbzLoadMore.qml index 8a4329d75..733c1b2e8 100644 --- a/crates/qbz-qt/qml/controls/QbzLoadMore.qml +++ b/crates/qbz-qt/qml/controls/QbzLoadMore.qml @@ -181,7 +181,7 @@ Column { width: pillText.implicitWidth + 36 height: 32 radius: theme.radiusSm - color: pillArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard + color: pillArea.containsMouse ? theme.surfaceElevated : (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard) border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml b/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml index 4af9028ad..506f52168 100644 --- a/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml +++ b/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml @@ -47,7 +47,7 @@ Rectangle { height: 44 radius: 8 - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) Row { anchors.fill: parent diff --git a/crates/qbz-qt/qml/controls/QbzSearchField.qml b/crates/qbz-qt/qml/controls/QbzSearchField.qml index 263474576..fc01332bf 100644 --- a/crates/qbz-qt/qml/controls/QbzSearchField.qml +++ b/crates/qbz-qt/qml/controls/QbzSearchField.qml @@ -32,7 +32,8 @@ Rectangle { implicitWidth: 168 implicitHeight: 34 radius: 6 - color: theme.surfaceElevated + // The header search's ambient-aware fill, so every search box reads alike. + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: input.activeFocus ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/QbzTextArea.qml b/crates/qbz-qt/qml/controls/QbzTextArea.qml index 03ce073ee..67bbe2c57 100644 --- a/crates/qbz-qt/qml/controls/QbzTextArea.qml +++ b/crates/qbz-qt/qml/controls/QbzTextArea.qml @@ -33,7 +33,7 @@ Rectangle { width: 240 height: 96 radius: theme.radiusSm - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: input.activeFocus ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/SettingsButton.qml b/crates/qbz-qt/qml/controls/SettingsButton.qml index 6aba6e94a..6821ebaf7 100644 --- a/crates/qbz-qt/qml/controls/SettingsButton.qml +++ b/crates/qbz-qt/qml/controls/SettingsButton.qml @@ -36,7 +36,7 @@ Rectangle { border.width: root.activeFocus ? 2 : 1 border.color: root.activeFocus ? theme.accent : (danger ? theme.danger : theme.borderSubtle) - color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: enabled && btnArea.containsMouse ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) opacity: enabled ? 1.0 : 0.4 activeFocusOnTab: enabled Accessible.role: Accessible.Button diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index c5a994f5b..f02362947 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -63,6 +63,9 @@ Rectangle { // HeaderBar.slint's with-alpha(app-background-surface-alpha)). color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard readonly property bool ambientOn: theme.ambientOn + /// The fill of an ACTIVE nav item / open button: elevated, at half alpha + /// under the ambient field like every other chrome control. + readonly property color activeFill: root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated /// Raised by the app menu's "Report an issue" row; AppShell owns the modal. @@ -312,7 +315,7 @@ Rectangle { height: 30 width: purchaseRow.implicitWidth radius: theme.radiusSm - color: purchaseTab.isActive ? theme.surfaceElevated + color: purchaseTab.isActive ? root.activeFill : purchaseArea.containsMouse ? theme.surfaceHover : "transparent" Row { @@ -363,7 +366,7 @@ Rectangle { width: tabRow.implicitWidth radius: theme.radiusSm opacity: isEnabled ? 1.0 : 0.5 - color: isActive ? theme.surfaceElevated + color: isActive ? root.activeFill : (tabArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent" Row { @@ -439,7 +442,7 @@ Rectangle { height: 30 radius: theme.radiusSm opacity: isEnabled ? 1.0 : 0.5 - color: isActive ? theme.surfaceElevated + color: isActive ? root.activeFill : (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent" // Baked glyph, or the section's own raw image when it carries one // (My QBZ branding) — see shell/NavSectionGlyph.qml. @@ -640,7 +643,7 @@ Rectangle { height: 30 radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter - color: plPopup.opened ? theme.surfaceElevated + color: plPopup.opened ? root.activeFill : plBtnArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "list-music" diff --git a/crates/qbz-qt/qml/shell/OrbitBanner.qml b/crates/qbz-qt/qml/shell/OrbitBanner.qml index 7325e1aef..a1ed412dc 100644 --- a/crates/qbz-qt/qml/shell/OrbitBanner.qml +++ b/crates/qbz-qt/qml/shell/OrbitBanner.qml @@ -12,7 +12,7 @@ Rectangle { property string hostName: "" visible: remoteActive height: remoteActive ? Math.max(38, label.implicitHeight + 16) : 0 - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: theme.accent Accessible.role: Accessible.StaticText diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index eb9dd76b6..f9bd286c9 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -429,7 +429,7 @@ Rectangle { height: 34 radius: 4 anchors.verticalCenter: parent.verticalCenter - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) // No clip: RoundedImage self-confines. One batch root per // visible queue row. RoundedImage { @@ -893,7 +893,8 @@ Rectangle { width: parent.width height: 44 radius: theme.radiusSm - color: theme.surfaceElevated + // Ambient-aware: the one opaque card left in a translucent sidebar. + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) Row { anchors.fill: parent anchors.leftMargin: 6 @@ -906,7 +907,7 @@ Rectangle { height: 34 radius: 4 anchors.verticalCenter: parent.verticalCenter - color: theme.surfaceCard + color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard) // No clip: RoundedImage self-confines. RoundedImage { anchors.fill: parent diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index 364dc467e..3529ea280 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -1009,7 +1009,7 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter height: 22 radius: 4 - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: theme.borderSubtle TextInput { @@ -1286,7 +1286,7 @@ Rectangle { width: parent.width - 4 * 26 height: 22 radius: 4 - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: theme.borderSubtle TextInput { diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index 5b50fcc2f..feb59b0ed 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -1591,7 +1591,8 @@ Rectangle { height: 30 radius: 6 anchors.verticalCenter: parent.verticalCenter - color: selectToggleArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: selectToggleArea.containsMouse ? theme.surfaceHover + : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: root.multiSelect ? theme.accent : theme.borderSubtle QbzIcon { diff --git a/crates/qbz-qt/qml/views/PlayHistoryView.qml b/crates/qbz-qt/qml/views/PlayHistoryView.qml index 4a52bbff8..0e6ffdaf2 100644 --- a/crates/qbz-qt/qml/views/PlayHistoryView.qml +++ b/crates/qbz-qt/qml/views/PlayHistoryView.qml @@ -103,7 +103,7 @@ Rectangle { radius: 6 border.width: 1 border.color: theme.borderSubtle - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) Row { anchors.fill: parent diff --git a/crates/qbz-qt/qml/views/PurchasesView.qml b/crates/qbz-qt/qml/views/PurchasesView.qml index c6d71c45b..08d71aaed 100644 --- a/crates/qbz-qt/qml/views/PurchasesView.qml +++ b/crates/qbz-qt/qml/views/PurchasesView.qml @@ -355,7 +355,7 @@ Rectangle { required property var modelData width: Math.max(96, kioskTabLabel.implicitWidth + 24) height: 64 - color: root.tab === modelData.id ? theme.surfaceElevated : "transparent" + color: root.tab === modelData.id ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : "transparent" Text { id: kioskTabLabel; anchors.centerIn: parent; text: parent.modelData.label; color: theme.textPrimary; font.pixelSize: 18 } MouseArea { anchors.fill: parent; onClicked: QbzPurchases.setTab(parent.modelData.id) } } diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index b7c8d2f71..e6c71d03b 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -885,7 +885,7 @@ Rectangle { width: root.kioskHost ? 64 : 36 height: root.kioskHost ? 64 : 36 radius: theme.radiusSm - color: theme.surfaceCard + color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard) border.width: 1 border.color: root.followPlaying ? theme.accent : theme.borderSubtle @@ -908,7 +908,7 @@ Rectangle { width: root.kioskHost ? 64 : 36 height: root.kioskHost ? 64 : 36 radius: theme.radiusSm - color: theme.surfaceCard + color: (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard) border.width: 1 border.color: theme.borderSubtle QbzIconButton { diff --git a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml index 619ef3d43..37e49ecde 100644 --- a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml +++ b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml @@ -82,7 +82,7 @@ Item { width: 30 height: 30 radius: 6 - color: active ? theme.surfaceElevated + color: active ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : ttArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: parent.name @@ -234,7 +234,9 @@ Item { y: 25 - height / 2 width: tabRow.width height: tabRow.height - color: theme.surfaceElevated + // Ambient-aware like the tab bar it hosts (the strip read opaque + // under the ambient field while Discover's and Local's had alpha). + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) radius: 6 Row { id: tabRow diff --git a/crates/qbz-qt/qml/views/local/LocalIconSelect.qml b/crates/qbz-qt/qml/views/local/LocalIconSelect.qml index 1042b0e16..2b85c1310 100644 --- a/crates/qbz-qt/qml/views/local/LocalIconSelect.qml +++ b/crates/qbz-qt/qml/views/local/LocalIconSelect.qml @@ -40,7 +40,7 @@ Item { radius: 6 border.width: 1 border.color: root.marked ? theme.accent : theme.borderSubtle - color: btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: btnArea.containsMouse ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) QbzIcon { name: root.iconName width: 15 diff --git a/crates/qbz-qt/qml/views/local/TreeRow.qml b/crates/qbz-qt/qml/views/local/TreeRow.qml index 6fff7fa07..18e94645d 100644 --- a/crates/qbz-qt/qml/views/local/TreeRow.qml +++ b/crates/qbz-qt/qml/views/local/TreeRow.qml @@ -35,7 +35,7 @@ Rectangle { height: 26 radius: 6 - color: selected ? theme.surfaceElevated + color: selected ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : rowArea.containsMouse ? theme.surfaceHover : "transparent" // Row body — declared FIRST so the chevron / checkbox win their clicks. From 2e984e14cd48f5f641c1ec414feaf374828d0d7d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:39:47 -0600 Subject: [PATCH 08/27] feat(qt): queue and Listen list row menus offer Go to album and Go to artist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both queue surfaces (the sidebar panel and the Listen list view) gain the two entries, routed by source the way the local rows already do: a catalog row opens the album through its catalog id, with its title and artist riding along as the unavailable-page hint, and the artist by id; a local row (file, Plex, Jellyfin, Subsonic) opens the album through the queue's navigation key — the key local_playback stamps so it opens the same page the card does — and the artist by name through the Local Library Artists tab. A row without the key or the name shows no entry. cc --- crates/qbz-qt/qml/shell/QueuePanel.qml | 41 +++++++++++++++++++++++ crates/qbz-qt/qml/views/QueueView.qml | 45 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index f9bd286c9..f6e8d60f1 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -316,6 +316,41 @@ Rectangle { // Row action icon button (the panel's small IconButton). + // Go to album / Go to artist, by SOURCE (2026-09-14). A catalog row routes + // through its catalog ids (title and artist ride along as the + // unavailable-page hint); a local row — file, Plex, Jellyfin, Subsonic — + // through the queue's NAVIGATION KEY (local_playback.rs stamps the album + // key the card itself opens, prefixed for media servers) and the artist + // NAME into the Local Library Artists tab, the local surfaces' own routes + // (local/LocalTrackRow.qml). A row without the key or the name shows no + // entry. + function rowCanGoAlbum(row) { + return (row.albumId || "") !== "" + } + function rowCanGoArtist(row) { + return row.isLocal === true ? (row.artist || "") !== "" : (row.artistId || "") !== "" + } + function goToAlbum(row) { + if (!rowCanGoAlbum(row)) + return + if (row.isLocal === true) { + QbzLocal.openAlbum(row.albumId) + QbzShell.navigateTo("localalbum") + } else { + QbzAlbum.openAlbumFrom(row.albumId, row.album || "", row.artist || "") + } + } + function goToArtist(row) { + if (!rowCanGoArtist(row)) + return + if (row.isLocal === true) { + QbzLocal.openArtistByName(row.artist) + QbzShell.navigateTo("local") + } else { + QbzArtist.openArtist(row.artistId) + } + } + // One UP NEXT / History row (QueueRow.slint). component QueueRow: Rectangle { id: qrRoot @@ -556,6 +591,10 @@ Rectangle { : QbzSession.tr("Stop after this", QbzSession.trRev), "icon": "circle-stop", "action": "stop-after" }, { "label": QbzSession.tr("Remove all after", QbzSession.trRev), "icon": "list-x", "action": "remove-after" }, ] + if (root.rowCanGoAlbum(row)) + items.push({ "label": QbzSession.tr("Go to album", QbzSession.trRev), "icon": "disc-3", "action": "go-album" }) + if (root.rowCanGoArtist(row)) + items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }) if (row.isEphemeral !== true) { // LOCAL/Plex rows drop it too: the queue's // `add_to_playlist` refuses them @@ -609,6 +648,8 @@ Rectangle { else if (a === "add-to-playlist") QbzQueue.queueAddToPlaylist(rowIndex) else if (a === "track-info") qrRoot.openTrackInfo() else if (a === "favorite") root.toggleFav(row) + else if (a === "go-album") root.goToAlbum(row) + else if (a === "go-artist") root.goToArtist(row) } } } diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index e6c71d03b..03d5d59cb 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -258,6 +258,41 @@ Rectangle { return "" } + // Go to album / Go to artist, by SOURCE (2026-09-14). A catalog row routes + // through its catalog ids (title and artist ride along as the + // unavailable-page hint); a local row — file, Plex, Jellyfin, Subsonic — + // through the queue's NAVIGATION KEY (local_playback.rs stamps the album + // key the card itself opens, prefixed for media servers) and the artist + // NAME into the Local Library Artists tab, the local surfaces' own routes + // (local/LocalTrackRow.qml). A row without the key or the name shows no + // entry. + function rowCanGoAlbum(row) { + return (row.albumId || "") !== "" + } + function rowCanGoArtist(row) { + return row.isLocal === true ? (row.artist || "") !== "" : (row.artistId || "") !== "" + } + function goToAlbum(row) { + if (!rowCanGoAlbum(row)) + return + if (row.isLocal === true) { + QbzLocal.openAlbum(row.albumId) + QbzShell.navigateTo("localalbum") + } else { + QbzAlbum.openAlbumFrom(row.albumId, row.album || "", row.artist || "") + } + } + function goToArtist(row) { + if (!rowCanGoArtist(row)) + return + if (row.isLocal === true) { + QbzLocal.openArtistByName(row.artist) + QbzShell.navigateTo("local") + } else { + QbzArtist.openArtist(row.artistId) + } + } + function queueMenu(row) { var t = QbzSession.tr var r = QbzSession.trRev @@ -277,6 +312,12 @@ Rectangle { ? t("Cancel stop after this", r) : t("Stop after this", r), "icon": "circle-stop", "action": "stop-after", "external": true }) } + if (root.rowCanGoAlbum(row)) + items.push({ "label": t("Go to album", r), "icon": "disc-3", + "action": "go-album", "external": true }) + if (root.rowCanGoArtist(row)) + items.push({ "label": t("Go to artist", r), "icon": "user", + "action": "go-artist", "external": true }) if (row.isEphemeral !== true) { if (row.isLocal !== true) items.push({ "label": t("Add to playlist", r), "icon": "list-plus", @@ -315,6 +356,10 @@ Rectangle { QbzQueue.queueToggleStopAfter(row.id) else if (action === "add-to-playlist") QbzPlaylistPicker.openForTrack(row.id) + else if (action === "go-album") + root.goToAlbum(row) + else if (action === "go-artist") + root.goToArtist(row) } function historyVisualIndex(row) { From 4bf5cb1a0e7b58fe6fb30c1fa3a6a360d620a762 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:39:47 -0600 Subject: [PATCH 09/27] feat(qt): the playlist editor's folder select is searchable Folder trees outgrow a plain list; QbzSelect's filter box (the ui-control standard's searchable dropdown) now sits on top of it. cc --- crates/qbz-qt/qml/controls/PlaylistEditModal.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml index 55e7cf195..b1b580efd 100644 --- a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -360,6 +360,10 @@ Item { } QbzSelect { menuWidth: 260 + // A folder tree grows past what a plain list can + // scan: the filter box, like the ui-control standard's + // searchable dropdown. + searchable: true options: root.folderOptions currentIndex: root.folderIndex enabled: !root.busy From ed7fa588ea8d33cb37b03c13298fc5ffe884429f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:49:54 -0600 Subject: [PATCH 10/27] fix(qt): elevated controls lift on hover instead of darkening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons that rest on surfaceElevated used surfaceHover for their hover fill: a 6% white wash meant for rows that rest transparent. Over an elevated button it lands DARKER than the rest fill — on a modal's dark ground the Cancel/Save/footer buttons went near-black under the pointer. QbzTheme gains surfaceElevatedHover (a lift of surfaceElevated, derived so every palette has it), its ambient half-alpha twin, and the elevatedFill / elevatedHoverFill pair an ambient-aware control paints. Every site with the old pairing (65 across modals, toolbars, selects, circle actions, chips and cards) now hovers to the lift. cc --- crates/qbz-qt/qml/controls/AddToMixtapeModal.qml | 2 +- crates/qbz-qt/qml/controls/BrowseGenreButton.qml | 4 ++-- crates/qbz-qt/qml/controls/DiscMetaModal.qml | 2 +- crates/qbz-qt/qml/controls/DiscoverConfigModal.qml | 4 ++-- crates/qbz-qt/qml/controls/FolderEditPanel.qml | 4 ++-- crates/qbz-qt/qml/controls/GenreFilterPopup.qml | 4 ++-- crates/qbz-qt/qml/controls/IconTextButton.qml | 2 +- .../qbz-qt/qml/controls/OfflineCacheChoiceModal.qml | 6 +++--- crates/qbz-qt/qml/controls/PlaylistCreateModal.qml | 2 +- crates/qbz-qt/qml/controls/PlaylistImportModal.qml | 2 +- crates/qbz-qt/qml/controls/PlaylistPickerModal.qml | 2 +- .../qbz-qt/qml/controls/PlaylistTagFilterButton.qml | 4 ++-- crates/qbz-qt/qml/controls/QbzCircleAction.qml | 4 ++-- crates/qbz-qt/qml/controls/QbzConfirmModal.qml | 2 +- crates/qbz-qt/qml/controls/QbzSelect.qml | 4 ++-- crates/qbz-qt/qml/controls/QbzToolButton.qml | 2 +- crates/qbz-qt/qml/controls/SettingsButton.qml | 2 +- crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml | 2 +- crates/qbz-qt/qml/settings/DacWizardModal.qml | 6 +++--- crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml | 2 +- crates/qbz-qt/qml/settings/MigrationSetupModal.qml | 2 +- crates/qbz-qt/qml/shell/AboutModal.qml | 6 +++--- crates/qbz-qt/qml/shell/CastPicker.qml | 2 +- crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml | 4 ++-- crates/qbz-qt/qml/shell/HeaderBar.qml | 2 +- crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml | 2 +- .../qml/shell/QconnectPlaybackConflictModal.qml | 2 +- crates/qbz-qt/qml/shell/QueuePanel.qml | 2 +- crates/qbz-qt/qml/shell/ReportIssueModal.qml | 2 +- crates/qbz-qt/qml/theme/QbzTheme.qml | 12 ++++++++++++ crates/qbz-qt/qml/views/AlbumView.qml | 4 ++-- crates/qbz-qt/qml/views/ArtistReleasesView.qml | 2 +- crates/qbz-qt/qml/views/BlacklistManagerView.qml | 2 +- crates/qbz-qt/qml/views/HomeView.qml | 2 +- crates/qbz-qt/qml/views/LibraryView.qml | 2 +- crates/qbz-qt/qml/views/LocalAlbumView.qml | 2 +- crates/qbz-qt/qml/views/PlaylistView.qml | 4 ++-- crates/qbz-qt/qml/views/PurchaseAlbumView.qml | 2 +- crates/qbz-qt/qml/views/PurchasesView.qml | 2 +- crates/qbz-qt/qml/views/SearchView.qml | 2 +- crates/qbz-qt/qml/views/library/LibraryToolbar.qml | 4 ++-- crates/qbz-qt/qml/views/local/FilterChip.qml | 2 +- crates/qbz-qt/qml/views/local/FolderSubcard.qml | 2 +- crates/qbz-qt/qml/views/local/LocalChrome.qml | 4 ++-- crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml | 4 ++-- crates/qbz-qt/qml/views/local/LocalFilterButton.qml | 2 +- crates/qbz-qt/qml/views/local/LocalGenresTab.qml | 7 +++---- crates/qbz-qt/qml/views/local/LocalIconSelect.qml | 2 +- crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml | 2 +- crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml | 2 +- .../qml/views/playlistmanager/PmFolderCard.qml | 2 +- .../qml/views/playlistmanager/PmFolderChip.qml | 2 +- .../qbz-qt/qml/views/purchases/PurchasesToolbar.qml | 6 +++--- 53 files changed, 87 insertions(+), 76 deletions(-) diff --git a/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml b/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml index ec794fd53..a8a09e491 100644 --- a/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml +++ b/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml @@ -650,7 +650,7 @@ Item { height: 38 radius: theme.radiusSm - color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml index d3210c09e..1051120cc 100644 --- a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml +++ b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml @@ -77,8 +77,8 @@ Rectangle { // .slint:77 — the same chip in three mounts). Accent and hover keep theirs. color: root.active ? theme.accent : genreArea.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill Row { id: genreRow diff --git a/crates/qbz-qt/qml/controls/DiscMetaModal.qml b/crates/qbz-qt/qml/controls/DiscMetaModal.qml index ac6683f77..79b74c761 100644 --- a/crates/qbz-qt/qml/controls/DiscMetaModal.qml +++ b/crates/qbz-qt/qml/controls/DiscMetaModal.qml @@ -162,7 +162,7 @@ Item { height: 30 radius: 6 color: active ? theme.accent - : (pArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (pArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) Text { id: pLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml index 12dfffeea..c9b5852ec 100644 --- a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml +++ b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml @@ -408,7 +408,7 @@ Item { width: refreshRow.width + 24 height: 34 radius: theme.radiusSm - color: refreshArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: refreshArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Row { @@ -481,7 +481,7 @@ Item { width: footerRow.width + 24 height: 34 radius: theme.radiusSm - color: footerArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: footerArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/FolderEditPanel.qml b/crates/qbz-qt/qml/controls/FolderEditPanel.qml index b95868d20..1075750cd 100644 --- a/crates/qbz-qt/qml/controls/FolderEditPanel.qml +++ b/crates/qbz-qt/qml/controls/FolderEditPanel.qml @@ -180,7 +180,7 @@ Item { radius: 8 color: presetTile.active ? theme.accent - : (presetArea.containsMouse ? theme.surfaceHover + : (presetArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) QbzIcon { anchors.centerIn: parent @@ -217,7 +217,7 @@ Item { radius: 8 color: imageTile.active ? theme.accent - : (imageArea.containsMouse ? theme.surfaceHover + : (imageArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) // No `clip: true` — it is a rectangular scissor and does // not follow `radius`. RoundedImage masks its own corners. diff --git a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml index de599ff9d..769f97410 100644 --- a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml +++ b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml @@ -415,7 +415,7 @@ Item { height: 38 radius: 6 color: chip.modelData.selected ? theme.accent - : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + : chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { anchors.fill: parent @@ -508,7 +508,7 @@ Item { radius: 6 opacity: root.liveCount > 0 ? 1.0 : 0.5 color: clearArea.containsMouse && root.liveCount > 0 - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated Text { anchors.centerIn: parent text: QbzSession.tr("Clear filter", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/controls/IconTextButton.qml b/crates/qbz-qt/qml/controls/IconTextButton.qml index 738ff7763..264bfeb57 100644 --- a/crates/qbz-qt/qml/controls/IconTextButton.qml +++ b/crates/qbz-qt/qml/controls/IconTextButton.qml @@ -36,7 +36,7 @@ Rectangle { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: hoverArea.containsMouse && root.btnEnabled ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + color: hoverArea.containsMouse && root.btnEnabled ? theme.elevatedHoverFill : theme.elevatedFill opacity: root.btnEnabled ? 1.0 : 0.4 Row { diff --git a/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml b/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml index 6eb67ac9a..81887caca 100644 --- a/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml +++ b/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml @@ -128,7 +128,7 @@ Item { width: parent.width height: allCol.implicitHeight + 24 radius: theme.radiusSm - color: allArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: allArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: allArea.containsMouse ? theme.accent : theme.borderSubtle Row { @@ -178,7 +178,7 @@ Item { width: parent.width height: missingCol.implicitHeight + 24 radius: theme.radiusSm - color: missingArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: missingArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: missingArea.containsMouse ? theme.accent : theme.borderSubtle Row { @@ -229,7 +229,7 @@ Item { width: cancelLabel.implicitWidth + 32 height: 36 radius: theme.radiusSm - color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: cancelLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml index 0cbef2fe1..3209e23bf 100644 --- a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml @@ -410,7 +410,7 @@ Item { height: 36 radius: theme.radiusSm color: (cancelArea.containsMouse && !root.busy) - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: root.busy ? 0.5 : 1.0 Text { id: cancelLabel diff --git a/crates/qbz-qt/qml/controls/PlaylistImportModal.qml b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml index 619b985eb..b5d49ec16 100644 --- a/crates/qbz-qt/qml/controls/PlaylistImportModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml @@ -462,7 +462,7 @@ Item { height: 36 radius: theme.radiusSm color: (pickArea.containsMouse && !root.loading) - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: root.loading ? 0.5 : 1.0 Text { id: pickLabel diff --git a/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml index c283d747a..bafcce8c3 100644 --- a/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml @@ -317,7 +317,7 @@ Item { width: 20 height: 20 radius: 10 - color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent name: "x" diff --git a/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml b/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml index 8369182a3..0396ba82b 100644 --- a/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml +++ b/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml @@ -31,8 +31,8 @@ Rectangle { // Translucent at rest under the dynamic background, like every other chip // in this toolbar band (PlaylistTagFilter.slint:77). color: tagArea.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill QbzFilterTip { id: tagTip diff --git a/crates/qbz-qt/qml/controls/QbzCircleAction.qml b/crates/qbz-qt/qml/controls/QbzCircleAction.qml index 466b326c0..c9855cbb0 100644 --- a/crates/qbz-qt/qml/controls/QbzCircleAction.qml +++ b/crates/qbz-qt/qml/controls/QbzCircleAction.qml @@ -111,8 +111,8 @@ Rectangle { // (CircleAction.slint:53-55). The overlay variant above sits on // artwork and keeps its white scale. : ((cbArea.containsMouse || active) - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated))) + ? theme.elevatedHoverFill + : theme.elevatedFill)) border.width: primary ? 0 : 1.5 border.color: overlay ? "#ccffffff" : theme.borderStrong // A loading button keeps FULL opacity — it is working, not unavailable, diff --git a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml index e929f344a..4bbecf516 100644 --- a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml +++ b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml @@ -220,7 +220,7 @@ Item { width: cancelLbl.implicitWidth + 32 height: root.kioskHost ? 44 : 36 radius: theme.radiusSm - color: cancelArea.containsMouse ? theme.surfaceHover + color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated activeFocusOnTab: root.opened border.width: activeFocus ? 2 : 0 diff --git a/crates/qbz-qt/qml/controls/QbzSelect.qml b/crates/qbz-qt/qml/controls/QbzSelect.qml index 99b0eb855..ecf73774d 100644 --- a/crates/qbz-qt/qml/controls/QbzSelect.qml +++ b/crates/qbz-qt/qml/controls/QbzSelect.qml @@ -74,8 +74,8 @@ Rectangle { // shows through the control (QbzSelect.slint:110-116). Hover keeps its own // token, which is already translucent. color: selArea.containsMouse && enabled - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill opacity: enabled ? 1.0 : 0.4 activeFocusOnTab: enabled Accessible.role: Accessible.ComboBox diff --git a/crates/qbz-qt/qml/controls/QbzToolButton.qml b/crates/qbz-qt/qml/controls/QbzToolButton.qml index e1ca89f4a..b940a7706 100644 --- a/crates/qbz-qt/qml/controls/QbzToolButton.qml +++ b/crates/qbz-qt/qml/controls/QbzToolButton.qml @@ -69,7 +69,7 @@ Rectangle { : 2 * root.padH + row.implicitWidth radius: 6 color: root.filled ? theme.accent - : (toolArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (toolArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) border.width: (root.large && !(root.active && !root.fillActive)) ? 0 : 1 border.color: (root.active && !root.fillActive) ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/SettingsButton.qml b/crates/qbz-qt/qml/controls/SettingsButton.qml index 6821ebaf7..0cd9e1496 100644 --- a/crates/qbz-qt/qml/controls/SettingsButton.qml +++ b/crates/qbz-qt/qml/controls/SettingsButton.qml @@ -36,7 +36,7 @@ Rectangle { border.width: root.activeFocus ? 2 : 1 border.color: root.activeFocus ? theme.accent : (danger ? theme.danger : theme.borderSubtle) - color: enabled && btnArea.containsMouse ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + color: enabled && btnArea.containsMouse ? theme.elevatedHoverFill : theme.elevatedFill opacity: enabled ? 1.0 : 0.4 activeFocusOnTab: enabled Accessible.role: Accessible.Button diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml b/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml index f059dd714..8437157cb 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml @@ -890,7 +890,7 @@ Rectangle { border.width: 1 border.color: chip.active ? theme.accent : theme.borderSubtle color: chip.active ? theme.accent - : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + : chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: chipLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/settings/DacWizardModal.qml b/crates/qbz-qt/qml/settings/DacWizardModal.qml index a14e4a56a..0af445724 100644 --- a/crates/qbz-qt/qml/settings/DacWizardModal.qml +++ b/crates/qbz-qt/qml/settings/DacWizardModal.qml @@ -754,7 +754,7 @@ Item { width: parent.width height: 40 radius: theme.radiusSm - color: cfgHead.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cfgHead.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: activeFocus ? theme.accent : theme.borderSubtle activeFocusOnTab: visible && enabled @@ -978,7 +978,7 @@ Item { event.accepted = true } } - color: prevTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: prevTa.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent width: 16 @@ -1016,7 +1016,7 @@ Item { event.accepted = true } } - color: nextTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: nextTa.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent width: 16 diff --git a/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml b/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml index 428d3c458..55bc2bb69 100644 --- a/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml +++ b/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml @@ -269,7 +269,7 @@ Item { width: resetRow.width + 24 height: root.kioskHost ? 44 : 34 radius: theme.radiusSm - color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: resetArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: activeFocus ? theme.accent : theme.borderSubtle activeFocusOnTab: root.opened diff --git a/crates/qbz-qt/qml/settings/MigrationSetupModal.qml b/crates/qbz-qt/qml/settings/MigrationSetupModal.qml index e8ea5e624..08ebe89a6 100644 --- a/crates/qbz-qt/qml/settings/MigrationSetupModal.qml +++ b/crates/qbz-qt/qml/settings/MigrationSetupModal.qml @@ -221,7 +221,7 @@ Item { width: body.width height: 92 radius: theme.radiusMd - color: selected ? theme.surfaceHover : theme.surfaceElevated + color: selected ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: selected ? 2 : 1 border.color: selected ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/shell/AboutModal.qml b/crates/qbz-qt/qml/shell/AboutModal.qml index 15b356b7c..be7948bbf 100644 --- a/crates/qbz-qt/qml/shell/AboutModal.qml +++ b/crates/qbz-qt/qml/shell/AboutModal.qml @@ -104,7 +104,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: lbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: lbArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: lbRow @@ -162,7 +162,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: hcArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: hcArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: hcRow @@ -224,7 +224,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: (sc.clickable && scArea.containsMouse) ? theme.surfaceHover : theme.surfaceElevated + color: (sc.clickable && scArea.containsMouse) ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: scText diff --git a/crates/qbz-qt/qml/shell/CastPicker.qml b/crates/qbz-qt/qml/shell/CastPicker.qml index 294758e66..b8fb41894 100644 --- a/crates/qbz-qt/qml/shell/CastPicker.qml +++ b/crates/qbz-qt/qml/shell/CastPicker.qml @@ -528,7 +528,7 @@ Popup { height: 34 radius: theme.radiusSm color: selected ? theme.accent - : (tabArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (tabArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) Row { anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml b/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml index 5ca3aec19..c4ab5f74d 100644 --- a/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml +++ b/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml @@ -452,7 +452,7 @@ Item { radius: theme.radiusSm color: entryRow.recording ? theme.bgHover - : (capArea.containsMouse ? theme.surfaceHover + : (capArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) border.width: (entryRow.recording || entryRow.entry.modified) ? 2 : 1 // Colour priority (trap 6): danger on @@ -499,7 +499,7 @@ Item { height: 30 anchors.verticalCenter: parent.verticalCenter radius: theme.radiusSm - color: resetArea.containsMouse ? theme.surfaceHover + color: resetArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index f02362947..8a840ce25 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -1260,7 +1260,7 @@ Rectangle { border.width: 1 border.color: theme.borderSubtle opacity: QbzSession.connectivity === 2 ? 0.4 : 1.0 - color: signInArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: signInArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: signInText anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml b/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml index b15d2c7db..b03e9249a 100644 --- a/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml +++ b/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml @@ -272,7 +272,7 @@ Item { width: customizeLabel.implicitWidth + 40 height: 40 radius: theme.radiusSm - color: customizeArea.containsMouse ? theme.surfaceHover + color: customizeArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: customizeArea.containsMouse ? theme.accent diff --git a/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml b/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml index dbe44886d..877d6b61a 100644 --- a/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml +++ b/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml @@ -126,7 +126,7 @@ Popup { width: parent.width height: 92 radius: theme.radiusSm - color: optionArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: optionArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: optionArea.containsMouse ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index f6e8d60f1..aaf431c78 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -1313,7 +1313,7 @@ Rectangle { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { anchors.fill: parent text: QbzSession.tr("Cancel Timer", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/shell/ReportIssueModal.qml b/crates/qbz-qt/qml/shell/ReportIssueModal.qml index 728d2d4ee..5a1424a9d 100644 --- a/crates/qbz-qt/qml/shell/ReportIssueModal.qml +++ b/crates/qbz-qt/qml/shell/ReportIssueModal.qml @@ -138,7 +138,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: goArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: goArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: goLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/theme/QbzTheme.qml b/crates/qbz-qt/qml/theme/QbzTheme.qml index a672df4aa..8f6fbe22f 100644 --- a/crates/qbz-qt/qml/theme/QbzTheme.qml +++ b/crates/qbz-qt/qml/theme/QbzTheme.qml @@ -59,6 +59,18 @@ QtObject { readonly property color surfaceCard: _c("surfaceCard") readonly property color surfaceElevated: _c("surfaceElevated") readonly property color surfaceHover: _c("surfaceHover") + /// Hover fill for a control whose REST fill is surfaceElevated: a lift of + /// that surface. NOT `surfaceHover` — that is a 6% white wash meant for + /// rows that rest transparent, and over an elevated button it lands + /// DARKER than the rest fill (the modal buttons that "went black on + /// hover", 2026-09-14). Derived, so every palette gets it for free. + readonly property color surfaceElevatedHover: Qt.lighter(surfaceElevated, isDark ? 1.35 : 0.94) + /// The same lift for the ambient half-alpha fill, a little more opaque so + /// the hover still reads as a lift over the field. + readonly property color surfaceElevatedHoverA50: Qt.rgba(surfaceElevatedHover.r, surfaceElevatedHover.g, surfaceElevatedHover.b, 0.62) + /// The ambient-aware pair an elevated control paints: rest and hover. + readonly property color elevatedFill: ambientOn ? surfaceElevatedA50 : surfaceElevated + readonly property color elevatedHoverFill: ambientOn ? surfaceElevatedHoverA50 : surfaceElevatedHover readonly property color bgHover: _c("bgHover") readonly property color textPrimary: _c("textPrimary") // Weak-ramp hardening under ambient on LIGHT themes (owner, 2026-08-31): diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index feb59b0ed..397279ec6 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -1591,8 +1591,8 @@ Rectangle { height: 30 radius: 6 anchors.verticalCenter: parent.verticalCenter - color: selectToggleArea.containsMouse ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + color: selectToggleArea.containsMouse ? theme.elevatedHoverFill + : theme.elevatedFill border.width: 1 border.color: root.multiSelect ? theme.accent : theme.borderSubtle QbzIcon { diff --git a/crates/qbz-qt/qml/views/ArtistReleasesView.qml b/crates/qbz-qt/qml/views/ArtistReleasesView.qml index d7add9f45..016dfe0a2 100644 --- a/crates/qbz-qt/qml/views/ArtistReleasesView.qml +++ b/crates/qbz-qt/qml/views/ArtistReleasesView.qml @@ -257,7 +257,7 @@ Rectangle { width: retryLabel.implicitWidth + 32 height: 34 radius: 6 - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: retryLabel diff --git a/crates/qbz-qt/qml/views/BlacklistManagerView.qml b/crates/qbz-qt/qml/views/BlacklistManagerView.qml index 946e5b762..90db7a37f 100644 --- a/crates/qbz-qt/qml/views/BlacklistManagerView.qml +++ b/crates/qbz-qt/qml/views/BlacklistManagerView.qml @@ -282,7 +282,7 @@ Rectangle { width: toggleRow.implicitWidth + 28 height: root.kioskHost ? 44 : 34 radius: theme.radiusSm - color: toggleArea.containsMouse ? theme.surfaceHover + color: toggleArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: toggleRow diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index ee807854d..6b2a62881 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -1796,7 +1796,7 @@ Rectangle { height: 32 radius: 6 color: retryArea.containsMouse - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 6bff0cffa..00455aaa2 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -1339,7 +1339,7 @@ Rectangle { height: 32 radius: 6 anchors.horizontalCenter: parent.horizontalCenter - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml index f90f6a38e..7959f1dc7 100644 --- a/crates/qbz-qt/qml/views/LocalAlbumView.qml +++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml @@ -588,7 +588,7 @@ Rectangle { border.width: 1 border.color: root.multiSelect ? theme.accent : theme.borderSubtle color: (root.multiSelect || selectArea.containsMouse) - ? theme.surfaceHover + ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "square-check-big" diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 455d80800..551ce3763 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -740,8 +740,8 @@ Rectangle { // PlaylistView.slint:855-859 — resting fill // translucent under the dynamic background. color: sortArea.containsMouse - ? theme.surfaceHover - : (root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill border.width: 1 border.color: theme.borderSubtle Row { diff --git a/crates/qbz-qt/qml/views/PurchaseAlbumView.qml b/crates/qbz-qt/qml/views/PurchaseAlbumView.qml index 433f87219..45c5cc7c7 100644 --- a/crates/qbz-qt/qml/views/PurchaseAlbumView.qml +++ b/crates/qbz-qt/qml/views/PurchaseAlbumView.qml @@ -400,7 +400,7 @@ Rectangle { height: 32 radius: 6 anchors.horizontalCenter: parent.horizontalCenter - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/PurchasesView.qml b/crates/qbz-qt/qml/views/PurchasesView.qml index 08d71aaed..3b37f1226 100644 --- a/crates/qbz-qt/qml/views/PurchasesView.qml +++ b/crates/qbz-qt/qml/views/PurchasesView.qml @@ -446,7 +446,7 @@ Rectangle { height: 32 radius: 6 anchors.horizontalCenter: parent.horizontalCenter - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml index 937a32722..c3a2ab90d 100644 --- a/crates/qbz-qt/qml/views/SearchView.qml +++ b/crates/qbz-qt/qml/views/SearchView.qml @@ -689,7 +689,7 @@ Rectangle { height: 24 radius: 12 anchors.verticalCenter: parent.verticalCenter - color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: clrArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: clrArea diff --git a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml index 37e49ecde..55f0fdafe 100644 --- a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml +++ b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml @@ -113,8 +113,8 @@ Item { // rest fill and, when a genre is applied, the SAME accent OUTLINE — // not an accent fill — so the three toolbar controls read identically // in their modified state. - color: gtbArea.containsMouse ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + color: gtbArea.containsMouse ? theme.elevatedHoverFill + : theme.elevatedFill border.width: gtb.active ? 1 : 0 border.color: gtb.active ? theme.accent : "transparent" Row { diff --git a/crates/qbz-qt/qml/views/local/FilterChip.qml b/crates/qbz-qt/qml/views/local/FilterChip.qml index 96fd49596..12b26e1b8 100644 --- a/crates/qbz-qt/qml/views/local/FilterChip.qml +++ b/crates/qbz-qt/qml/views/local/FilterChip.qml @@ -22,7 +22,7 @@ Rectangle { border.width: 1 border.color: active ? theme.accent : theme.borderSubtle color: active ? theme.accent - : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + : chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: lbl diff --git a/crates/qbz-qt/qml/views/local/FolderSubcard.qml b/crates/qbz-qt/qml/views/local/FolderSubcard.qml index 97717ed5a..6e6b18e09 100644 --- a/crates/qbz-qt/qml/views/local/FolderSubcard.qml +++ b/crates/qbz-qt/qml/views/local/FolderSubcard.qml @@ -26,7 +26,7 @@ Rectangle { radius: 6 border.width: 1 border.color: cardArea.containsMouse ? theme.accent : theme.borderSubtle - color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Column { anchors.fill: parent diff --git a/crates/qbz-qt/qml/views/local/LocalChrome.qml b/crates/qbz-qt/qml/views/local/LocalChrome.qml index a946703aa..10ac507e3 100644 --- a/crates/qbz-qt/qml/views/local/LocalChrome.qml +++ b/crates/qbz-qt/qml/views/local/LocalChrome.qml @@ -120,8 +120,8 @@ Item { readonly property bool busy: QbzLocal.localDiscOpening color: (!openBtn.busy && (openArea.containsMouse || openMenu.opened)) - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill Row { id: openRow diff --git a/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml b/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml index 50d48655d..7c1aa5460 100644 --- a/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml +++ b/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml @@ -314,8 +314,8 @@ Item { radius: 6 anchors.verticalCenter: parent.verticalCenter color: ripArea.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill Row { id: ripRow anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/views/local/LocalFilterButton.qml b/crates/qbz-qt/qml/views/local/LocalFilterButton.qml index 304901d74..04dcb64d3 100644 --- a/crates/qbz-qt/qml/views/local/LocalFilterButton.qml +++ b/crates/qbz-qt/qml/views/local/LocalFilterButton.qml @@ -25,7 +25,7 @@ Item { border.width: 1 border.color: root.view && root.view.filterCount > 0 ? theme.accent : theme.borderSubtle - color: area.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: area.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "list-filter" diff --git a/crates/qbz-qt/qml/views/local/LocalGenresTab.qml b/crates/qbz-qt/qml/views/local/LocalGenresTab.qml index 0ce85ac5a..a86b604fb 100644 --- a/crates/qbz-qt/qml/views/local/LocalGenresTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalGenresTab.qml @@ -198,9 +198,8 @@ Item { height: 28 radius: 6 color: chipMouse.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 - : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill border.width: 1 border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle @@ -312,7 +311,7 @@ Item { radius: 5 color: theme.ambientOn ? theme.surfaceElevatedA50 - : (caretArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (caretArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) border.width: 1 border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle QbzIcon { diff --git a/crates/qbz-qt/qml/views/local/LocalIconSelect.qml b/crates/qbz-qt/qml/views/local/LocalIconSelect.qml index 2b85c1310..5560c0b20 100644 --- a/crates/qbz-qt/qml/views/local/LocalIconSelect.qml +++ b/crates/qbz-qt/qml/views/local/LocalIconSelect.qml @@ -40,7 +40,7 @@ Item { radius: 6 border.width: 1 border.color: root.marked ? theme.accent : theme.borderSubtle - color: btnArea.containsMouse ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + color: btnArea.containsMouse ? theme.elevatedHoverFill : theme.elevatedFill QbzIcon { name: root.iconName width: 15 diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml index c79aeb4b5..4b08e225d 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml @@ -47,7 +47,7 @@ Rectangle { color: cardArea.containsMouse ? theme.surfaceHover : (root.compact ? "transparent" : theme.surfaceCard) border.width: root.compact ? 0 : 1 - border.color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated // `label · meta`, but never a bare " · ": a default/half-built `item` (a // standalone instance, or a pre-publish frame) has neither half and would diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml index 77524835f..68af97b9f 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml @@ -524,7 +524,7 @@ Rectangle { width: 28 height: 28 radius: 14 - color: newArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: newArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent name: "plus" diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml index 635df4196..7b6e7eb31 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml @@ -35,7 +35,7 @@ Rectangle { width: 160 height: 150 radius: 10 - color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 // padding 16 / spacing 8 / centred column. diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml index 502fb8f13..2763a27bd 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml @@ -40,7 +40,7 @@ Rectangle { height: 52 radius: 8 - color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 Row { diff --git a/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml b/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml index 27b72bd57..a6e7febfc 100644 --- a/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml +++ b/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml @@ -96,7 +96,7 @@ Item { width: tb.kioskHost ? Math.max(64, tbRow.width) : tbRow.width height: tb.kioskHost ? 64 : 30 radius: 6 - color: tbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: tbArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: tbRow height: parent.height @@ -396,7 +396,7 @@ Item { width: root.kioskHost ? 64 : 30 height: root.kioskHost ? 64 : 30 radius: 6 - color: filterArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: filterArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "list-filter" width: 16 @@ -579,7 +579,7 @@ Item { height: root.kioskHost ? 64 : 30 radius: 6 anchors.verticalCenter: parent.verticalCenter - color: viewArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: viewArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: root.viewMode === "grid" ? "list" : "layout-grid" width: 16 From a564ed936bedbc6ba610ab002f32daf887889c54 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:49:54 -0600 Subject: [PATCH 11/27] fix(qt): the header search text stops short of the clear cross and the Enter glyph The TextInput spanned the whole box, so a long query ran under the cross and the Enter hint. It now ends at whichever of the two is showing, and TextInput scrolls the text inside that width. cc --- crates/qbz-qt/qml/shell/HeaderBar.qml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index 8a840ce25..fd1bf4c71 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -997,9 +997,13 @@ Rectangle { QbzTextEditMenu { } id: searchInput anchors.left: parent.left - anchors.right: parent.right + // Stop short of whatever sits at the right end (the clear cross, + // the Enter glyph while the cortinilla is open): TextInput scrolls + // its text within its own width, so nothing hides under them. + anchors.right: clearCross.visible ? clearCross.left + : (enterHint.visible ? enterHint.left : parent.right) anchors.leftMargin: 30 - anchors.rightMargin: 8 + anchors.rightMargin: (clearCross.visible || enterHint.visible) ? 2 : 8 height: parent.height color: theme.textPrimary font.pixelSize: 13 @@ -1118,6 +1122,7 @@ Rectangle { // sits to the LEFT of the ↵ hint while the dropdown is open, at the // right edge otherwise. Rectangle { + id: clearCross visible: searchInput.text !== "" anchors.right: QbzSearch.cortinillaOpen ? enterHint.left : parent.right anchors.rightMargin: QbzSearch.cortinillaOpen ? 4 : 5 From 3d4d3bc6ed64269216a19258a9011af892c325cc Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:49:54 -0600 Subject: [PATCH 12/27] feat(qt): the sidebar's playlist and My QBZ filters follow the search-box contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both inline fields were bare TextInputs: no clear cross, no Escape. They are QbzSearchField now — Escape empties the filter, hands the keyboard back and folds the field into its title; the cross empties it and keeps the caret. The control grew what a 22px header row needs: an optional leading glyph (the toggle already shows one), a cross sized to the field, and an `escaped` signal for hosts that fold the field away. cc --- crates/qbz-qt/qml/controls/QbzSearchField.qml | 24 +++-- crates/qbz-qt/qml/shell/Sidebar.qml | 90 +++++++------------ 2 files changed, 47 insertions(+), 67 deletions(-) diff --git a/crates/qbz-qt/qml/controls/QbzSearchField.qml b/crates/qbz-qt/qml/controls/QbzSearchField.qml index fc01332bf..45e41440a 100644 --- a/crates/qbz-qt/qml/controls/QbzSearchField.qml +++ b/crates/qbz-qt/qml/controls/QbzSearchField.qml @@ -22,6 +22,14 @@ Rectangle { property alias text: input.text property string placeholder: "" + /// The leading magnifier; off for hosts whose own toggle already shows one + /// (the sidebar's inline filters). + property bool showGlyph: true + /// Escape emptied the field and handed the keyboard back; a host that + /// folds the field away listens here. + signal escaped() + /// The clear cross scales with a short field (the sidebar's 22px rows). + readonly property int clearSize: Math.min(22, Math.max(16, height - 2)) property int glyphSize: 14 property int fontPx: 13 property int sidePadding: 10 @@ -80,6 +88,7 @@ Rectangle { } QbzIcon { + visible: root.showGlyph name: "search" width: root.glyphSize height: root.glyphSize @@ -92,7 +101,7 @@ Rectangle { QbzTextEditMenu { } anchors.left: parent.left anchors.right: clearSlot.left - anchors.leftMargin: root.sidePadding + root.glyphSize + 7 + anchors.leftMargin: root.sidePadding + (root.showGlyph ? root.glyphSize + 7 : 0) anchors.rightMargin: 4 height: parent.height color: theme.textPrimary @@ -106,6 +115,7 @@ Rectangle { } Keys.onEscapePressed: function (event) { root.clearAndBlur() + root.escaped() event.accepted = true } Text { @@ -124,19 +134,19 @@ Rectangle { id: clearSlot anchors.right: parent.right anchors.rightMargin: 5 - width: input.text !== "" ? 22 : 0 + width: input.text !== "" ? root.clearSize : 0 height: parent.height visible: input.text !== "" Rectangle { anchors.centerIn: parent - width: 22 - height: 22 - radius: 11 + width: root.clearSize + height: root.clearSize + radius: root.clearSize / 2 color: clearArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "x" - width: 12 - height: 12 + width: Math.round(root.clearSize * 0.55) + height: Math.round(root.clearSize * 0.55) anchors.centerIn: parent tintName: clearArea.containsMouse ? "textPrimary" : "muted" } diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index 3529ea280..06571b6ea 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -903,12 +903,10 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: { root.myqbzSearchOpen = !root.myqbzSearchOpen - if (!root.myqbzSearchOpen) { - myqbzSearchEdit.text = "" - root.myqbzSearch = "" - } else { - myqbzSearchEdit.forceActiveFocus() - } + if (!root.myqbzSearchOpen) + myqbzSearchEdit.clear() + else + myqbzSearchEdit.focusField() } } } @@ -999,8 +997,11 @@ Rectangle { onClicked: QbzShell.navigateTo("collections") } } - // Inline search field (filters the tree client-side). - Rectangle { + // Inline search field (filters the tree client-side). The + // shared search box: Escape empties it AND folds it away, + // the cross empties it and keeps the caret. + QbzSearchField { + id: myqbzSearchEdit visible: root.myqbzSearchOpen anchors.left: myqbzBrandIcon.right anchors.leftMargin: 8 @@ -1009,28 +1010,12 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter height: 22 radius: 4 - color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) - border.width: 1 - border.color: theme.borderSubtle - TextInput { - QbzTextEditMenu { } - id: myqbzSearchEdit - anchors.fill: parent - anchors.leftMargin: 6 - color: theme.textPrimary - font.pixelSize: 12 - verticalAlignment: Text.AlignVCenter - clip: true - onTextEdited: root.myqbzSearch = text - Text { - visible: myqbzSearchEdit.text === "" - anchors.fill: parent - text: QbzSession.tr("Search My QBZ", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 12 - verticalAlignment: Text.AlignVCenter - } - } + showGlyph: false + sidePadding: 6 + fontPx: 12 + placeholder: QbzSession.tr("Search My QBZ", QbzSession.trRev) + onEdited: function (text) { root.myqbzSearch = text } + onEscaped: root.myqbzSearchOpen = false } } @@ -1280,34 +1265,21 @@ Rectangle { font.letterSpacing: 1 verticalAlignment: Text.AlignVCenter } - // Inline search input (filters entries, recursive). - Rectangle { + // Inline search input (filters entries, recursive). The shared + // search box: Escape empties it AND folds it back into the title, + // the cross empties it and keeps the caret. + QbzSearchField { + id: searchEdit visible: root.searchOpen width: parent.width - 4 * 26 height: 22 radius: 4 - color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) - border.width: 1 - border.color: theme.borderSubtle - TextInput { - QbzTextEditMenu { } - id: searchEdit - anchors.fill: parent - anchors.leftMargin: 6 - color: theme.textPrimary - font.pixelSize: 12 - verticalAlignment: Text.AlignVCenter - clip: true - onTextEdited: QbzShell.sidebarSearch(text) - Text { - visible: searchEdit.text === "" - anchors.fill: parent - text: QbzSession.tr("Search playlists", QbzSession.trRev) - color: theme.textMuted - font.pixelSize: 12 - verticalAlignment: Text.AlignVCenter - } - } + showGlyph: false + sidePadding: 6 + fontPx: 12 + placeholder: QbzSession.tr("Search playlists", QbzSession.trRev) + onEdited: function (text) { QbzShell.sidebarSearch(text) } + onEscaped: root.searchOpen = false } // Search toggle. @@ -1330,12 +1302,10 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: { root.searchOpen = !root.searchOpen - if (!root.searchOpen) { - searchEdit.text = "" - QbzShell.sidebarSearch("") - } else { - searchEdit.forceActiveFocus() - } + if (!root.searchOpen) + searchEdit.clear() + else + searchEdit.focusField() } } } From 34ea883d07b90d267072e7d3a0b9cfed25983335 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:49:54 -0600 Subject: [PATCH 13/27] fix(qt): the replacement modal spells out each candidate's quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier badge alone reads as a mark, and its hover tooltip never shows on these rows because the row's MouseArea takes the hover. The exact tier ("24-bit / 96 kHz") now sits beside the badge — it is the decision aid the list exists for. cc --- crates/qbz-qt/qml/controls/TrackReplacementModal.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/qbz-qt/qml/controls/TrackReplacementModal.qml b/crates/qbz-qt/qml/controls/TrackReplacementModal.qml index 3125dbf75..0ea7a2d55 100644 --- a/crates/qbz-qt/qml/controls/TrackReplacementModal.qml +++ b/crates/qbz-qt/qml/controls/TrackReplacementModal.qml @@ -411,6 +411,18 @@ Item { tierOverride: cand.modelData.qualityTier || "" label: cand.modelData.qualityDetail || "" } + // The mark alone says "CD" or "Hi-Res"; the + // exact tier is the decision, so it is spelled + // out beside it. (The badge's own hover tooltip + // never shows on this row: the row's MouseArea + // takes the hover.) + Text { + anchors.verticalCenter: parent.verticalCenter + visible: (cand.modelData.qualityDetail || "") !== "" + text: cand.modelData.qualityDetail || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + } Text { anchors.verticalCenter: parent.verticalCenter text: root.releaseMode ? "" : (cand.modelData.duration || "") From 64d75e497a9e22d547d271ed2e9ef9cda484b207 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:16:52 -0600 Subject: [PATCH 14/27] fix(qt): featured artist names in track rows are plain spans, not underlined links StyledText underlines every `` anchor unconditionally and nothing else in the app underlines a link. The names are now colour spans in the same StyledText (elision kept), and the click and hover hit test measures the prefix up to each name with the row's own font, so each name still opens its page or its search on its own. cc --- crates/qbz-qt/qml/rows/TrackRow.qml | 90 ++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index f602df425..4b699b2d0 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -990,13 +990,28 @@ Rectangle { // Main artist first, then the featured performers, one comma // separated list. Local / media-server rows carry no // `featured` and stay as they are. With links enabled and - // featured names present, every name is its own StyledText - // link: the main artist opens its page as before; a featured + // featured names present, every name is its own clickable + // SPAN: the main artist opens its page as before; a featured // name opens its page when the album credits gave it an id - // and the search for that name otherwise. StyledText elides - // like plain text (probed: truncated stays true on one line). + // and the search for that name otherwise. Spans, not `` + // anchors: StyledText underlines every anchor unconditionally + // (qquickstyledtext.cpp, setFontUnderline(true)) and nothing + // else in the app underlines a link, so the hit test is done + // by measuring the prefix up to each name with the same font + // (TextMetrics below). StyledText still elides like plain + // text (probed: truncated stays true on one line). readonly property var featured: root.showFeaturedArtists ? (root.item.featured || []) : [] readonly property bool linkMode: root.artistLink && featured.length > 0 + /// The names as displayed, in order: {name, href} — href "" + /// = the main artist with no page to open. + readonly property var spans: { + var out = [{ "name": String(root.item.artist || ""), "href": root.item.artistId ? "main" : "" }] + for (var i = 0; i < featured.length; i++) + out.push({ "name": String(featured[i].name || ""), "href": "f:" + i }) + return out + } + /// The span under the pointer (-1 = none). + property int hotSpan: -1 function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">") } @@ -1005,51 +1020,84 @@ Rectangle { return ("0" + Math.round(v * 255).toString(16)).slice(-2) }).join("") } - function link(href, name) { - var hot = artistLine.hoveredLink === href - return '' + esc(name) + '' + function span(i) { + var s = artistLine.spans[i] + var hot = artistLine.hotSpan === i && s.href !== "" + return '' + esc(s.name) + '' } textFormat: linkMode ? Text.StyledText : Text.PlainText text: { var names = featured.map(function (f) { return f.name }) if (!linkMode) return (root.item.artist || "") + (names.length ? ", " + names.join(", ") : "") - var parts = [root.item.artistId ? link("main", root.item.artist) : esc(root.item.artist)] - for (var i = 0; i < featured.length; i++) - parts.push(link("f:" + i, featured[i].name)) + var parts = [] + for (var i = 0; i < spans.length; i++) + parts.push(span(i)) return parts.join(", ") } - linkColor: theme.textMuted color: root.artistLink && root.item.artistId && artistLinkArea.containsMouse ? theme.textPrimary : theme.textMuted font.pixelSize: 12 elide: Text.ElideRight - onLinkActivated: function (href) { - if (href === "main") { + /// Which span sits at `x` (-1 = a separator or past the text): + /// the prefix up to each name's end is measured with this + /// Text's own font, so the edges match what is drawn. + function spanAt(x) { + if (!linkMode || x < 0) + return -1 + var prefix = "" + for (var i = 0; i < spans.length; i++) { + spanMetrics.text = prefix + var from = spanMetrics.advanceWidth + prefix += spans[i].name + spanMetrics.text = prefix + if (x >= from && x < spanMetrics.advanceWidth) + return i + prefix += ", " + } + return -1 + } + function activate(i) { + var s = artistLine.spans[i] + if (!s || s.href === "") + return + if (s.href === "main") { if (root.routeGoToExternally) root.goToRequested("artist") else QbzArtist.openArtist(root.item.artistId) return } - var f = featured[parseInt(href.slice(2))] + var f = featured[parseInt(s.href.slice(2))] if (!f) return if (f.id) QbzArtist.openArtist(f.id) else QbzSearch.searchSubmit(f.name) } - HoverHandler { - enabled: artistLine.linkMode - cursorShape: artistLine.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + TextMetrics { + id: spanMetrics + font: artistLine.font } MouseArea { id: artistLinkArea anchors.fill: parent - enabled: root.artistLink && !!root.item.artistId && !artistLine.linkMode + enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId) hoverEnabled: true - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + cursorShape: (artistLine.linkMode + ? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "") + : enabled) ? Qt.PointingHandCursor : Qt.ArrowCursor + onPositionChanged: function (mouse) { + if (artistLine.linkMode) + artistLine.hotSpan = artistLine.spanAt(mouse.x) + } + onExited: artistLine.hotSpan = -1 // Same routing as the menu's "Go to artist" — Slint drives // both through one handler, so gating only the menu would // leave the LINK navigating to the track's own artist. - onClicked: { + onClicked: function (mouse) { + if (artistLine.linkMode) { + var i = artistLine.spanAt(mouse.x) + if (i >= 0) + artistLine.activate(i) + return + } if (root.routeGoToExternally) root.goToRequested("artist") else QbzArtist.openArtist(root.item.artistId) } From ba6d296443780db2d6f2d213937a86dbbbd5b4c0 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:16:52 -0600 Subject: [PATCH 15/27] feat(qt): the header search box grows with the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 960px up the box takes a quarter of the window width, between the 256px it was designed around and a 480px cap — on a 1720px window that is 353px beside the header nav, where it used to be 196px. Small windows keep the fixed width, the nav still takes its 60px when it lives in the header, and the fit rule reads the box's real edge, so the nav goes compact by itself when the wider box leaves it no room. cc --- crates/qbz-qt/qml/shell/HeaderBar.qml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index fd1bf4c71..7f9c43ec5 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -968,10 +968,16 @@ Rectangle { id: searchBox x: (root.width - width) / 2 y: (root.height - height) / 2 - // 80% of the prior search width; gives up 60px to the section nav - // whenever that nav lives in the header (HeaderBar.slint:569). The - // width animates and `x` re-centers with it. - width: (root.width < 960 ? 179 : 256) - (QbzShell.navInSidebar ? 0 : 60) + // Grows with the window from 960px up (a quarter of it), between the + // 256px the layout was designed around and a 480px cap: wide enough + // to read a query on a 1720px window, never half the screen. Small + // windows keep the fixed 179px. The section nav still takes its 60px + // whenever it lives in the header (HeaderBar.slint:569); the fit rule + // reads this box's real edge, so the nav goes compact by itself when + // the wider box leaves it no room. The width animates and `x` + // re-centers with it. + width: (root.width < 960 ? 179 : Math.round(Math.max(256, Math.min(root.width * 0.24, 480)))) + - (QbzShell.navInSidebar ? 0 : 60) height: 32 Behavior on width { NumberAnimation { duration: 220; easing.type: Easing.InOutQuad } From 71678662868b09d43c7265733f9f4b675a5e1e74 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:20:05 -0600 Subject: [PATCH 16/27] fix(qt): accent buttons no longer turn black under the pointer QbzPrimaryButton animated its fill with a NumberAnimation; a colour interpolated as a number lands on black, so every Add / Save / Create button in the modals went dark on hover and its label vanished with it. A ColorAnimation interpolates the two accents. cc --- crates/qbz-qt/qml/controls/QbzPrimaryButton.qml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml index 20b53ec69..9c676d5fc 100644 --- a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml +++ b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml @@ -83,7 +83,10 @@ Rectangle { ? Qt.darker(theme.danger, 1.15) : theme.danger) : ((btnArea.containsMouse && root.btnEnabled) ? theme.accentHover : theme.accent) - Behavior on color { NumberAnimation { duration: 150 } } + // ColorAnimation, NOT NumberAnimation: a NumberAnimation on a colour + // interpolates it as a number and lands on BLACK, which is how every + // accent button in the modals went dark under the pointer (2026-09-14). + Behavior on color { ColorAnimation { duration: 150 } } Text { id: lbl From 47b5c14d32e8f129ee713bdd063f96b453632774 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:54:57 -0600 Subject: [PATCH 17/27] feat(qt): two Wallpaper background modes, with the desktop's picture or your own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Dynamic background" gains "Wallpaper" and "Wallpaper, blurred". The picture is the desktop's wallpaper — Plasma's appletsrc (the containment on screen 0, image plugin only; a wallpaper package resolves to its largest bitmap, a slideshow folder to its first image), GNOME through gsettings (the dark variant under a dark scheme), Windows' current TranscodedWallpaper, macOS through System Events — or an image the user picks (Settings > Appearance > Background image), which outranks it. The resolved path and its mtime are memoised, so the frequent refreshes (session entry, a mode switch, the window coming to the front) republish nothing while the file holds. "Wallpaper, blurred" sends the picture through the same atmosphere pass Blurred art gives the cover, held still. "Wallpaper" shows the part of the wallpaper that lies behind the window, lightly blurred under the half-alpha chrome, at most a screen-sized texture: the window's place on the screen comes from Qt where the platform tells it (X11, macOS, Windows); Wayland hides window positions, so there the crop is centred until the Plasma window-management protocol is wired. Neither mode waits for a playing track. Six new strings in eight catalogs. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 24 + .../translations/en/LC_MESSAGES/qbz-ui.po | 24 + .../translations/es/LC_MESSAGES/qbz-ui.po | 24 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 24 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 24 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 24 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 24 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 24 + crates/qbz-qt/build.rs | 1 + .../qml/settings/AppearanceSettings.qml | 21 + crates/qbz-qt/qml/shell/AppShell.qml | 49 +++ crates/qbz-qt/qml/shell/WallpaperField.qml | 73 +++ crates/qbz-qt/qml/theme/QbzTheme.qml | 5 +- crates/qbz-qt/src/bridge.rs | 12 + crates/qbz-qt/src/main.rs | 3 + crates/qbz-qt/src/settings_qt.rs | 40 +- crates/qbz-qt/src/shell_bridge.rs | 16 + crates/qbz-qt/src/wallpaper_qt.rs | 416 ++++++++++++++++++ 18 files changed, 825 insertions(+), 3 deletions(-) create mode 100644 crates/qbz-qt/qml/shell/WallpaperField.qml create mode 100644 crates/qbz-qt/src/wallpaper_qt.rs diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 32f2efadf..0d6ffcf84 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7695,3 +7695,27 @@ msgstr "Suche nach anderen Versionen…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Diese Veröffentlichung" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Hintergrundbild" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Hintergrundbild, unscharf" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Hintergrundmotiv" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "Das Hintergrundbild des Systems. Alternativ ein eigenes Bild wählen." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Bild wählen…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "System-Hintergrundbild" diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 3d21c3910..28bb38d97 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3350,3 +3350,27 @@ msgstr "Looking for other versions…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "This release" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Wallpaper" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Wallpaper, blurred" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Background image" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "The system wallpaper. Choose an image of your own instead." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Choose image…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "System wallpaper" diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 5179bf451..41a5faa7b 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7701,3 +7701,27 @@ msgstr "Buscando otras versiones…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Este lanzamiento" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Fondo de pantalla" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Fondo de pantalla difuminado" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Imagen de fondo" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "El fondo de pantalla del sistema. También puedes elegir una imagen propia." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Elegir imagen…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Fondo del sistema" diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index beafc9386..373e941ab 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7693,3 +7693,27 @@ msgstr "Recherche d'autres versions…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Cette sortie" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Fond d'écran" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Fond d'écran flouté" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Image de fond" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "Le fond d'écran du système. Vous pouvez aussi choisir votre propre image." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Choisir une image…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Fond d'écran du système" diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 01603bf64..8f3db3988 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7676,3 +7676,27 @@ msgstr "他のバージョンを探しています…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "このリリース" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "壁紙" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "ぼかし壁紙" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "背景画像" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "システムの壁紙。代わりに自分の画像を選ぶこともできます。" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "画像を選択…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "システムの壁紙" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 9a53d4814..ed59317fc 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7683,3 +7683,27 @@ msgstr "Andere versies zoeken…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Deze release" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Bureaubladachtergrond" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Vervaagde bureaubladachtergrond" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Achtergrondafbeelding" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "De bureaubladachtergrond van het systeem. Je kunt ook een eigen afbeelding kiezen." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Afbeelding kiezen…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Systeemachtergrond" diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index e64cdb11f..b4b3dbd79 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7693,3 +7693,27 @@ msgstr "A procurar outras versões…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Este lançamento" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Papel de parede" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Papel de parede desfocado" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Imagem de fundo" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "O papel de parede do sistema. Também pode escolher uma imagem sua." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Escolher imagem…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Papel de parede do sistema" diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index aea8cc68e..b72c4540c 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7714,3 +7714,27 @@ msgstr "Ищем другие версии…" #: crates/qbz-qt/qml/views/AlbumView.qml msgid "This release" msgstr "Этот релиз" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper" +msgstr "Обои" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper, blurred" +msgstr "Размытые обои" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Background image" +msgstr "Фоновое изображение" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "The system wallpaper. Choose an image of your own instead." +msgstr "Обои системы. Можно выбрать собственное изображение." + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Choose image…" +msgstr "Выбрать изображение…" + +#: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "System wallpaper" +msgstr "Обои системы" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 53dd03f98..31e86e5aa 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -656,6 +656,7 @@ fn main() { "qml/settings/OrbitSettings.qml", "qml/settings/OrbitSettingsPanel.qml", "qml/shell/AmbientField.qml", + "qml/shell/WallpaperField.qml", "qml/shell/AppShell.qml", "qml/shell/OrbitBanner.qml", "qml/shell/ArtPreviewOverlay.qml", diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index dd21a2f6b..90869ca31 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -148,6 +148,27 @@ Column { onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) } } } + // The picture behind the two Wallpaper modes: the desktop's, or one of + // the user's own (wallpaper_qt.rs). + SettingRow { kioskHost: root.kioskHost; + visible: (root.doc.appBackgroundIndex || 0) >= 3 + label: QbzSession.tr("Background image", QbzSession.trRev) + description: (root.doc.appBackgroundImage || "") !== "" + ? root.doc.appBackgroundImage + : QbzSession.tr("The system wallpaper. Choose an image of your own instead.", QbzSession.trRev) + Row { + spacing: 8 + SettingsButton { kioskHost: root.kioskHost; + text: QbzSession.tr("Choose image…", QbzSession.trRev) + onClicked: QbzBridge.settingsPickBackgroundImage() + } + SettingsButton { kioskHost: root.kioskHost; + visible: (root.doc.appBackgroundImage || "") !== "" + text: QbzSession.tr("System wallpaper", QbzSession.trRev) + onClicked: QbzBridge.settingsClearBackgroundImage() + } + } + } // Auto-theme rows (the "auto" theme only). SettingRow { kioskHost: root.kioskHost; visible: QbzShell.themeSlug === "auto" diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index e2e5a84e5..9d0659f45 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -121,6 +121,55 @@ Rectangle { opacity: root.veilStrength } + // Mode 3 — Wallpaper: the desktop wallpaper, the part of it that lies + // BEHIND this window (where the platform tells the window its place: + // X11, macOS, Windows — Wayland hides it, so a centred crop stands in), + // lightly blurred under the half-alpha chrome. Static: it costs a frame + // only when the window moves or resizes (shell/WallpaperField.qml). + // Mode 4 — Wallpaper, blurred: the wallpaper through the SAME atmosphere + // pass Blurred art gives the cover, held still (no transport to follow). + // Neither waits for a playing track (QbzTheme.ambientOn). + readonly property bool wallpaperModeOn: root.ambientOn && QbzShell.ambientMode === 3 + readonly property bool wallpaperBlurOn: root.ambientOn && QbzShell.ambientMode === 4 + WallpaperField { + anchors.fill: parent + visible: root.wallpaperModeOn + source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : "" + hostWindow: root.hostWindow + blur: 0.35 + dim: theme.isDark ? QbzShell.ambientDim : 0.0 + } + Rectangle { + anchors.fill: parent + visible: root.wallpaperModeOn && !theme.isDark + color: theme.surfaceMain + opacity: root.veilStrength + } + ImmersiveAtmosphere { + anchors.fill: parent + visible: root.wallpaperBlurOn + source: root.wallpaperBlurOn ? QbzShell.wallpaperAtmosphereUrl : "" + animated: false + dim: theme.isDark ? QbzShell.ambientDim : 0.0 + } + Rectangle { + anchors.fill: parent + visible: root.wallpaperBlurOn && !theme.isDark + color: theme.surfaceMain + opacity: root.veilStrength + } + // The wallpaper can change while QBZ runs: re-resolve when the window + // comes back to the front (wallpaper_qt memoises path + mtime, so a + // wallpaper that did not change republishes nothing). + Connections { + target: root.hostWindow + ignoreUnknownSignals: true + function onActiveChanged() { + if (root.hostWindow && root.hostWindow.active && QbzShell.ambientMode >= 3) + QbzShell.refreshWallpaper() + } + } + // The host ApplicationWindow (custom chrome: drag / maximize / resize). property var hostWindow: null diff --git a/crates/qbz-qt/qml/shell/WallpaperField.qml b/crates/qbz-qt/qml/shell/WallpaperField.qml new file mode 100644 index 000000000..272788ad0 --- /dev/null +++ b/crates/qbz-qt/qml/shell/WallpaperField.qml @@ -0,0 +1,73 @@ +// WallpaperField — the desktop wallpaper behind the window (2026-09-14): the +// part of it that lies UNDER this window, so moving the window slides the +// picture the way a translucent window would, lightly blurred beneath the +// half-alpha chrome. Nothing animates: it costs a frame only when the window +// moves or resizes, and a screen-sized texture at most (`sourceSize` caps the +// decode — a 3x-upscaled wallpaper never reaches the GPU whole). +// +// WHERE THE WINDOW IS. X11, macOS and Windows report the window's position; +// Wayland hides it from clients by design, so there the crop is centred on +// the screen until the Plasma window-management protocol is wired +// (wallpaper_qt.rs header). `hostWindow` is the ApplicationWindow. + +import QtQuick +import QtQuick.Window +import QtQuick.Effects + +Item { + id: root + + /// file:// URL of the wallpaper (QbzShell.wallpaperUrl). + property string source: "" + property var hostWindow: null + /// MultiEffect blur amount, 0 = crisp. + property real blur: 0.0 + /// Darkening on top (the dark-theme legibility veil). + property real dim: 0.0 + + clip: true + + readonly property bool positionKnown: root.hostWindow !== null + && Qt.platform.pluginName !== "wayland" + readonly property real screenW: Math.max(1, Screen.width) + readonly property real screenH: Math.max(1, Screen.height) + // The window's origin inside its screen; centred when unknown. + readonly property real offX: root.positionKnown + ? root.hostWindow.x - Screen.virtualX + : Math.max(0, (root.screenW - root.width) / 2) + readonly property real offY: root.positionKnown + ? root.hostWindow.y - Screen.virtualY + : Math.max(0, (root.screenH - root.height) / 2) + + Image { + id: picture + source: root.source + x: -root.offX + y: -root.offY + width: root.screenW + height: root.screenH + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: false + sourceSize.width: Math.round(root.screenW * Screen.devicePixelRatio) + sourceSize.height: Math.round(root.screenH * Screen.devicePixelRatio) + smooth: true + } + MultiEffect { + visible: root.blur > 0 && picture.status === Image.Ready + source: picture + x: picture.x + y: picture.y + width: picture.width + height: picture.height + autoPaddingEnabled: false + blurEnabled: true + blur: root.blur + blurMax: 48 + } + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: root.dim + } +} diff --git a/crates/qbz-qt/qml/theme/QbzTheme.qml b/crates/qbz-qt/qml/theme/QbzTheme.qml index 8f6fbe22f..27ddb1378 100644 --- a/crates/qbz-qt/qml/theme/QbzTheme.qml +++ b/crates/qbz-qt/qml/theme/QbzTheme.qml @@ -161,7 +161,10 @@ QtObject { // renderer-tier arm: the Qt field renders on the software path too // (ShaderEffect where shaders exist, Canvas where they do not), so the // feature is never taken away from a weak GPU here. - readonly property bool ambientOn: QbzShell.ambientMode > 0 && QbzPlayer.npHasTrack + // The wallpaper modes (3, 4) do not depend on a playing track: the + // picture is always there. + readonly property bool ambientOn: QbzShell.ambientMode > 0 + && (QbzShell.ambientMode >= 3 || QbzPlayer.npHasTrack) // --- ON AN ACCENT FILL: the glyph tint and its colour twin ----------- // diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index ebaefafd0..6e5903bc8 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -84,6 +84,10 @@ pub mod qbz_bridge { /// Select rows: the picked OPTION INDEX within the row's list. #[qinvokable] fn settings_select(self: Pin<&mut QbzBridge>, key: QString, index: i32); + #[qinvokable] + fn settings_pick_background_image(self: Pin<&mut QbzBridge>); + #[qinvokable] + fn settings_clear_background_image(self: Pin<&mut QbzBridge>); /// Slider rows (initial buffer size). #[qinvokable] fn settings_slider(self: Pin<&mut QbzBridge>, key: QString, value: i32); @@ -281,6 +285,14 @@ impl qbz_bridge::QbzBridge { crate::settings_bool(key.to_string(), value); } + pub fn settings_pick_background_image(self: Pin<&mut Self>) { + crate::settings_qt::pick_background_image(); + } + + pub fn settings_clear_background_image(self: Pin<&mut Self>) { + crate::settings_qt::clear_background_image(); + } + pub fn settings_select(self: Pin<&mut Self>, key: QString, index: i32) { crate::settings_select(key.to_string(), index); } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index b14b6614a..2bd778fb8 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -291,6 +291,7 @@ mod label_qt; mod local_media_info_qt; mod nav_qt; mod page_restore_qt; +mod wallpaper_qt; mod now_playing; mod offline_fwd; mod output_labels; @@ -837,6 +838,8 @@ fn on_session_entered() { }; nav_qt::record(&entry_view); hydrate_view(&entry_view); + // The desktop wallpaper, when a background mode paints it (a no-op otherwise). + wallpaper_qt::refresh(); // Logged-off startup is the ONLY automatic redirect. An authenticated // account keeps the established startup/restore flow even when physical // connectivity is down. The selected tab is the first user-ordered Local diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 484c173b4..0c8058454 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -778,6 +778,34 @@ pub fn ambient_bar_alpha() -> f32 { /// Same one-document rule as `toggle_system_title_bar`: the current key is /// read inside the write closure, so a torn read can no longer make the app /// commit "ambient" over a user who had just turned it off (or the reverse). +/// Settings > Appearance > Background image > "Choose image…": the user's +/// own picture behind the wallpaper modes, outranking the desktop's. +pub fn pick_background_image() { + crate::spawn(async { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title(qbz_i18n::t("Background image")) + .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp", "avif"]) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().to_string(); + save_pref("app_background_image", serde_json::json!(path)); + publish_snapshot().await; + crate::wallpaper_qt::refresh(); + }); +} + +/// "System wallpaper": drop the user's own picture. +pub fn clear_background_image() { + save_pref("app_background_image", serde_json::json!("")); + crate::spawn(async { + publish_snapshot().await; + crate::wallpaper_qt::refresh(); + }); +} + pub fn toggle_ambient_background() -> i32 { edit_prefs(|doc| { let current = doc @@ -1643,8 +1671,10 @@ const QCONNECT_CONFLICT_POLICY_LABELS: &[&str] = &[ "Continue local playback and replace the Qobuz Connect queue", ]; // Appearance option tables (AppearanceSettings.slint / ui_prefs.rs). -const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"]; -const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"]; +// 3 and 4 paint the desktop wallpaper (wallpaper_qt.rs): behind the window, +// or through the Blurred-art atmosphere pass. +const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art", "Wallpaper", "Wallpaper, blurred"]; +const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred", "wallpaper", "wallpaper-blurred"]; const LANGUAGE_LABELS: &[&str] = &[ "Auto", "English", @@ -2155,6 +2185,9 @@ pub struct SettingsDoc { pub app_background_modes: Vec, #[serde(rename = "appBackgroundIndex")] pub app_background_index: i32, + /// The user's own background image ("" = the desktop wallpaper). + #[serde(rename = "appBackgroundImage")] + pub app_background_image: String, #[serde(rename = "autoThemeSources")] pub auto_theme_sources: Vec, #[serde(rename = "autoThemeSourceIndex")] @@ -2714,6 +2747,7 @@ pub async fn publish_snapshot() { .iter() .position(|v| *v == pref_str("app_background", "off")) .unwrap_or(0) as i32, + app_background_image: pref_str("app_background_image", ""), auto_theme_sources: AUTO_THEME_SOURCE_LABELS .iter() .map(|l| qbz_i18n::t(l)) @@ -4225,6 +4259,8 @@ pub async fn settings_select(runtime: &Arc>, key: &st // ImmersiveAtmosphere for 2, exactly like AppShell.slint:213-231). let ambient = index as i32; crate::shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(ambient)); + // The wallpaper modes need their image (a no-op for the others). + crate::wallpaper_qt::refresh(); } "language" => { let Some(lang) = LANGUAGE_VALUES.get(index) else { diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index da00fb2bc..760aab970 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -296,6 +296,11 @@ pub mod qbz_shell { /// May the app-wide dynamic background be offered? /// Same tier, same reason (`main.rs:8563`); gates the whole picker row. #[qproperty(bool, app_background_available)] + /// The desktop wallpaper (or the user's own image) as a file:// URL, + /// and its atmosphere bitmap — the "wallpaper" background modes + /// (wallpaper_qt.rs). Empty until a mode that paints it resolves one. + #[qproperty(QString, wallpaper_url)] + #[qproperty(QString, wallpaper_atmosphere_url)] /// Is the kiosk touch shell the one currently mounted? /// /// The Qt counterpart of `ShellState.kiosk-profile` @@ -533,6 +538,9 @@ pub mod qbz_shell { /// Header history buttons. #[qinvokable] fn navigate_back(self: Pin<&mut QbzShell>); + /// Re-resolve the desktop wallpaper (window activation, a mode switch). + #[qinvokable] + fn refresh_wallpaper(self: Pin<&mut QbzShell>); #[qinvokable] fn navigate_forward(self: Pin<&mut QbzShell>); @@ -896,6 +904,8 @@ pub struct QbzShellRust { gpu_tier: bool, shader_scenes_available: bool, app_background_available: bool, + wallpaper_url: QString, + wallpaper_atmosphere_url: QString, kiosk_profile: bool, kiosk_fullscreen_boot: bool, ambient_mode: i32, @@ -1011,6 +1021,8 @@ impl Default for QbzShellRust { gpu_tier: crate::renderer_qt::gpu_tier(), shader_scenes_available: crate::renderer_qt::gpu_tier(), app_background_available: crate::renderer_qt::gpu_tier(), + wallpaper_url: QString::default(), + wallpaper_atmosphere_url: QString::default(), kiosk_profile: crate::kiosk_profile_qt::active(), kiosk_fullscreen_boot: crate::kiosk_profile_qt::active() && crate::kiosk_profile_qt::fullscreen_at_boot(), @@ -1433,6 +1445,10 @@ impl qbz_shell::QbzShell { crate::nav_qt::back(); } + pub fn refresh_wallpaper(self: Pin<&mut Self>) { + crate::wallpaper_qt::refresh(); + } + pub fn navigate_forward(self: Pin<&mut Self>) { crate::nav_qt::forward(); } diff --git a/crates/qbz-qt/src/wallpaper_qt.rs b/crates/qbz-qt/src/wallpaper_qt.rs new file mode 100644 index 000000000..b891b24ac --- /dev/null +++ b/crates/qbz-qt/src/wallpaper_qt.rs @@ -0,0 +1,416 @@ +//! The desktop wallpaper as the app-wide background (2026-09-14). +//! +//! Two `app_background` modes ride on it: "wallpaper" (3) shows the part of +//! the wallpaper that lies BEHIND the window, lightly blurred under the +//! translucent chrome (shell/WallpaperField.qml — the window's place on the +//! screen comes from Qt where the platform tells it: X11, macOS, Windows; +//! Wayland hides window positions, so a centred crop stands in until the +//! Plasma window-management protocol is wired); "wallpaper-blurred" (4) +//! passes the wallpaper through the same atmosphere pass Blurred art gives +//! the cover (atmosphere_qt::for_cover_blocking), so it reads like the album +//! blur with the desktop's colours. +//! +//! The image is the system wallpaper, resolved per desktop — KDE Plasma's +//! `plasma-org.kde.plasma.desktop-appletsrc` (the containment on screen 0 +//! wins; a wallpaper PACKAGE resolves to its largest `contents/images/*` +//! bitmap, a slideshow folder to its first image), GNOME through `gsettings` +//! (the dark variant under a dark colour scheme), Windows' current +//! `TranscodedWallpaper`, macOS through System Events — or an image the user +//! chose (`app_background_image`), which outranks the desktop. `refresh()` +//! is cheap to call often: the resolved path and its mtime are memoised, and +//! nothing is republished while they hold. + +use cxx_qt_lib::QString; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +pub const MODE_WALLPAPER: i32 = 3; + +static LAST: Mutex)>> = Mutex::new(None); + +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp", "avif", "jxl"]; + +/// Does this `app_background` mode paint the wallpaper? +pub fn mode_uses_wallpaper(mode: i32) -> bool { + mode >= MODE_WALLPAPER +} + +/// Resolve the wallpaper (the user's own image first) and publish its URL and +/// its atmosphere bitmap on the shell bridge. No-op while the mode does not +/// paint it, and quiet while the same file (same mtime) is already published. +pub fn refresh() { + if !mode_uses_wallpaper(crate::settings_qt::app_background_mode()) { + return; + } + crate::spawn(async { + let resolved = tokio::task::spawn_blocking(|| { + let custom = crate::settings_qt::pref_str("app_background_image", ""); + let path = if custom.trim().is_empty() { + resolve_system() + } else { + Some(PathBuf::from(custom.trim())) + }; + let path = path.filter(|p| p.is_file())?; + let mtime = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()); + { + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if last + .as_ref() + .is_some_and(|(p, t)| *p == path && *t == mtime) + { + return None; + } + *last = Some((path.clone(), mtime)); + } + let atmosphere = crate::atmosphere_qt::for_cover_blocking(&path.to_string_lossy()) + .unwrap_or_default(); + Some((file_url(&path), atmosphere)) + }) + .await + .ok() + .flatten(); + if let Some((url, atmosphere)) = resolved { + log::info!("[qbz-qt] wallpaper: {url}"); + crate::shell_bridge::ui(move |mut b| { + b.as_mut().set_wallpaper_url(QString::from(url.as_str())); + b.as_mut() + .set_wallpaper_atmosphere_url(QString::from(atmosphere.as_str())); + }); + } + }); +} + +/// The desktop's current wallpaper, if this desktop tells us. +pub fn resolve_system() -> Option { + #[cfg(target_os = "windows")] + return windows_wallpaper(); + #[cfg(target_os = "macos")] + return macos_wallpaper(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + return kde_wallpaper().or_else(gnome_wallpaper); +} + +fn file_url(path: &Path) -> String { + let text = path.to_string_lossy(); + if text.starts_with('/') { + format!("file://{text}") + } else { + // Windows: `C:\a\b.png` -> `file:///C:/a/b.png`. + format!("file:///{}", text.replace('\\', "/")) + } +} + +/// `%20` and friends back to bytes (the desktops store wallpaper paths as +/// URLs). Anything malformed passes through untouched. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = &s[i + 1..i + 3]; + if let Ok(v) = u8::from_str_radix(hex, 16) { + out.push(v); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A wallpaper setting's value (`file:///…` or a bare path) to a path. +fn path_from_setting(value: &str) -> PathBuf { + let v = value.trim().trim_matches('\'').trim_matches('"'); + let v = v.strip_prefix("file://").unwrap_or(v); + PathBuf::from(percent_decode(v)) +} + +fn is_image(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) +} + +/// A file stays a file; a directory is either a KDE wallpaper PACKAGE +/// (`contents/images/.`, the largest wins) or a slideshow folder +/// (its first image, sorted). +fn resolve_image_source(path: PathBuf) -> Option { + if path.is_file() { + return Some(path); + } + if !path.is_dir() { + return None; + } + let package = path.join("contents").join("images"); + let dir = if package.is_dir() { package } else { path }; + let mut files: Vec = std::fs::read_dir(&dir) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && is_image(p)) + .collect(); + if files.is_empty() { + return None; + } + files.sort(); + let pixels = |p: &Path| -> u64 { + p.file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| { + let (w, h) = s.split_once('x')?; + Some(w.parse::().ok()? * h.parse::().ok()?) + }) + .unwrap_or(0) + }; + // The FIRST of equals wins (a slideshow folder has no sizes in its names), + // which `max_by_key` would not give: it returns the last maximum. + let mut best = files[0].clone(); + let mut best_pixels = pixels(&best); + for file in &files[1..] { + let px = pixels(file); + if px > best_pixels { + best = file.clone(); + best_pixels = px; + } + } + Some(best) +} + +fn section_ids(section: &str) -> Vec<&str> { + section + .trim_matches(|c| c == '[' || c == ']') + .split("][") + .collect() +} + +/// The image Plasma paints on screen 0 (else the lowest screen, else any): +/// `[Containments][N][Wallpaper][org.kde.image][General] Image=`, for the +/// containments whose wallpaper plugin is the image one. +fn kde_image_from_config(text: &str) -> Option { + let mut section = String::new(); + let mut screen_of: HashMap = HashMap::new(); + let mut plugin_of: HashMap = HashMap::new(); + let mut image_of: HashMap = HashMap::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.starts_with('[') { + section = line.to_string(); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let ids = section_ids(§ion); + if ids.len() == 2 && ids[0] == "Containments" { + match key { + "lastScreen" => { + if let Ok(n) = value.trim().parse::() { + screen_of.insert(ids[1].to_string(), n); + } + } + "wallpaperplugin" => { + plugin_of.insert(ids[1].to_string(), value.trim().to_string()); + } + _ => {} + } + } else if ids.len() == 5 + && ids[0] == "Containments" + && ids[2] == "Wallpaper" + && ids[3] == "org.kde.image" + && ids[4] == "General" + && key == "Image" + && !value.trim().is_empty() + { + image_of.insert(ids[1].to_string(), value.trim().to_string()); + } + } + let mut best: Option<(i32, String)> = None; + for (id, image) in image_of { + if plugin_of.get(&id).is_some_and(|p| p != "org.kde.image") { + continue; + } + let screen = screen_of.get(&id).copied().unwrap_or(i32::MAX); + let rank = if screen < 0 { i32::MAX } else { screen }; + if best.as_ref().is_none_or(|(r, _)| rank < *r) { + best = Some((rank, image)); + } + } + best.map(|(_, image)| image) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn kde_wallpaper() -> Option { + let config = dirs::config_dir()?.join("plasma-org.kde.plasma.desktop-appletsrc"); + let text = std::fs::read_to_string(config).ok()?; + let value = kde_image_from_config(&text)?; + resolve_image_source(path_from_setting(&value)) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn gsettings(schema: &str, key: &str) -> Option { + let out = std::process::Command::new("gsettings") + .args(["get", schema, key]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn gnome_wallpaper() -> Option { + let dark = gsettings("org.gnome.desktop.interface", "color-scheme") + .is_some_and(|v| v.contains("prefer-dark")); + let key = if dark { + "picture-uri-dark" + } else { + "picture-uri" + }; + let value = gsettings("org.gnome.desktop.background", key) + .filter(|v| !v.trim_matches('\'').is_empty()) + .or_else(|| gsettings("org.gnome.desktop.background", "picture-uri"))?; + resolve_image_source(path_from_setting(&value)) +} + +#[cfg(target_os = "windows")] +fn windows_wallpaper() -> Option { + let appdata = std::env::var_os("APPDATA")?; + let transcoded = PathBuf::from(appdata) + .join("Microsoft") + .join("Windows") + .join("Themes") + .join("TranscodedWallpaper"); + if transcoded.is_file() { + return Some(transcoded); + } + let out = std::process::Command::new("reg") + .args(["query", r"HKCU\Control Panel\Desktop", "/v", "WallPaper"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let line = text.lines().find(|l| l.contains("WallPaper"))?; + let value = line.split("REG_SZ").nth(1)?.trim(); + resolve_image_source(PathBuf::from(value)) +} + +#[cfg(target_os = "macos")] +fn macos_wallpaper() -> Option { + let out = std::process::Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to get picture of current desktop", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if value.is_empty() { + return None; + } + resolve_image_source(PathBuf::from(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const APPLETSRC: &str = r#" +[Containments][1] +activityId=abc +lastScreen=1 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image + +[Containments][1][Wallpaper][org.kde.image][General] +Image=file:///home/me/Pictures/Second%20Screen.png + +[Containments][2] +activityId=abc +lastScreen=0 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image + +[Containments][2][Wallpaper][org.kde.color][General] +Color=0,0,0 + +[Containments][2][Wallpaper][org.kde.image][General] +Image=file:///usr/share/wallpapers/MilkyWay/ + +[Containments][3] +lastScreen=0 +wallpaperplugin=org.kde.color + +[Containments][3][Wallpaper][org.kde.image][General] +Image=file:///home/me/stale.png +"#; + + #[test] + fn plasma_picks_the_image_containment_on_screen_zero() { + assert_eq!( + kde_image_from_config(APPLETSRC).as_deref(), + Some("file:///usr/share/wallpapers/MilkyWay/") + ); + // Without screen 0 the lowest screen wins; a colour containment never does. + let only_second = APPLETSRC.replace("lastScreen=0", "lastScreen=5"); + assert_eq!( + kde_image_from_config(&only_second).as_deref(), + Some("file:///home/me/Pictures/Second%20Screen.png") + ); + assert!(kde_image_from_config("[Containments][9]\nlastScreen=0\n").is_none()); + } + + #[test] + fn settings_values_become_decoded_paths() { + assert_eq!( + path_from_setting("file:///home/me/Pictures/Second%20Screen.png"), + PathBuf::from("/home/me/Pictures/Second Screen.png") + ); + assert_eq!( + path_from_setting("'file:///usr/share/backgrounds/a.jpg'"), + PathBuf::from("/usr/share/backgrounds/a.jpg") + ); + assert_eq!( + path_from_setting("/plain/path.png"), + PathBuf::from("/plain/path.png") + ); + assert_eq!(percent_decode("100%"), "100%"); + } + + #[test] + fn a_package_resolves_to_its_largest_bitmap_and_a_folder_to_its_first_image() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("MilkyWay"); + let images = package.join("contents").join("images"); + std::fs::create_dir_all(&images).unwrap(); + for name in ["1080x1920.png", "5120x2880.png", "notes.txt"] { + std::fs::write(images.join(name), b"x").unwrap(); + } + assert_eq!( + resolve_image_source(package.clone()), + Some(images.join("5120x2880.png")) + ); + let slides = temp.path().join("slides"); + std::fs::create_dir_all(&slides).unwrap(); + for name in ["b.jpg", "a.png", "readme.md"] { + std::fs::write(slides.join(name), b"x").unwrap(); + } + assert_eq!( + resolve_image_source(slides.clone()), + Some(slides.join("a.png")) + ); + let file = temp.path().join("one.webp"); + std::fs::write(&file, b"x").unwrap(); + assert_eq!(resolve_image_source(file.clone()), Some(file)); + assert!(resolve_image_source(temp.path().join("missing")).is_none()); + } +} From 1c45c7f2b9d56b82d34158f6811c2e511e059faa Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:54:57 -0600 Subject: [PATCH 18/27] fix(qt): saving the queue as a playlist works with local rows, and offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queue_qt::save_as_playlist skipped every local and ephemeral row and, with no catalog ids left, returned without a word — a queue of Local Library tracks opened nothing from either the queue panel or the Listen list, online or offline. The module's own note said the picker's local mode existed and only the row-to-ref resolver was missing. local_playlist_qt::local_picker_ref_for_queue_track now builds that ref from a QueueTrack by source (a file its row id, Plex its rating key, Jellyfin/Subsonic the server item id, an offline copy its row id from the hint slot; an ephemeral row nothing), and the queue carries ids and refs together: only local rows open the picker in local mode, a mix carries the new Payload::Mixed, which the picker routes as the two halves would be on their own — both writes onto a local playlist, the sidecar plus the API (duplicate check included) onto a Qobuz one, a local playlist on create while offline. The per-row "Add to playlist" entry shows for local rows on both queue surfaces too. cc --- crates/qbz-qt/qml/shell/QueuePanel.qml | 17 +--- crates/qbz-qt/qml/views/QueueView.qml | 7 +- crates/qbz-qt/src/local_playlist_qt.rs | 38 ++++--- crates/qbz-qt/src/playlist_picker_qt.rs | 95 ++++++++++++++++- crates/qbz-qt/src/queue_qt.rs | 129 +++++++++++++++++++----- 5 files changed, 218 insertions(+), 68 deletions(-) diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index aaf431c78..08fb4ff12 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -596,18 +596,11 @@ Rectangle { if (root.rowCanGoArtist(row)) items.push({ "label": QbzSession.tr("Go to artist", QbzSession.trRev), "icon": "user", "action": "go-artist" }) if (row.isEphemeral !== true) { - // LOCAL/Plex rows drop it too: the queue's - // `add_to_playlist` refuses them - // (queue_qt.rs — a queue row's id is a - // library.db / Plex synthetic id and this - // panel has no source-aware resolver to - // turn it into a local-mode ref), so the - // entry would render and no-op. Absent - // beats dead; the LOCAL surfaces that DO - // have the resolver offer it instead - // (local/LocalTrackRow.qml). - if (row.isLocal !== true) - items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" }) + // Local/Plex/media rows too (2026-09-14): + // the queue resolves them to picker refs + // by source (local_playlist_qt:: + // local_picker_ref_for_queue_track). + items.push({ "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-plus", "action": "add-to-playlist" }) items.push({ "label": QbzSession.tr("Track info", QbzSession.trRev), "icon": "info", "action": "track-info" }) items.push({ "label": root.favState(row) ? QbzSession.tr("Remove from Library", QbzSession.trRev) : QbzSession.tr("Add to Library", QbzSession.trRev), "icon": root.favState(row) ? "heart-filled" : "heart", "action": "favorite" }) } diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index 03d5d59cb..f45e674a4 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -319,9 +319,10 @@ Rectangle { items.push({ "label": t("Go to artist", r), "icon": "user", "action": "go-artist", "external": true }) if (row.isEphemeral !== true) { - if (row.isLocal !== true) - items.push({ "label": t("Add to playlist", r), "icon": "list-plus", - "action": "add-to-playlist", "external": true }) + // Local rows too (2026-09-14): the queue resolves them to picker + // refs by source (queue_qt::picker_payload_for). + items.push({ "label": t("Add to playlist", r), "icon": "list-plus", + "action": "add-to-playlist", "external": true }) items.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) if (row.isLocal !== true) items.push({ "label": row.isFavorite === true diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs index fb6fdc81c..0ed121c29 100644 --- a/crates/qbz-qt/src/local_playlist_qt.rs +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -711,30 +711,30 @@ pub fn set_open_mixed_snapshot( pub fn local_picker_ref_for_row(id: &str) -> Option { let queue = CURRENT_QUEUE.lock().ok()?; let q = queue.iter().find(|q| q.id.to_string() == id)?; + local_picker_ref_for_queue_track(q) +} + +/// The picker ref for a QUEUE row, source-aware like +/// `local_picker_ref_for_track`: Plex and the media servers ride the key +/// their row keeps in the hint slot (the rating key / the server item id — +/// `local_playback::local_queue_track`, `media_servers_qt::cached_to_local_track`), +/// an offline copy its library row id (ALSO in the hint: its queue id is the +/// catalog id when the copy has one, which is what `local_row_input` needs to +/// re-derive the right input kind), a local file its row id. `None` = a +/// local row this port cannot file (a media row that lost its key). The +/// queue's "save as playlist" and per-row "Add to playlist" build their local +/// refs here — never by hand (the module header's rule). +pub fn local_picker_ref_for_queue_track(q: &QueueTrack) -> Option { match q.source.as_deref() { Some("plex") => q .source_item_id_hint .as_ref() .map(|key| format!("plex:{key}")), - // Remote rows carry the server item id in the hint slot, same as - // Plex carries the rating key (media_servers_qt::cached_to_local_track). Some(source @ ("jellyfin" | "subsonic")) => q .source_item_id_hint .as_ref() .map(|item| format!("{source}:{item}")), Some("local") => Some(q.id.to_string()), - // An OFFLINE COPY row (`local_playback::local_queue_track` tags these - // "qobuz_download"). It reaches this function because `row_to_display` - // publishes every non-Plex `RowItem::Local` with `source: "local"`, so - // TrackRow.qml offers the entry — returning `None` here would render it - // and no-op, which is the defect class this round is closing. - // - // Its `q.id` is NOT a library row id: `local_queue_track` sets the - // queue id to `qobuz_track_id` when the row has one. The library row id - // is carried in `source_item_id_hint` for exactly this reason, and it - // is what `local_row_input` needs to re-derive the right input kind - // (`Qobuz(qid)` when the copy has a catalog id, the file path when it - // does not). Some("qobuz_download") => q.source_item_id_hint.clone(), _ => None, } @@ -876,11 +876,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option (PlaylistTrackRow, Option"` Plex rows. LocalRefs(Vec), + /// Both at once — the QUEUE saved as a playlist while it holds catalog + /// and local rows (queue_qt::save_as_playlist). Routed as the two + /// halves would be on their own; the confusion the enum exists to make + /// unrepresentable stays so, each half in its own typed slot. + Mixed { qobuz: Vec, local: Vec }, } impl Default for Payload { @@ -89,6 +94,7 @@ impl Payload { match self { Self::Qobuz(v) => v.len(), Self::LocalRefs(v) => v.len(), + Self::Mixed { qobuz, local } => qobuz.len() + local.len(), } } @@ -360,6 +366,18 @@ pub fn open_for_local_refs(runtime: &Runtime, refs: Vec) { open_payload(runtime, Payload::LocalRefs(refs)); } +/// Open the picker with catalog ids AND local refs — the queue saved as a +/// playlist. Either half may be empty, in which case this is the matching +/// single-kind open. +pub fn open_for_mixed(runtime: &Runtime, ids: Vec, refs: Vec) { + let qobuz: Vec = ids.iter().filter_map(|s| s.parse::().ok()).collect(); + match (qobuz.is_empty(), refs.is_empty()) { + (true, _) => open_payload(runtime, Payload::LocalRefs(refs)), + (false, true) => open_payload(runtime, Payload::Qobuz(qobuz)), + (false, false) => open_payload(runtime, Payload::Mixed { qobuz, local: refs }), + } +} + fn open_payload(runtime: &Runtime, payload: Payload) { if payload.is_empty() { return; @@ -426,6 +444,14 @@ async fn refresh_containment(epoch: u64) { Payload::LocalRefs(strings) => { crate::local_playlist_qt::membership_refs_blocking(strings) } + Payload::Mixed { qobuz, local } => { + let mut refs: Vec = qobuz + .iter() + .map(|&id| PlaylistTrackRef::Qobuz(id)) + .collect(); + refs.extend(crate::local_playlist_qt::membership_refs_blocking(local)); + refs + } }; // Only the Qobuz snapshot can be incomplete; the sidecar and local // tables are locally authoritative (QBZ is their only writer). @@ -648,6 +674,11 @@ pub fn pick(playlist_id: &str) { Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) } + // Cases 1 + 2 at once: the queue's mixed payload. + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&target, &local) + } }) .await .unwrap_or(0); @@ -663,6 +694,26 @@ pub fn pick(playlist_id: &str) { }; match payload { + // Cases 3 + 4 at once — the queue's MIXED payload: the local refs + // ride the sidecar right away, and the catalog ids take the Qobuz arm + // below (its duplicate check included) through one more pass of this + // function with only them carried. + Payload::Mixed { qobuz, local } => { + let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); + let sidecar_runtime = runtime.clone(); + crate::spawn(async move { + let written = write_sidecar_refs(pid, qobuz_count, local).await; + log::info!( + "[qbz-qt] playlist picker: {written} local row(s) filed on the sidecar of {pid}" + ); + crate::playlist_qt::refresh_after_membership_change(&sidecar_runtime, pid).await; + }); + { + let mut st = STATE.lock().unwrap(); + st.payload = Payload::Qobuz(qobuz); + } + pick(playlist_id); + } // Case 3 — local refs onto a QOBUZ playlist. They attach through the // library.db sidecar tables (the same tables the merged detail // renders), never through the API: a library row id posted to Qobuz @@ -820,6 +871,10 @@ pub fn create_and_add(name: &str) { Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &ids) } + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&new_id, &local) + } } }) .await @@ -887,6 +942,20 @@ pub fn create_and_add(name: &str) { toast_added(written, &name); crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; } + // The queue's mixed payload: local refs on the sidecar, + // catalog ids through the API, one toast for the ids. + Payload::Mixed { qobuz, local } => { + if !local.is_empty() { + let written = write_sidecar_refs(pid, 0, local).await; + log::info!( + "[qbz-qt] playlist picker: {written} local row(s) filed on the new playlist {pid}" + ); + } + if !qobuz.is_empty() { + add_and_toast(&runtime, pid, qobuz, name).await; + } + crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; + } _ => {} } close(); @@ -926,6 +995,10 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) } + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&target, &local) + } } }) .await @@ -935,13 +1008,25 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p refresh_local_target(runtime, &target).await; } Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => { - let Payload::LocalRefs(refs) = payload else { - return; + let (ids, refs) = match payload { + Payload::LocalRefs(refs) => (Vec::new(), refs), + Payload::Mixed { qobuz, local } => (qobuz, local), + Payload::Qobuz(_) => return, }; + let had_ids = !ids.is_empty(); let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); - let written = write_sidecar_refs(pid, qobuz_count, refs).await; - set_last_used(&pid.to_string(), written); - toast_added(written, ""); + let written = if refs.is_empty() { + 0 + } else { + write_sidecar_refs(pid, qobuz_count, refs).await + }; + if had_ids { + add_and_toast(runtime, pid, ids, String::new()).await; + } + set_last_used(&pid.to_string(), written.max(1)); + if written > 0 || !had_ids { + toast_added(written, ""); + } crate::playlist_qt::refresh_after_membership_change(runtime, pid).await; } None => {} diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs index 85bf01593..dec430927 100644 --- a/crates/qbz-qt/src/queue_qt.rs +++ b/crates/qbz-qt/src/queue_qt.rs @@ -1493,37 +1493,62 @@ pub async fn panel_opened(runtime: &Arc>) { /// (or a synthetic ephemeral id), and the picker's Qobuz arm would add an /// unrelated catalog track under that number. /// -/// The local-mode ARM now exists (`playlist_picker_qt::open_for_local_refs`), -/// so the remaining blocker is narrower than the old note claimed: what this -/// module lacks is a source-aware row -> ref RESOLVER. A `QueueTrack`'s -/// `source_item_id_hint` is overloaded (`local_playback` documents it as the -/// album id on the non-mixtape enqueue paths), so it is not a safe Plex rating -/// key outside the local queue builder's own rows. PARITY-DEBT, recorded -/// rather than silently mixing the id spaces. +/// Every row the picker can file rides along, by SOURCE: a catalog row its +/// id, a local row (file, Plex, Jellyfin/Subsonic, offline copy) the ref +/// `local_playlist_qt::local_picker_ref_for_queue_track` builds, an +/// ephemeral row nothing. A queue of only local rows opens the picker in +/// local mode (its list is the local playlists, which is the whole list +/// offline); a queue of both kinds carries a MIXED payload the picker +/// routes as the two halves would be on their own. Until 2026-09-14 the +/// local rows were skipped, so an all-local queue opened nothing at all. pub async fn save_as_playlist(runtime: &Arc>) { let state = runtime.core().get_queue_state_full().await; + let (ids, refs, skipped) = + picker_payload_for(state.current_track.iter().chain(state.upcoming.iter())); + if skipped > 0 { + log::info!( + "[qbz-qt] queue save-as-playlist: skipped {skipped} row(s) with nothing to file \ + (ephemeral, or a local row without its key)" + ); + } + if ids.is_empty() && refs.is_empty() { + log::warn!("[qbz-qt] queue save-as-playlist: nothing to file"); + return; + } + crate::playlist_picker_qt::open_for_mixed(runtime, ids, refs); +} + +/// The picker payload for queue rows: catalog ids and local refs, each +/// de-duplicated, plus the count of rows that could not be filed. +fn picker_payload_for<'a>( + rows: impl Iterator, +) -> (Vec, Vec, usize) { let mut ids: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let mut refs: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + let mut seen_refs: HashSet = HashSet::new(); let mut skipped = 0usize; - for track in state.current_track.iter().chain(state.upcoming.iter()) { - if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { + for track in rows { + if crate::local_ephemeral::is_ephemeral_id(track.id as i64) { skipped += 1; continue; } - if seen.insert(track.id) { + if track.is_local { + match crate::local_playlist_qt::local_picker_ref_for_queue_track(track) { + Some(r) => { + if seen_refs.insert(r.clone()) { + refs.push(r); + } + } + None => skipped += 1, + } + continue; + } + if seen_ids.insert(track.id) { ids.push(track.id.to_string()); } } - if skipped > 0 { - log::info!( - "[qbz-qt] queue save-as-playlist: skipped {skipped} local/ephemeral row(s) — \ - no local-mode picker in this port" - ); - } - if ids.is_empty() { - return; - } - crate::playlist_picker_qt::open_for_ids(runtime, ids); + (ids, refs, skipped) } /// Open the picker seeded with ONE upcoming row (page-local `page_index`) — @@ -1541,17 +1566,22 @@ pub async fn add_to_playlist(runtime: &Arc>, page_ind log::warn!("[qbz-qt] queue: add_to_playlist {page_index} -> no upcoming track"); return; }; - if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { - // Defensive: QueuePanel.qml drops the entry for `isLocal` and - // `isEphemeral` rows, so this is only reachable from a stale page - // index. Not "no local-mode picker" (that arm exists) — no - // source-aware row -> ref resolver here; see `save_as_playlist`. + if crate::local_ephemeral::is_ephemeral_id(track.id as i64) { log::warn!( - "[qbz-qt] queue: add_to_playlist {page_index} is a local/ephemeral row — \ - no source-aware ref resolver on the queue" + "[qbz-qt] queue: add_to_playlist {page_index} is an ephemeral row — nothing to file" ); return; } + if track.is_local { + // By source, the way the local surfaces do it (2026-09-14). + match crate::local_playlist_qt::local_picker_ref_for_queue_track(track) { + Some(r) => crate::playlist_picker_qt::open_for_local_refs(runtime, vec![r]), + None => log::warn!( + "[qbz-qt] queue: add_to_playlist {page_index} is a local row without a picker key" + ), + } + return; + } crate::playlist_picker_qt::open_for_ids(runtime, vec![track.id.to_string()]); } @@ -1624,6 +1654,49 @@ mod tests { } } + #[test] + fn save_as_playlist_files_every_source_it_can() { + let mut local = track(41, "file"); + local.is_local = true; + local.source = Some("local".into()); + let mut plex = track(4200, "plex"); + plex.is_local = true; + plex.source = Some("plex".into()); + plex.source_item_id_hint = Some("12345".into()); + let mut jelly = track(77, "jf"); + jelly.is_local = true; + jelly.source = Some("jellyfin".into()); + jelly.source_item_id_hint = Some("abc".into()); + let mut copy = track(900, "copy"); + copy.is_local = true; + copy.source = Some("qobuz_download".into()); + copy.source_item_id_hint = Some("58".into()); + let mut keyless = track(78, "lost"); + keyless.is_local = true; + keyless.source = Some("subsonic".into()); + let rows = [ + track(555, "q"), + local, + plex, + jelly, + copy, + keyless, + track(555, "q again"), + ]; + let (ids, refs, skipped) = picker_payload_for(rows.iter()); + assert_eq!(ids, vec!["555".to_string()]); + assert_eq!( + refs, + vec![ + "41".to_string(), + "plex:12345".to_string(), + "jellyfin:abc".to_string(), + "58".to_string() + ] + ); + assert_eq!(skipped, 1); + } + /// §4.4 JSON shape: {index, tracks:[{id,title,artist,artUrl}]} over the /// FULL flat queue, history OLDEST-first. #[test] From e0e1bdfbee28351f4c763a0e069f1d1ef505b258 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:29:16 -0600 Subject: [PATCH 19/27] feat(qt): multi-select removes tracks from a playlist The playlist view's multi-select toolbar gains "Remove from playlist" for the owner of the open playlist, behind a confirm modal that names the count. One dispatcher (`playlist_remove_tracks`) routes the picked row ids by the open document's kind: a local playlist removes by stored position (descending, one blocking pass, then reload); a mixed playlist drops its sidecar rows and batches the remaining catalog ids into a single `remove_tracks_from_playlist` call with an optimistic document patch. Row positions are resolved before any delete so the compaction the local repo performs after each removal cannot shift a later target. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 4 + .../translations/en/LC_MESSAGES/qbz-ui.po | 4 + .../translations/es/LC_MESSAGES/qbz-ui.po | 4 + .../translations/fr/LC_MESSAGES/qbz-ui.po | 4 + .../translations/ja/LC_MESSAGES/qbz-ui.po | 4 + .../translations/nl/LC_MESSAGES/qbz-ui.po | 4 + .../translations/pt/LC_MESSAGES/qbz-ui.po | 4 + .../translations/ru/LC_MESSAGES/qbz-ui.po | 4 + crates/qbz-qt/qml/views/PlaylistView.qml | 32 +++++++- crates/qbz-qt/src/bridge.rs | 6 ++ crates/qbz-qt/src/local_playlist_qt.rs | 42 +++++++++++ crates/qbz-qt/src/main.rs | 31 ++++++++ crates/qbz-qt/src/playlist_qt.rs | 75 ++++++++++++++++++- 13 files changed, 215 insertions(+), 3 deletions(-) diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 0d6ffcf84..9350ec72b 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7719,3 +7719,7 @@ msgstr "Bild wählen…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "System-Hintergrundbild" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "{} Titel werden aus dieser Playlist entfernt." diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 28bb38d97..89d4a7dfb 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3374,3 +3374,7 @@ msgstr "Choose image…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "System wallpaper" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "{} tracks will be removed from this playlist." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 41a5faa7b..71487a0ae 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7725,3 +7725,7 @@ msgstr "Elegir imagen…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "Fondo del sistema" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "Se quitarán {} pistas de esta playlist." diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 373e941ab..cf8555f94 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7717,3 +7717,7 @@ msgstr "Choisir une image…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "Fond d'écran du système" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "{} titres seront retirés de cette playlist." diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 8f3db3988..1ff5a9aaf 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7700,3 +7700,7 @@ msgstr "画像を選択…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "システムの壁紙" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "{} 曲をこのプレイリストから削除します。" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index ed59317fc..442e84d83 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7707,3 +7707,7 @@ msgstr "Afbeelding kiezen…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "Systeemachtergrond" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "{} nummers worden uit deze afspeellijst verwijderd." diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index b4b3dbd79..a862cf6c7 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7717,3 +7717,7 @@ msgstr "Escolher imagem…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "Papel de parede do sistema" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "{} faixas serão removidas desta playlist." diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index b72c4540c..e17cb65d9 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7738,3 +7738,7 @@ msgstr "Выбрать изображение…" #: crates/qbz-qt/src/settings_qt.rs crates/qbz-qt/qml/settings/AppearanceSettings.qml msgid "System wallpaper" msgstr "Обои системы" + +#: crates/qbz-qt/qml/views/PlaylistView.qml +msgid "{} tracks will be removed from this playlist." +msgstr "Из этого плейлиста будет удалено {} треков." diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index 551ce3763..c936e4602 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -75,6 +75,8 @@ Rectangle { // never reach Rust, everything else goes down as a JSON id array through // QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs). property bool multiSelect: false + /// The rows a bulk "Remove from playlist" is waiting to confirm. + property var pendingRemoveIds: [] property var selected: ({}) readonly property int selectedCount: Object.keys(root.selected).length readonly property bool multiSelectOn: root.multiSelect @@ -108,6 +110,13 @@ Rectangle { return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return } + if (action === "remove") { + var picked = root.selectedIdsInOrder() + if (picked.length === 0) return + root.pendingRemoveIds = picked + removeConfirm.open() + return + } var ids = root.selectedIdsInOrder() if (ids.length === 0) return QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || "")) @@ -873,8 +882,11 @@ Rectangle { { "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true }, { "id": "add-to-favorites", "label": QbzSession.tr("Add to Library", QbzSession.trRev), "icon": "heart", "danger": false, "needsSelection": true }, { "id": "make-offline", "label": QbzSession.tr("Make available offline", QbzSession.trRev), "icon": "cloud-download", "danger": false, "needsSelection": true }, + // Owner only, like the row menu's entry (2026-09-14): one + // confirm, then one bulk remove routed per row. + { "id": "remove", "label": QbzSession.tr("Remove from playlist", QbzSession.trRev), "icon": "trash-2", "danger": true, "needsSelection": true, "hidden": !root.isOwner }, { "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true } - ] + ].filter(function (a) { return a.hidden !== true }) onAction: function (id) { root.bulkAction(id) } } @@ -1075,4 +1087,22 @@ Rectangle { } } } + + // Multi-select "Remove from playlist" (2026-09-14): one confirm for the + // whole selection, then one bulk remove — playlist_remove_tracks routes + // each row the way the single remove does (local repo, sidecar, Qobuz). + QbzConfirmModal { + id: removeConfirm + anchors.fill: parent + title: QbzSession.tr("Remove from playlist", QbzSession.trRev) + body: QbzSession.tr("{} tracks will be removed from this playlist.", QbzSession.trRev) + .replace("{}", root.pendingRemoveIds.length) + confirmLabel: QbzSession.tr("Remove", QbzSession.trRev) + danger: true + onConfirmed: { + QbzBridge.playlistRemoveTracks(JSON.stringify(root.pendingRemoveIds)) + root.pendingRemoveIds = [] + root.selected = ({}) + } + } } diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 6e5903bc8..f1294a38f 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -173,6 +173,8 @@ pub mod qbz_bridge { /// invokable this replaced could not express them. #[qinvokable] fn playlist_remove_track(self: Pin<&mut QbzBridge>, row_id: QString); + #[qinvokable] + fn playlist_remove_tracks(self: Pin<&mut QbzBridge>, ids_json: QString); /// Drag-reorder drop: visible row `from` -> insertion slot `slot`. #[qinvokable] fn playlist_reorder(self: Pin<&mut QbzBridge>, from: i32, slot: i32); @@ -394,6 +396,10 @@ impl qbz_bridge::QbzBridge { crate::playlist_remove_track(row_id.to_string()); } + pub fn playlist_remove_tracks(self: Pin<&mut Self>, ids_json: QString) { + crate::playlist_remove_tracks(ids_json.to_string()); + } + pub fn playlist_reorder(self: Pin<&mut Self>, from: i32, slot: i32) { crate::playlist_reorder(from, slot); } diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs index 0ed121c29..22f9ac1ab 100644 --- a/crates/qbz-qt/src/local_playlist_qt.rs +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -1580,6 +1580,48 @@ pub async fn remove_row(runtime: &Runtime, row_id: &str) { crate::reload_sidebar_including_local(); } +/// `remove_row` for a SELECTION: the repo positions are taken up front and +/// removed HIGHEST FIRST — `repo::remove_track` compacts the positions above +/// the one it deletes, so any other order would shift the rows still to +/// come — then one reload and one sidebar refresh. +pub async fn remove_rows(runtime: &Runtime, row_ids: &[String]) { + let Some((playlist_id, _)) = CURRENT_META.lock().ok().and_then(|m| m.clone()) else { + return; + }; + let mut positions: Vec = { + let Ok(map) = ROW_POSITIONS.lock() else { + return; + }; + row_ids + .iter() + .filter_map(|id| map.get(id).copied()) + .collect() + }; + positions.sort_unstable_by(|a, b| b.cmp(a)); + positions.dedup(); + if positions.is_empty() { + log::warn!( + "[qbz-qt] local playlist bulk remove: no known rows among {} id(s)", + row_ids.len() + ); + return; + } + let pid = playlist_id.clone(); + let _ = tokio::task::spawn_blocking(move || { + with_db(true, |db| { + Ok(db.with_connection(|conn| -> rusqlite::Result<()> { + for position in positions { + repo::remove_track(conn, &pid, position)?; + } + Ok(()) + })) + }) + }) + .await; + load(runtime, &playlist_id).await; + crate::reload_sidebar_including_local(); +} + /// Reorder the open detail: move the row at `from` to `to` (repo positions), /// then reload. /// diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 2bd778fb8..0f338c58a 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -2174,6 +2174,37 @@ pub(crate) fn playlist_remove_track(row_id: String) { }); } +/// Multi-select "Remove from playlist": the three arms of +/// `playlist_remove_track` over every selected row — the local repo rows in +/// one pass, the sidecar rows one by one (each is its own table write), the +/// Qobuz memberships in ONE API call. +pub(crate) fn playlist_remove_tracks(ids_json: String) { + let ids: Vec = serde_json::from_str(&ids_json).unwrap_or_default(); + if ids.is_empty() { + return; + } + let runtime = app(); + spawn(async move { + if local_playlist_qt::local_detail_open() { + local_playlist_qt::remove_rows(&runtime, &ids).await; + return; + } + let mut catalog: Vec = Vec::new(); + for row_id in &ids { + if playlist_qt::is_mixed() && playlist_qt::remove_sidecar_row(&runtime, row_id).await { + continue; + } + match row_id.parse::() { + Ok(id) => catalog.push(id), + Err(_) => log::warn!("[qbz-qt] playlist bulk remove: non-numeric row id {row_id}"), + } + } + if !catalog.is_empty() { + playlist_qt::remove_tracks(&runtime, &catalog).await; + } + }); +} + /// Drag-reorder drop: move the visible row `from` to insertion slot `slot`. /// /// Routes like the chevrons below: a LOCAL playlist writes the repo `position` diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index e28286a9c..b19d6bf51 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -458,7 +458,10 @@ mod sidecar_removal_tests { #[test] fn local_sidecar_is_the_numeric_row_id() { - assert!(matches!(sidecar_removal("local", "4321", None), Some(SidecarRemoval::Local(4321)))); + assert!(matches!( + sidecar_removal("local", "4321", None), + Some(SidecarRemoval::Local(4321)) + )); assert!(sidecar_removal("local", "local:nope", None).is_none()); } @@ -878,7 +881,12 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow { .unwrap_or_default(); PlaylistTrackRow { position: 0, - featured: crate::album_qt::featured_list(track.performers.as_deref(), &artist, &track.title, &[]), + featured: crate::album_qt::featured_list( + track.performers.as_deref(), + &artist, + &track.title, + &[], + ), // Heart state at build time, from the favourite-id cache — the same // O(1) read `album_qt` / `artist_qt` / `label_qt` rows use. It was // never stamped here, so `TrackRow.qml` saw `undefined` on every @@ -2067,6 +2075,69 @@ pub async fn remove_track(runtime: &Arc>, catalog_id: } } +/// `remove_track` for a SELECTION: every matching membership leaves the +/// document at once and the API is asked ONCE (`playlist/deleteTracks` takes +/// the list). Same optimistic removal, same reconcile-by-reload on failure. +pub async fn remove_tracks(runtime: &Arc>, catalog_ids: &[u64]) { + let Some((pid, memberships)) = with_doc(|d| { + if !d.is_owner { + return None; + } + let pid = d.id.parse::().ok()?; + let memberships: Vec = d + .tracks + .iter() + .filter(|t| { + t.id.parse::() + .ok() + .is_some_and(|id| catalog_ids.contains(&id)) + }) + .map(|t| t.playlist_track_id) + .collect(); + Some((pid, memberships)) + }) + .flatten() else { + return; + }; + if memberships.is_empty() { + return; + } + with_doc(|d| { + d.tracks + .retain(|t| !memberships.contains(&t.playlist_track_id)); + d.track_count = d.tracks.len() as i32; + d.total_duration = total_duration_label(&d.tracks); + let doc = d.clone(); + publish(&doc); + }); + match runtime + .core() + .remove_tracks_from_playlist(pid, &memberships) + .await + { + Ok(()) => { + let removed: Vec = catalog_ids.to_vec(); + let _ = tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(true, |db| { + Ok(db.with_connection(|conn| { + qbz_library::qobuz_playlist_snapshot::apply_removed_tracks( + conn, pid, &removed, + ) + })) + }) + }) + .await; + } + Err(e) => { + log::error!( + "[qbz-qt] remove {} membership(s) from playlist {pid} failed: {e}", + memberships.len() + ); + let _ = load(runtime, pid).await; + } + } +} + /// The open document's row ids, IN THE ORDER THE VIEW RENDERS THEM. /// /// The Qt twin of the reference's `playlist::full_item_ids()` From 461238bd6fa5e462ad92147d4efb2a882f73364a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:22:44 -0600 Subject: [PATCH 20/27] fix(qt): modal forms open clean, never with the last session's text QbzLineEdit re-seeds its box only when `text` CHANGES and only while the box is not focused; QbzTextArea only while unfocused. Modals that reset their drafts on the close edge (the box still holds focus for that frame) or reopen with the seed they closed on produced neither, so the box kept the previous input while the draft said otherwise. Both controls gain `reset(value)`, which writes the box directly, and every modal form calls it from its open handler: playlist edit and create, MyQBZ create / rename / description, folder create and edit, the link resolver, the rip wizard and the library folder editor. The MyQBZ create name now resets on the open edge, and its edit panel re-seeds on every open, also for the same collection. Renaming a Qobuz playlist also patches the playlist manager's warm cache, which the editor seeds from before any fetch: reopening the editor right after Save showed the pre-save name until the background reload landed, and forever while offline. cc --- .../qbz-qt/qml/controls/FolderEditPanel.qml | 2 ++ crates/qbz-qt/qml/controls/FolderModals.qml | 1 + .../qbz-qt/qml/controls/LinkResolverModal.qml | 3 +++ crates/qbz-qt/qml/controls/MyQbzModals.qml | 25 ++++++++++++++++--- .../qml/controls/PlaylistCreateModal.qml | 5 ++++ .../qbz-qt/qml/controls/PlaylistEditModal.qml | 6 +++++ crates/qbz-qt/qml/controls/QbzLineEdit.qml | 14 +++++++++++ crates/qbz-qt/qml/controls/QbzTextArea.qml | 6 +++++ crates/qbz-qt/qml/controls/RipWizardModal.qml | 8 ++++++ .../qml/settings/LibFolderEditModal.qml | 2 +- crates/qbz-qt/src/playlist_qt.rs | 20 +++++++++++++++ 11 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/qbz-qt/qml/controls/FolderEditPanel.qml b/crates/qbz-qt/qml/controls/FolderEditPanel.qml index 1075750cd..b564c3846 100644 --- a/crates/qbz-qt/qml/controls/FolderEditPanel.qml +++ b/crates/qbz-qt/qml/controls/FolderEditPanel.qml @@ -95,6 +95,8 @@ Item { root.draftPreset = root.doc.iconPreset || "folder" root.draftColor = root.doc.iconColor || "" root.draftHidden = root.doc.isHidden === true + // The box as well as the draft (QbzLineEdit.reset()). + nameField.reset() } /// Focus the name field. Split out so the host can call it AFTER the panel diff --git a/crates/qbz-qt/qml/controls/FolderModals.qml b/crates/qbz-qt/qml/controls/FolderModals.qml index b25432249..d49064a6a 100644 --- a/crates/qbz-qt/qml/controls/FolderModals.qml +++ b/crates/qbz-qt/qml/controls/FolderModals.qml @@ -103,6 +103,7 @@ Item { if (root.createOpen) { // Its OWN draft, reset on its OWN open transition. root.draftCreateName = "" + createField.reset("") createScope.forceActiveFocus() createField.focusField() } else { diff --git a/crates/qbz-qt/qml/controls/LinkResolverModal.qml b/crates/qbz-qt/qml/controls/LinkResolverModal.qml index 6d328a7a5..77133b299 100644 --- a/crates/qbz-qt/qml/controls/LinkResolverModal.qml +++ b/crates/qbz-qt/qml/controls/LinkResolverModal.qml @@ -25,6 +25,9 @@ Item { onVisibleChanged: { if (visible) { + // Rust cleared `url` before opening; sync the box regardless of + // where focus sat at the last close (QbzLineEdit.reset()). + urlInput.reset(QbzLink.url) urlInput.focusField() } else { // Return focus only if it still belongs to this modal. A search diff --git a/crates/qbz-qt/qml/controls/MyQbzModals.qml b/crates/qbz-qt/qml/controls/MyQbzModals.qml index 139236af4..29b0facb7 100644 --- a/crates/qbz-qt/qml/controls/MyQbzModals.qml +++ b/crates/qbz-qt/qml/controls/MyQbzModals.qml @@ -76,10 +76,19 @@ Item { property string name: "" readonly property bool canCreate: createPanel.name !== "" && !createPanel.creating - // A fresh open starts with an empty field. + // A fresh open starts with an empty field — reset on the OPEN edge, + // never the close edge: at close the box still holds focus for a + // frame, its focus-gated re-seed skips, and the next open found the + // draft already "" with the old text still in the box (the + // dirty-form report of 2026-09-14). `reset()` syncs the box itself. Connections { target: root - function onCreateOpenChanged() { createPanel.name = "" } + function onCreateOpenChanged() { + if (!root.createOpen) + return + createPanel.name = "" + createNameField.reset("") + } } Rectangle { @@ -166,6 +175,7 @@ Item { font.letterSpacing: 1.5 } QbzLineEdit { + id: createNameField width: parent.width height: 34 text: createPanel.name @@ -255,10 +265,17 @@ Item { // frame, and then `open` never changes and the prefill would be stale. // Keying it on the whole document instead would clobber what the user // is typing every time an unrelated field (`busy`) flips. - readonly property string editKey: editPanel.mode + "|" + (root.editDoc.name || "") + // The open flag rides along so a reopen on the SAME collection + // re-seeds too (a cancelled rename left its draft behind). + readonly property string editKey: (root.editOpen ? "1" : "0") + "|" + + editPanel.mode + "|" + (root.editDoc.name || "") onEditKeyChanged: { editPanel.draftName = root.editDoc.name || "" editPanel.draftDescription = root.editDoc.description || "" + if (root.editOpen) { + renameField.reset() + descriptionField.reset() + } } Rectangle { @@ -338,6 +355,7 @@ Item { // --- Body: rename QbzLineEdit { + id: renameField visible: editPanel.isRename width: parent.width height: visible ? 34 : 0 @@ -350,6 +368,7 @@ Item { // --- Body: description QbzTextArea { + id: descriptionField visible: editPanel.isDescription width: parent.width // A FLAT 96, never `visible ? 96 : 0`: a Column does not diff --git a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml index 3209e23bf..d2eaba1cd 100644 --- a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml @@ -103,6 +103,10 @@ Item { root.draftIsPublic = false // Offline forces it ON and locks it (reference :184-216). root.draftOfflineOnly = root.offlineLocked + // The boxes too (QbzLineEdit.reset()): "" is also what the + // drafts held after the last cancel, so nothing above re-seeds. + nameField.reset("") + descriptionField.reset("") scope.forceActiveFocus() nameField.focusField() } else { @@ -274,6 +278,7 @@ Item { font.weight: theme.weightMedium } QbzTextArea { + id: descriptionField width: parent.width height: 80 text: root.draftDescription diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml index b1b580efd..449518fd5 100644 --- a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -135,6 +135,11 @@ Item { root.draftDescription = root.doc.description || "" root.draftOfflineOnly = root.doc.offlineOnly === true root.draftFolderId = root.doc.folderId || "" + // The boxes, not just the drafts: a seed equal to the previous + // one changes nothing above, and a box that was still focused + // at close never re-seeded (QbzLineEdit.reset()). + nameField.reset() + descriptionField.reset() scope.forceActiveFocus() nameField.focusField() } @@ -332,6 +337,7 @@ Item { font.pixelSize: theme.fontLegal } QbzTextArea { + id: descriptionField width: parent.width height: 90 text: root.draftDescription diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index 5eda4ce41..2122fd7e4 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -101,6 +101,20 @@ Rectangle { property bool _cleared: false onTextChanged: root._cleared = false + /// Re-seed the box from `value` (default: `text`) NOW, focus or not. + /// + /// The re-seed below reacts only to a CHANGE of `text`, and only while + /// the box is not being edited. A modal that resets its draft on the + /// CLOSE edge (the input still holds focus for that frame), or that + /// reopens with the same seed it closed on, produces neither — and the + /// box kept the last thing typed while the draft said otherwise (the + /// dirty-form report of 2026-09-14). Every modal form calls this from + /// its open handler, after seeding its drafts. + function reset(value) { + root._cleared = false + input.text = (value === undefined) ? root.text : String(value) + } + function clearSearch() { input.text = "" root._cleared = true diff --git a/crates/qbz-qt/qml/controls/QbzTextArea.qml b/crates/qbz-qt/qml/controls/QbzTextArea.qml index 67bbe2c57..169cfdbed 100644 --- a/crates/qbz-qt/qml/controls/QbzTextArea.qml +++ b/crates/qbz-qt/qml/controls/QbzTextArea.qml @@ -28,6 +28,12 @@ Rectangle { property int wrapMode: TextEdit.WordWrap signal edited(string value) + /// Re-seed the body from `value` (default: `text`) NOW, focus or not — + /// the QbzLineEdit.reset() twin (the why is documented there). + function reset(value) { + input.text = (value === undefined) ? root.text : String(value) + } + QbzTheme { id: theme } width: 240 diff --git a/crates/qbz-qt/qml/controls/RipWizardModal.qml b/crates/qbz-qt/qml/controls/RipWizardModal.qml index 7acbee6a2..2d08f9484 100644 --- a/crates/qbz-qt/qml/controls/RipWizardModal.qml +++ b/crates/qbz-qt/qml/controls/RipWizardModal.qml @@ -75,6 +75,11 @@ Item { album = doc.album || "" albumArtist = doc.albumArtist || "" year = doc.year || "" + // The boxes too (QbzLineEdit.reset()): a disc re-read with the same + // tags changes no draft above. + albumField.reset() + yearField.reset() + albumArtistField.reset() libraryChoice = "none" var copy = [] var src = doc.tracks || [] @@ -254,6 +259,7 @@ Item { font.pixelSize: theme.fontLegal } QbzLineEdit { width: parent.width + id: albumField text: root.album onEdited: function (v) { root.album = v } } @@ -265,6 +271,7 @@ Item { font.pixelSize: theme.fontLegal } QbzLineEdit { width: parent.width + id: yearField text: root.year onEdited: function (v) { root.year = v } } @@ -277,6 +284,7 @@ Item { font.pixelSize: theme.fontLegal } QbzLineEdit { width: parent.width + id: albumArtistField text: root.albumArtist onEdited: function (v) { root.albumArtist = v } } diff --git a/crates/qbz-qt/qml/settings/LibFolderEditModal.qml b/crates/qbz-qt/qml/settings/LibFolderEditModal.qml index 20bf749b1..eb3f63a16 100644 --- a/crates/qbz-qt/qml/settings/LibFolderEditModal.qml +++ b/crates/qbz-qt/qml/settings/LibFolderEditModal.qml @@ -64,7 +64,7 @@ Item { root.draftIsNetwork = root.st.isNetwork === true root.draftOverride = root.st.userOverrideNetwork === true root.draftFsIndex = root.st.fsTypeIndex || 0 - aliasField.text = root.draftAlias + aliasField.reset(root.draftAlias) closeButton.forceActiveFocus() } diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index b19d6bf51..393b842d8 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -1874,6 +1874,26 @@ pub async fn rename_by_id( .await .map_err(|e| format!("rename playlist {pid} failed: {e}"))?; + // The editor seeds a Qobuz playlist from the manager's WARM cache before + // it fetches (`playlist_manager_qt::cached_playlist_seed`), and the + // reload `after_write` orders lands later — or never, offline. Patch the + // cache now, so an editor reopened right after Save never shows the + // pre-save name or description (the dirty-form report of 2026-09-14). + { + let cache_name = name.clone(); + let cache_desc = description.map(|d| d.trim().to_string()); + if crate::playlist_manager_qt::patch_cache(|data| { + if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) { + p.name = cache_name; + if let Some(desc) = cache_desc { + p.description = Some(desc); + } + } + }) { + crate::playlist_manager_qt::publish_document(); + } + } + let target = pid.to_string(); let patched = with_doc(|d| { if d.id != target { From 32fd77f75e5f14ed5ab27b43b23639c1159ac3b8 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:22:55 -0600 Subject: [PATCH 21/27] feat(qt): Wallpaper follows the window on Plasma Wayland; its blurred twin drifts "Wallpaper" now behaves like a window that sees straight through to the desktop: it always paints the part of the wallpaper that lies under the window, wherever the window is moved, with a stronger blur so nothing in the picture competes with the text. X11, macOS and Windows already told Qt where the window is. Wayland does not, so on KDE Plasma a tracker reads the window's geometry from org_kde_plasma_window_management on its own client connection and thread (wayland-client + the plasma protocol crate, pure Rust backend). It follows this process's windows only, drops every other window's object after its initial state, and republishes only when the geometry list changes; the field picks the window by size and ignores updates while another background is showing. Where the protocol is not offered the crop stays centred and one line is logged. KWin hands that protocol only to a binary whose desktop entry names it by an absolute Exec and lists it in X-KDE-Wayland-Interfaces, so the Linux desktop entries now carry both (the deb/rpm /usr/bin/qbz symlink resolves to the same binary). The AppImage step restores the bare Exec name that linuxdeploy resolves inside the AppDir, and the Nix package rewrites Exec to its store path. "Wallpaper, blurred" is Blurred art with the wallpaper in the cover's place, so it now drifts while the transport plays and holds still when it does not. cc --- .github/workflows/build-qt-linux.yml | 7 +- crates/Cargo.lock | 78 +++++ crates/qbz-qt/Cargo.toml | 5 + crates/qbz-qt/qml/shell/AppShell.qml | 22 +- crates/qbz-qt/qml/shell/WallpaperField.qml | 97 +++++- crates/qbz-qt/src/main.rs | 2 + crates/qbz-qt/src/shell_bridge.rs | 15 + crates/qbz-qt/src/wallpaper_qt.rs | 27 +- crates/qbz-qt/src/wallpaper_wayland_qt.rs | 380 +++++++++++++++++++++ flake.nix | 4 + packaging/arch/com.blitzfc.qbz.desktop | 6 +- packaging/linux/qbz.desktop | 6 +- 12 files changed, 610 insertions(+), 39 deletions(-) create mode 100644 crates/qbz-qt/src/wallpaper_wayland_qt.rs diff --git a/.github/workflows/build-qt-linux.yml b/.github/workflows/build-qt-linux.yml index 7175e1100..f1944d246 100644 --- a/.github/workflows/build-qt-linux.yml +++ b/.github/workflows/build-qt-linux.yml @@ -221,7 +221,12 @@ jobs: # The desktop file goes in under the MPRIS DesktopEntry basename # (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the # media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png. - cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop + # linuxdeploy looks the Exec name up under AppDir/usr/bin, so the + # AppImage entry keeps the bare name; the deb/rpm install the source + # entry as-is, with the absolute Exec KWin matches against the + # running binary (packaging/linux/qbz.desktop). + sed 's|^Exec=/usr/bin/qbz |Exec=qbz |' packaging/linux/qbz.desktop > com.blitzfc.qbz.desktop + grep -q '^Exec=qbz ' com.blitzfc.qbz.desktop cp packaging/icons/256x256.png qbz.png ./linuxdeploy --appimage-extract-and-run --appdir AppDir \ -e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt diff --git a/crates/Cargo.lock b/crates/Cargo.lock index db999b796..db7bb6656 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -1458,6 +1458,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dst-decoder" version = "0.1.2" @@ -4494,6 +4500,8 @@ dependencies = [ "url", "urlencoding", "uuid 1.26.0", + "wayland-client", + "wayland-protocols-plasma", "windows-sys 0.61.2", "winresource", "zbus 5.19.0", @@ -6812,6 +6820,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index bff6c3235..9bff83860 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -152,6 +152,11 @@ ksni = { version = "0.3", default-features = false, features = ["blocking", "asy # Session-bus single-instance guard. Keep the same async-io executor as ksni; # enabling zbus/tokio would poison the graph-wide executor choice. zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } +# The "Wallpaper" background reads this window's place on a Plasma Wayland +# desktop through org_kde_plasma_window_management (wallpaper_wayland_qt.rs) +# on its OWN client connection; Qt's display is never shared. +wayland-client = "0.31" +wayland-protocols-plasma = { version = "0.3", features = ["client"] } [build-dependencies] cxx-qt-build = "0.7" diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 9d0659f45..6c3f37a5d 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -121,13 +121,17 @@ Rectangle { opacity: root.veilStrength } - // Mode 3 — Wallpaper: the desktop wallpaper, the part of it that lies - // BEHIND this window (where the platform tells the window its place: - // X11, macOS, Windows — Wayland hides it, so a centred crop stands in), - // lightly blurred under the half-alpha chrome. Static: it costs a frame - // only when the window moves or resizes (shell/WallpaperField.qml). - // Mode 4 — Wallpaper, blurred: the wallpaper through the SAME atmosphere - // pass Blurred art gives the cover, held still (no transport to follow). + // Mode 3 — Wallpaper: the window reads as translucent to the desktop: + // ALWAYS the part of the wallpaper that lies under it, wherever it is + // moved (X11, macOS and Windows say where the window is; KDE Plasma + // Wayland says so through its window-management protocol; anywhere + // else a centred crop stands in), lightly blurred so nothing in the + // picture competes with the text, yet still recognisable. Static: it + // costs a frame only when the window moves or resizes + // (shell/WallpaperField.qml). + // Mode 4 — Wallpaper, blurred: Blurred art with the wallpaper in the + // cover's place — the SAME atmosphere pass, the SAME drift while the + // transport plays, the same still pose when it does not. // Neither waits for a playing track (QbzTheme.ambientOn). readonly property bool wallpaperModeOn: root.ambientOn && QbzShell.ambientMode === 3 readonly property bool wallpaperBlurOn: root.ambientOn && QbzShell.ambientMode === 4 @@ -136,7 +140,7 @@ Rectangle { visible: root.wallpaperModeOn source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : "" hostWindow: root.hostWindow - blur: 0.35 + blur: 0.6 dim: theme.isDark ? QbzShell.ambientDim : 0.0 } Rectangle { @@ -149,7 +153,7 @@ Rectangle { anchors.fill: parent visible: root.wallpaperBlurOn source: root.wallpaperBlurOn ? QbzShell.wallpaperAtmosphereUrl : "" - animated: false + animated: root.wallpaperBlurOn && QbzPlayer.npPlaying dim: theme.isDark ? QbzShell.ambientDim : 0.0 } Rectangle { diff --git a/crates/qbz-qt/qml/shell/WallpaperField.qml b/crates/qbz-qt/qml/shell/WallpaperField.qml index 272788ad0..96a7f5dda 100644 --- a/crates/qbz-qt/qml/shell/WallpaperField.qml +++ b/crates/qbz-qt/qml/shell/WallpaperField.qml @@ -1,18 +1,27 @@ // WallpaperField — the desktop wallpaper behind the window (2026-09-14): the -// part of it that lies UNDER this window, so moving the window slides the -// picture the way a translucent window would, lightly blurred beneath the -// half-alpha chrome. Nothing animates: it costs a frame only when the window -// moves or resizes, and a screen-sized texture at most (`sourceSize` caps the -// decode — a 3x-upscaled wallpaper never reaches the GPU whole). +// part of it that lies UNDER this window, wherever the window is, so the +// window reads as translucent to the desktop — an X-ray straight to the +// wallpaper, never to the windows in between (a really translucent surface +// would show those). Lightly blurred beneath the half-alpha chrome: enough +// that nothing in the picture competes with the text, little enough that the +// picture is still recognisable. Nothing animates: it costs a frame only when +// the window moves or resizes, and a screen-sized texture at most +// (`sourceSize` caps the decode — a 3x-upscaled wallpaper never reaches the +// GPU whole). // -// WHERE THE WINDOW IS. X11, macOS and Windows report the window's position; -// Wayland hides it from clients by design, so there the crop is centred on -// the screen until the Plasma window-management protocol is wired -// (wallpaper_qt.rs header). `hostWindow` is the ApplicationWindow. +// WHERE THE WINDOW IS. X11, macOS and Windows tell Qt (`hostWindow.x/y`). +// Wayland hides it from clients by design; on KDE Plasma the compositor +// reports every window's geometry through org_kde_plasma_window_management +// (wallpaper_wayland_qt.rs), published as QbzShell.wallpaperWindowsJson — +// this process's windows with absolute coordinates — and the one whose size +// is this window's is the one the picture sits under (the miniplayer and the +// dialogs are this process's windows too). Where neither can say, the crop +// is centred on the screen. `hostWindow` is the ApplicationWindow. import QtQuick import QtQuick.Window import QtQuick.Effects +import com.blitzfc.qbz Item { id: root @@ -27,17 +36,69 @@ Item { clip: true - readonly property bool positionKnown: root.hostWindow !== null - && Qt.platform.pluginName !== "wayland" + readonly property bool onWayland: Qt.platform.pluginName === "wayland" + + // Ask the compositor as soon as this field is actually painting on a + // Wayland session (idempotent on the Rust side; a no-op elsewhere). + function _track() { + if (root.visible && root.onWayland && root.source !== "") + QbzShell.trackWindowPosition() + } + onVisibleChanged: _track() + onSourceChanged: _track() + Component.onCompleted: _track() + + // Read only while this field is the background: the tracker keeps + // reporting moves after the mode is switched away, and an invisible item + // whose geometry changes still dirties the window (the repaint rule). + readonly property var compositorRects: { + if (!root.visible || !root.onWayland) + return [] + try { + var a = JSON.parse(QbzShell.wallpaperWindowsJson || "[]") + return Array.isArray(a) ? a : [] + } catch (e) { + return [] + } + } + /// The compositor's rectangle for THIS window, or null when it has said + /// nothing (no protocol, or nothing our size). + readonly property var compositorRect: root.pickRect(root.compositorRects, + root.hostWindow ? root.hostWindow.width : root.width, + root.hostWindow ? root.hostWindow.height : root.height) + + /// The rectangle whose size is closest to `w` x `h`, when it is close + /// enough to be this window at all: a geometry that is not our size + /// belongs to another of our windows, and the centred crop beats that + /// window's place. + function pickRect(rects, w, h) { + var best = null + var bestScore = 0 + for (var i = 0; i < rects.length; i++) { + var r = rects[i] + if (!r || typeof r.w !== "number" || typeof r.h !== "number" + || typeof r.x !== "number" || typeof r.y !== "number") + continue + var score = Math.abs(r.w - w) + Math.abs(r.h - h) + if (best === null || score < bestScore) { + best = r + bestScore = score + } + } + return (best !== null && bestScore <= 64) ? best : null + } + + readonly property bool positionKnown: root.visible && root.hostWindow !== null + && (!root.onWayland || root.compositorRect !== null) readonly property real screenW: Math.max(1, Screen.width) readonly property real screenH: Math.max(1, Screen.height) // The window's origin inside its screen; centred when unknown. - readonly property real offX: root.positionKnown - ? root.hostWindow.x - Screen.virtualX - : Math.max(0, (root.screenW - root.width) / 2) - readonly property real offY: root.positionKnown - ? root.hostWindow.y - Screen.virtualY - : Math.max(0, (root.screenH - root.height) / 2) + readonly property real offX: !root.positionKnown + ? Math.max(0, (root.screenW - root.width) / 2) + : (root.onWayland ? root.compositorRect.x : root.hostWindow.x) - Screen.virtualX + readonly property real offY: !root.positionKnown + ? Math.max(0, (root.screenH - root.height) / 2) + : (root.onWayland ? root.compositorRect.y : root.hostWindow.y) - Screen.virtualY Image { id: picture @@ -63,7 +124,7 @@ Item { autoPaddingEnabled: false blurEnabled: true blur: root.blur - blurMax: 48 + blurMax: 64 } Rectangle { anchors.fill: parent diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 0f338c58a..a27649f15 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -292,6 +292,8 @@ mod local_media_info_qt; mod nav_qt; mod page_restore_qt; mod wallpaper_qt; +#[cfg(target_os = "linux")] +mod wallpaper_wayland_qt; mod now_playing; mod offline_fwd; mod output_labels; diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 760aab970..f03202b3f 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -301,6 +301,11 @@ pub mod qbz_shell { /// (wallpaper_qt.rs). Empty until a mode that paints it resolves one. #[qproperty(QString, wallpaper_url)] #[qproperty(QString, wallpaper_atmosphere_url)] + /// This process's windows with their absolute geometry, as a Plasma + /// Wayland compositor reports them (`[{"x","y","w","h"}, …]`); + /// "[]" anywhere else. WallpaperField.qml picks the ApplicationWindow + /// out of it by size (wallpaper_wayland_qt.rs). + #[qproperty(QString, wallpaper_windows_json)] /// Is the kiosk touch shell the one currently mounted? /// /// The Qt counterpart of `ShellState.kiosk-profile` @@ -541,6 +546,10 @@ pub mod qbz_shell { /// Re-resolve the desktop wallpaper (window activation, a mode switch). #[qinvokable] fn refresh_wallpaper(self: Pin<&mut QbzShell>); + /// Start following this window's place on a Plasma Wayland desktop + /// (the Wallpaper mode on Wayland). Idempotent; a no-op elsewhere. + #[qinvokable] + fn track_window_position(self: Pin<&mut QbzShell>); #[qinvokable] fn navigate_forward(self: Pin<&mut QbzShell>); @@ -906,6 +915,7 @@ pub struct QbzShellRust { app_background_available: bool, wallpaper_url: QString, wallpaper_atmosphere_url: QString, + wallpaper_windows_json: QString, kiosk_profile: bool, kiosk_fullscreen_boot: bool, ambient_mode: i32, @@ -1023,6 +1033,7 @@ impl Default for QbzShellRust { app_background_available: crate::renderer_qt::gpu_tier(), wallpaper_url: QString::default(), wallpaper_atmosphere_url: QString::default(), + wallpaper_windows_json: QString::from("[]"), kiosk_profile: crate::kiosk_profile_qt::active(), kiosk_fullscreen_boot: crate::kiosk_profile_qt::active() && crate::kiosk_profile_qt::fullscreen_at_boot(), @@ -1449,6 +1460,10 @@ impl qbz_shell::QbzShell { crate::wallpaper_qt::refresh(); } + pub fn track_window_position(self: Pin<&mut Self>) { + crate::wallpaper_qt::track_window_position(); + } + pub fn navigate_forward(self: Pin<&mut Self>) { crate::nav_qt::forward(); } diff --git a/crates/qbz-qt/src/wallpaper_qt.rs b/crates/qbz-qt/src/wallpaper_qt.rs index b891b24ac..864760139 100644 --- a/crates/qbz-qt/src/wallpaper_qt.rs +++ b/crates/qbz-qt/src/wallpaper_qt.rs @@ -1,14 +1,16 @@ //! The desktop wallpaper as the app-wide background (2026-09-14). //! -//! Two `app_background` modes ride on it: "wallpaper" (3) shows the part of -//! the wallpaper that lies BEHIND the window, lightly blurred under the -//! translucent chrome (shell/WallpaperField.qml — the window's place on the -//! screen comes from Qt where the platform tells it: X11, macOS, Windows; -//! Wayland hides window positions, so a centred crop stands in until the -//! Plasma window-management protocol is wired); "wallpaper-blurred" (4) -//! passes the wallpaper through the same atmosphere pass Blurred art gives -//! the cover (atmosphere_qt::for_cover_blocking), so it reads like the album -//! blur with the desktop's colours. +//! Two `app_background` modes ride on it: "wallpaper" (3) makes the window +//! read as translucent to the desktop — ALWAYS the part of the wallpaper +//! that lies under the window, wherever it is moved, lightly blurred under +//! the half-alpha chrome (shell/WallpaperField.qml). The window's place on +//! the screen comes from Qt where the platform tells it (X11, macOS, +//! Windows) and from the compositor on KDE Plasma Wayland +//! (wallpaper_wayland_qt.rs, `org_kde_plasma_window_management`); where +//! neither can say, a centred crop stands in. "wallpaper-blurred" (4) is +//! Blurred art with the wallpaper in the cover's place: the same +//! ImmersiveAtmosphere, the same drift while the transport plays +//! (atmosphere_qt::for_cover_blocking builds its bitmap). //! //! The image is the system wallpaper, resolved per desktop — KDE Plasma's //! `plasma-org.kde.plasma.desktop-appletsrc` (the containment on screen 0 @@ -37,6 +39,13 @@ pub fn mode_uses_wallpaper(mode: i32) -> bool { mode >= MODE_WALLPAPER } +/// Start following the window's place on a Plasma Wayland desktop +/// (wallpaper_wayland_qt.rs). Idempotent; nothing to start elsewhere. +pub fn track_window_position() { + #[cfg(target_os = "linux")] + crate::wallpaper_wayland_qt::start(); +} + /// Resolve the wallpaper (the user's own image first) and publish its URL and /// its atmosphere bitmap on the shell bridge. No-op while the mode does not /// paint it, and quiet while the same file (same mtime) is already published. diff --git a/crates/qbz-qt/src/wallpaper_wayland_qt.rs b/crates/qbz-qt/src/wallpaper_wayland_qt.rs new file mode 100644 index 000000000..b6f86cc79 --- /dev/null +++ b/crates/qbz-qt/src/wallpaper_wayland_qt.rs @@ -0,0 +1,380 @@ +//! Where OUR window sits on a Plasma Wayland desktop (2026-09-14). +//! +//! The "Wallpaper" background (wallpaper_qt.rs, mode 3) paints the part of +//! the desktop picture that lies UNDER the window, so moving the window slides +//! the picture the way a translucent window would. That needs the window's +//! place on the screen, and Wayland hides it from clients by design: Qt's +//! `QWindow::position()` is a fiction there. KDE Plasma does tell a client +//! where every window is — through `org_kde_plasma_window_management`, the +//! protocol its own task manager and window switchers use — and this module +//! reads it back for the windows of this process. +//! +//! WHY A SEPARATE CONNECTION. Qt owns the process's Wayland display and reads +//! it from its own event loop; sharing that `wl_display` would mean a foreign +//! event queue dispatched from a thread Qt does not know about. A second +//! client connection is ordinary Wayland (any process may hold several), costs +//! one socket, and lives on its own thread that blocks in `dispatch` and only +//! wakes when KWin has something to say. It reports through the shell bridge +//! (`wallpaperWindowsJson`), a JSON array of this pid's windows with their +//! absolute geometry, republished only when that array changes; +//! `shell/WallpaperField.qml` picks the one whose size is the +//! ApplicationWindow's and turns the global origin into a per-screen offset. +//! Nothing else in the app touches this connection. +//! +//! WHO GETS THE PROTOCOL. KWin blacklists `org_kde_plasma_window_management` +//! (`kwin/src/wayland_server.cpp`, `interfacesBlackList`) and hands it only to +//! a client whose executable — `/proc//exe` — is named by a `.desktop` +//! file whose `Exec` (first word, canonical ABSOLUTE path) is that binary and +//! which lists the interface in `X-KDE-Wayland-Interfaces` +//! (`kwin/src/utils/serviceutils.h`). The packaged desktop entries carry both +//! (`packaging/linux/qbz.desktop`: `Exec=/usr/bin/qbz`); a development binary +//! run from the target directory gets the protocol only with a user-level +//! desktop file pointing at it. Installs whose running binary no desktop file +//! can name never see it: Flatpak and Snap (sandboxed), the AppImage (a +//! per-run mount point), Nix (the wrapper script is not the binary that +//! runs). When the global is absent the `bind` +//! fails, one line is logged, and the crop stays centred — the same picture as +//! before this module, never a broken one. Other Wayland compositors have no +//! equivalent protocol and take the same fallback. +//! +//! The tracker starts lazily (`start()`, first use of mode 3 on a Wayland +//! session, from QML which knows the QPA platform) and runs for the life of +//! the process: geometry events arrive only when a window moves or resizes, +//! so an idle tracker costs nothing. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use cxx_qt_lib::QString; +use wayland_client::globals::{registry_queue_init, GlobalListContents}; +use wayland_client::protocol::wl_registry; +use wayland_client::{Connection, Dispatch, QueueHandle}; +use wayland_protocols_plasma::plasma_window_management::client::org_kde_plasma_window::{ + self, OrgKdePlasmaWindow, +}; +use wayland_protocols_plasma::plasma_window_management::client::org_kde_plasma_window_management::{ + self, OrgKdePlasmaWindowManagement, +}; + +/// `window_with_uuid` arrived with v13; `client_geometry` (the surface +/// without decorations, which is what the ApplicationWindow measures) with +/// v18. Everything in between is tolerated: a v13–v17 compositor reports the +/// frame geometry instead, which is the same rectangle for QBZ's frameless +/// window. +const MIN_VERSION: u32 = 13; +const MAX_VERSION: u32 = 18; + +static STARTED: AtomicBool = AtomicBool::new(false); + +/// One window of this process, as the compositor reports it. Absolute +/// (global, logical) coordinates. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct WindowRect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Everything the compositor has said about one mapped window, until its +/// `pid` proves it is not ours (then the proxy is destroyed and the entry +/// dropped). +#[derive(Debug, Default)] +struct Tracked { + pid: Option, + /// Frame geometry (`geometry`), the v13–v17 fallback. + frame: Option<(i32, i32, u32, u32)>, + /// Surface geometry (`client_geometry`, v18) — preferred. + client: Option<(i32, i32, u32, u32)>, +} + +struct State { + me: u32, + windows: HashMap, + dirty: bool, + /// What the bridge holds now: a move that ends where it started, or a + /// geometry event that repeats the frame, republishes nothing. + published: String, +} + +/// Start the tracker once per process. Safe to call on every mode switch: +/// every call after the first returns immediately. Outside a Wayland session +/// there is nothing to track and nothing is started. +pub fn start() { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + return; + } + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + if let Err(e) = std::thread::Builder::new() + .name("qbz-wl-windows".into()) + .spawn(run) + { + log::warn!("[qbz-qt] wallpaper: window tracker thread failed to start: {e}"); + STARTED.store(false, Ordering::SeqCst); + } +} + +fn run() { + let conn = match Connection::connect_to_env() { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-qt] wallpaper: no Wayland connection for the window tracker: {e}"); + return; + } + }; + let (globals, mut queue) = match registry_queue_init::(&conn) { + Ok(v) => v, + Err(e) => { + log::warn!("[qbz-qt] wallpaper: Wayland registry failed: {e}"); + return; + } + }; + let qh = queue.handle(); + let _manager: OrgKdePlasmaWindowManagement = + match globals.bind(&qh, MIN_VERSION..=MAX_VERSION, ()) { + Ok(m) => m, + Err(e) => { + log::info!( + "[qbz-qt] wallpaper: org_kde_plasma_window_management is not offered to this \ + binary ({e}); the crop stays centred (wallpaper_wayland_qt.rs)" + ); + return; + } + }; + let mut state = State { + me: std::process::id(), + windows: HashMap::new(), + dirty: false, + published: "[]".to_string(), + }; + loop { + if let Err(e) = queue.blocking_dispatch(&mut state) { + log::warn!("[qbz-qt] wallpaper: window tracker stopped: {e}"); + publish("[]".to_string()); + return; + } + if state.dirty { + state.dirty = false; + let json = to_json(&state.mine()); + if json != state.published { + state.published = json.clone(); + publish(json); + } + } + } +} + +impl State { + /// This process's windows, in a stable order (position, then size) so + /// two publishes of the same layout serialise identically. + fn mine(&self) -> Vec { + let mut out: Vec = self + .windows + .values() + .filter(|w| w.pid == Some(self.me)) + .filter_map(|w| { + let (x, y, width, height) = w.client.or(w.frame)?; + Some(WindowRect { + x, + y, + width, + height, + }) + }) + .collect(); + out.sort_by(|a, b| { + (a.x, a.y, a.width, a.height).cmp(&(b.x, b.y, b.width, b.height)) + }); + out + } +} + +/// The JSON the shell bridge carries: `[{"x","y","w","h"}, …]`. +pub(crate) fn to_json(rects: &[WindowRect]) -> String { + let items: Vec = rects + .iter() + .map(|r| { + serde_json::json!({ + "x": r.x, + "y": r.y, + "w": r.width, + "h": r.height, + }) + }) + .collect(); + serde_json::Value::Array(items).to_string() +} + +fn publish(json: String) { + crate::shell_bridge::ui(move |mut b| { + b.as_mut() + .set_wallpaper_windows_json(QString::from(json.as_str())); + }); +} + +// The registry: `registry_queue_init` collected the globals; nothing to do +// per event. +impl Dispatch for State { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + manager: &OrgKdePlasmaWindowManagement, + event: org_kde_plasma_window_management::Event, + _data: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + // A mapped window: ask for its object, keyed by the uuid the + // compositor gave it. Every window on the desktop announces itself + // here; the `pid` event sorts ours from the rest. + if let org_kde_plasma_window_management::Event::WindowWithUuid { uuid, .. } = event { + manager.get_window_by_uuid(uuid.clone(), qh, uuid.clone()); + state.windows.insert(uuid, Tracked::default()); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + window: &OrgKdePlasmaWindow, + event: org_kde_plasma_window::Event, + uuid: &String, + _conn: &Connection, + _qh: &QueueHandle, + ) { + use org_kde_plasma_window::Event; + let me = state.me; + let Some(tracked) = state.windows.get_mut(uuid) else { + return; + }; + match event { + Event::PidChanged { pid } => tracked.pid = Some(pid), + Event::Geometry { + x, + y, + width, + height, + } => { + tracked.frame = Some((x, y, width, height)); + state.dirty |= tracked.pid == Some(me); + } + Event::ClientGeometry { + x, + y, + width, + height, + } => { + tracked.client = Some((x, y, width, height)); + state.dirty |= tracked.pid == Some(me); + } + // The compositor has said what it knows about a new window + // (kwin sends `pid_changed` before this, `client_geometry` + // after it). Someone else's: let go of it now, so its later + // moves never reach this thread. + Event::InitialState => { + if tracked.pid != Some(me) { + state.windows.remove(uuid); + window.destroy(); + } else { + state.dirty = true; + } + } + Event::Unmapped => { + let mine = tracked.pid == Some(me); + state.windows.remove(uuid); + window.destroy(); + state.dirty |= mine; + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_carries_each_window_with_short_keys() { + let rects = vec![ + WindowRect { + x: 640, + y: 120, + width: 1720, + height: 980, + }, + WindowRect { + x: -10, + y: 0, + width: 320, + height: 96, + }, + ]; + let json = to_json(&rects); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["x"], 640); + assert_eq!(arr[0]["y"], 120); + assert_eq!(arr[0]["w"], 1720); + assert_eq!(arr[0]["h"], 980); + assert_eq!(arr[1]["x"], -10); + } + + #[test] + fn empty_tracker_publishes_an_empty_array() { + assert_eq!(to_json(&[]), "[]"); + } + + #[test] + fn only_this_process_windows_are_reported_and_client_geometry_wins() { + let mut state = State { + me: 4242, + windows: HashMap::new(), + dirty: false, + published: String::new(), + }; + state.windows.insert( + "a".into(), + Tracked { + pid: Some(4242), + frame: Some((100, 100, 800, 600)), + client: Some((104, 130, 792, 566)), + }, + ); + state.windows.insert( + "b".into(), + Tracked { + pid: Some(7), + frame: Some((0, 0, 500, 500)), + client: None, + }, + ); + state.windows.insert( + "c".into(), + Tracked { + pid: Some(4242), + frame: None, + client: None, + }, + ); + let mine = state.mine(); + assert_eq!(mine.len(), 1); + assert_eq!(mine[0].x, 104); + assert_eq!(mine[0].width, 792); + } +} diff --git a/flake.nix b/flake.nix index 799a5ae6d..bb9487bfb 100644 --- a/flake.nix +++ b/flake.nix @@ -87,6 +87,10 @@ install -Dm755 qbz "$out/bin/qbz" install -Dm644 qbz.desktop \ "$out/share/applications/com.blitzfc.qbz.desktop" + # KWin matches the Exec path against /proc//exe before it + # offers org_kde_plasma_window_management (Wallpaper background). + substituteInPlace "$out/share/applications/com.blitzfc.qbz.desktop" \ + --replace-warn "Exec=/usr/bin/qbz" "Exec=$out/bin/qbz" if [ -d icons ]; then cp -r icons "$out/share/icons"; fi install -Dm644 LICENSE "$out/share/licenses/qbz/LICENSE" if [ -d licenses ]; then diff --git a/packaging/arch/com.blitzfc.qbz.desktop b/packaging/arch/com.blitzfc.qbz.desktop index 93bf12922..aef3da006 100644 --- a/packaging/arch/com.blitzfc.qbz.desktop +++ b/packaging/arch/com.blitzfc.qbz.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz %u +Exec=/usr/bin/qbz %u Type=Application Icon=com.blitzfc.qbz Categories=AudioVideo;Audio;Music;Player; @@ -10,3 +10,7 @@ StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; MimeType=x-scheme-handler/qobuzapp; +# KWin hands org_kde_plasma_window_management (where this window is, for the +# Wallpaper background on Plasma Wayland) only to a binary named by an +# ABSOLUTE Exec that declares the interface here. +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management diff --git a/packaging/linux/qbz.desktop b/packaging/linux/qbz.desktop index f8ca2203a..58c964902 100644 --- a/packaging/linux/qbz.desktop +++ b/packaging/linux/qbz.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz %u +Exec=/usr/bin/qbz %u Icon=qbz Terminal=false Type=Application @@ -10,3 +10,7 @@ Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; StartupWMClass=com.blitzfc.qbz StartupNotify=true MimeType=x-scheme-handler/qobuzapp; +# KWin hands org_kde_plasma_window_management (where this window is, for the +# Wallpaper background on Plasma Wayland) only to a binary named by an +# ABSOLUTE Exec that declares the interface here. +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management From b3aa9929549c38e48e1f0d0dd87ade4a3b304888 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:37:31 -0600 Subject: [PATCH 22/27] feat(qt): a keyed list model whose rows change instead of being replaced Handing an item view a new JS array is a model swap: every delegate is thrown away, the scroll offset resets and the page is built again. QbzKeyedModel takes the same arrays and reconciles them by key into removes, inserts, moves and in-place updates, so a view keeps its delegates, its offset and its artwork, and a leaving row can fade out while its neighbours slide into the gap. Each model row carries only `rowKey` and `rowRev`; a delegate looks the row up by key, so it reads the host's own JS object (no copy into a ListModel map) and a rev bump hands it a changed row. New objects for an existing key are compared field by field, so a republished document with the same data touches no delegate. A reorder keeps the longest in-order run steady and moves only the rest; past a cap, and on any scope change (tab, sort, search), the rows are replaced without animation, laid out synchronously and started from the top, like the swap they replace. Three shared transitions (add, remove, displaced) give every adopter the same motion. The remove transition restores opacity and scale as its last step: the view pools a delegate the moment the transition ends and hands it back as it was left, which measured 76 invisible rows after three removals and a scroll. tst_keyed_model covers order and identity under 400 random edits, the one-move reorder, silent resets, a removal that keeps the viewport and creates at most two delegates, the fade that keeps its content, and the pooled rows; it runs in the runtime gate. cc --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/build.rs | 4 + crates/qbz-qt/qml/controls/QbzKeyedModel.qml | 405 ++++++++++++++++++ crates/qbz-qt/qml/controls/QbzRowAdd.qml | 28 ++ .../qbz-qt/qml/controls/QbzRowDisplaced.qml | 36 ++ crates/qbz-qt/qml/controls/QbzRowRemove.qml | 40 ++ scripts/qml-tests/tst_keyed_model.qml | 339 +++++++++++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-keyed-model-qml.sh | 20 + 9 files changed, 875 insertions(+) create mode 100644 crates/qbz-qt/qml/controls/QbzKeyedModel.qml create mode 100644 crates/qbz-qt/qml/controls/QbzRowAdd.qml create mode 100644 crates/qbz-qt/qml/controls/QbzRowDisplaced.qml create mode 100644 crates/qbz-qt/qml/controls/QbzRowRemove.qml create mode 100644 scripts/qml-tests/tst_keyed_model.qml create mode 100755 scripts/test-keyed-model-qml.sh diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index a14dd9e8c..35721081d 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -87,6 +87,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-keyed-model-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" @@ -117,6 +118,7 @@ on: - "scripts/test-compact-seek-qml.sh" - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" + - "scripts/test-keyed-model-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 31e86e5aa..454c413de 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -575,6 +575,10 @@ fn main() { "qml/controls/PlaylistPickerModal.qml", "qml/controls/PmFolderIcon.qml", "qml/controls/QbzArrayModel.qml", + "qml/controls/QbzKeyedModel.qml", + "qml/controls/QbzRowAdd.qml", + "qml/controls/QbzRowDisplaced.qml", + "qml/controls/QbzRowRemove.qml", "qml/controls/QbzCircleAction.qml", "qml/controls/QbzClipboard.qml", "qml/controls/QbzColorPicker.qml", diff --git a/crates/qbz-qt/qml/controls/QbzKeyedModel.qml b/crates/qbz-qt/qml/controls/QbzKeyedModel.qml new file mode 100644 index 000000000..e1ac683a4 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzKeyedModel.qml @@ -0,0 +1,405 @@ +// QbzKeyedModel — a list model that CHANGES instead of being replaced +// (2026-09-14). +// +// Handing an item view a new JS array is a model swap: QQuickItemView:: +// setModel() throws every delegate away, resets the scroll offset and builds +// the page again, so one album leaving the Library grid used to rebuild the +// whole grid at the top with every cover fading back in. This model takes the +// same arrays (`rows`) but tells the view only what changed: rows whose key +// disappeared are REMOVED, new keys are INSERTED where they belong, keys that +// changed places are MOVED, and a row whose data changed is UPDATED in place. +// The view keeps its delegates, its scroll offset and its covers, and with the +// row transitions next door (QbzRowAdd / QbzRowRemove / QbzRowDisplaced) the +// leaving row fades out while its neighbours slide into the gap. +// +// WHAT THE VIEW SEES. Each model row carries two roles, `rowKey` and `rowRev`, +// never the row itself. A delegate looks its row up by key: +// +// required property string rowKey +// required property int rowRev +// readonly property var modelData: keyedModel.row(rowKey, rowRev) +// ListView.onReused: { opacity = 1; scale = 1 } // GridView.onReused too +// +// The delegate reads the SAME JS object the host derived (no copy into a +// ListModel map, no conversion per read, and an in-place mutation of a row is +// visible to delegates built later exactly as it was with a plain array). +// `rowRev` is bumped when a row's data changed, which re-runs that binding and +// hands the delegate its new object; an unchanged row keeps its delegate +// untouched. A removed row stays readable (a small graveyard) because its +// delegate may still be fading out: Qt does not re-notify a removed item's +// roles (QQmlDMAbstractItemModelData reads its cache once the index is -1), so +// its binding keeps the value it had. The `onReused` restore matters because +// a transition cut short (a fast flick during an insert) can pool a delegate +// half faded, and the pool hands it back exactly as it was left. +// +// SCOPE. A change of `scope` (another tab, sort, search, filter set) is not a +// reconcile — the rows are replaced wholesale, WITHOUT animation (`animate` +// drops for the reset and the registered `views` lay out synchronously, so no +// transition ever sees it), which is what a tab switch has always looked like. +// Inside one scope everything reconciles and animates. +// +// COST. Keys are compared as strings in one pass; unchanged row objects are +// skipped by identity; a new object for an existing key is compared field by +// field (`ignoredKeys` excluded) and only a real difference bumps its rev. A +// reorder is applied with the fewest moves (the longest increasing run stays +// put); past `maxMoves` a reset is both cheaper and calmer than a cascade of +// slides. The reconcile is deferred with Qt.callLater, so a derive that +// changes `rows` and `scope` together lands as one operation. + +import QtQuick + +ListModel { + id: root + + /// The rows to show, in order. Assign a new array whenever the source + /// changes; the array itself does not have to be stable. + property var rows: [] + /// A row's identity. Equal keys are the same entity; repeats are told + /// apart by occurrence (the second "x" is "x#1"). + property var keyOf: function (row) { return row.id } + /// Another scope replaces the rows without animation. + property string scope: "" + /// Row fields whose change is not a change (positional caches). + property var ignoredKeys: [] + /// While true, `rows` changes wait and apply when it clears. + property bool paused: false + /// Item views bound to this model: laid out synchronously around a reset + /// so no add/remove transition runs for it. + property var views: [] + /// Beyond this many moves in one reconcile, reset instead. + property int maxMoves: 48 + + /// Bind each row transition's `enabled` to this. + readonly property bool animate: root._animate + /// The array the model currently reflects, index for index — what an + /// index-based reader (an artwork window report) must use. + readonly property var published: root._published + + /// After every applied change; `reset` is true for a wholesale replace. + signal reconciled(bool reset) + + property bool _animate: true + property var _published: [] + // Registries live on one never-reassigned object so that reading them + // inside a delegate binding does not subscribe the binding to anything. + property var _state: ({ + "initialized": false, + "scope": "", + "keys": [], + "byKey": new Map(), + "revs": new Map(), + "graveyard": new Map(), + "rev": 0 + }) + + onRowsChanged: Qt.callLater(root.sync) + onScopeChanged: Qt.callLater(root.sync) + onPausedChanged: if (!root.paused) Qt.callLater(root.sync) + Component.onCompleted: Qt.callLater(root.sync) + + /// The row behind `key` — also for a row that just left, while its + /// delegate fades out. `rev` is only a binding dependency. + function row(key, rev) { + var st = root._state + var r = st.byKey.get(key) + if (r === undefined) + r = st.graveyard.get(key) + return r === undefined ? ({}) : r + } + + /// Model index of `key`, or -1. + function indexOfKey(key) { + return root._state.keys.indexOf(key) + } + + /// Re-hand `key`'s row to its delegate after the host mutated that row + /// in place and a mounted delegate must re-read it. + function touch(key) { + var st = root._state + var i = st.keys.indexOf(key) + if (i < 0) + return + var rev = ++st.rev + st.revs.set(key, rev) + root.setProperty(i, "rowRev", rev) + } + + function keysFor(rows) { + var seen = new Map() + var out = new Array(rows.length) + for (var i = 0; i < rows.length; i++) { + var k = String(root.keyOf(rows[i])) + var n = seen.get(k) || 0 + seen.set(k, n + 1) + out[i] = n === 0 ? k : k + "#" + n + } + return out + } + + /// Field-by-field equality, `ignoredKeys` excluded. Arrays of scalars are + /// compared element-wise; anything deeper falls back to JSON. + function sameRow(a, b) { + if (a === b) + return true + if (!a || !b || typeof a !== "object" || typeof b !== "object") + return false + var ignored = root.ignoredKeys + var k + var counted = 0 + for (k in a) { + if (ignored.indexOf(k) >= 0) + continue + counted++ + if (!(k in b) || !root._sameValue(a[k], b[k])) + return false + } + for (k in b) { + if (ignored.indexOf(k) >= 0) + continue + counted-- + } + return counted === 0 + } + + function _sameValue(x, y) { + if (x === y) + return true + if (x === null || y === null || typeof x !== "object" || typeof y !== "object") + return false + if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) { + for (var i = 0; i < x.length; i++) { + if (x[i] !== y[i]) { + if (typeof x[i] !== "object" || typeof y[i] !== "object") + return false + return JSON.stringify(x) === JSON.stringify(y) + } + } + return true + } + return JSON.stringify(x) === JSON.stringify(y) + } + + function sync() { + if (root.paused) + return + var next = root.rows || [] + var keys = root.keysFor(next) + var st = root._state + if (!st.initialized || st.scope !== root.scope) { + root._reset(next, keys) + return + } + var old = st.keys + var sameOrder = old.length === keys.length + for (var s = 0; sameOrder && s < keys.length; s++) + sameOrder = old[s] === keys[s] + if (!sameOrder && !root._restructure(next, keys)) + return + var changed = sameOrder ? root._update(next, keys) : true + root._published = next + if (changed) + root.reconciled(false) + } + + function _reset(next, keys) { + var st = root._state + var had = root.count > 0 + root._animate = false + st.byKey = new Map() + st.revs = new Map() + st.graveyard = new Map() + var batch = new Array(next.length) + for (var i = 0; i < next.length; i++) { + st.byKey.set(keys[i], next[i]) + batch[i] = { "rowKey": keys[i], "rowRev": 0 } + } + root.clear() + if (batch.length > 0) + root.append(batch) + st.keys = keys + st.scope = root.scope + st.initialized = true + root._published = next + root._layoutViews(had) + root._animate = true + root.reconciled(true) + } + + /// Apply the reset now, with transitions off, and — when it replaced + /// rows the user was looking at — start from the top, as the model swap + /// it replaces did. (A ListView lands there by itself; a GridView keeps + /// its offset clamped into the new, shorter content.) + function _layoutViews(toTop) { + var vs = root.views || [] + for (var i = 0; i < vs.length; i++) { + if (!vs[i] || typeof vs[i].forceLayout !== "function") + continue + vs[i].forceLayout() + if (toTop && typeof vs[i].positionViewAtBeginning === "function") + vs[i].positionViewAtBeginning() + } + } + + /// Removals, then inserts and moves; false when it fell back to a reset. + function _restructure(next, keys) { + var st = root._state + var wanted = new Map() + for (var w = 0; w < keys.length; w++) + wanted.set(keys[w], w) + + // Survivors in their new order, and the longest run of them that is + // already in order: those never move. + var current = st.keys.slice() + var positions = [] + var survivors = [] + for (var c = 0; c < current.length; c++) { + if (wanted.has(current[c])) { + survivors.push(current[c]) + positions.push(wanted.get(current[c])) + } + } + var steady = root._longestIncreasing(positions, survivors) + if (survivors.length - steady.size > root.maxMoves) { + root._reset(next, keys) + return false + } + + // 1. Removals, bottom-up, contiguous runs in one call. + var i = current.length - 1 + while (i >= 0) { + if (wanted.has(current[i])) { + i-- + continue + } + var end = i + while (i - 1 >= 0 && !wanted.has(current[i - 1])) + i-- + if (st.graveyard.size > 512) + st.graveyard = new Map() + for (var g = i; g <= end; g++) { + st.graveyard.set(current[g], st.byKey.get(current[g])) + st.byKey.delete(current[g]) + st.revs.delete(current[g]) + } + root.remove(i, end - i + 1) + current.splice(i, end - i + 1) + i-- + } + + // 2. Inserts (contiguous runs) and moves, walking the new order. + var p = 0 + var guard = 3 * keys.length + 16 + while (p < keys.length) { + if (--guard < 0) { + root._reset(next, keys) + return false + } + if (current[p] === keys[p]) { + p++ + continue + } + var key = keys[p] + if (!st.byKey.has(key)) { + var runEnd = p + while (runEnd + 1 < keys.length && !st.byKey.has(keys[runEnd + 1])) + runEnd++ + var batch = [] + for (var r = p; r <= runEnd; r++) { + batch.push({ "rowKey": keys[r], "rowRev": 0 }) + st.byKey.set(keys[r], next[r]) + } + root.insert(p, batch) + Array.prototype.splice.apply(current, [p, 0].concat(keys.slice(p, runEnd + 1))) + p = runEnd + 1 + continue + } + if (!steady.has(key)) { + // A row that has to move: bring it here. + var from = current.indexOf(key, p + 1) + if (from < 0) { + root._reset(next, keys) + return false + } + root.move(from, p, 1) + current.splice(p, 0, current.splice(from, 1)[0]) + p++ + continue + } + // `key` stays put, so the row sitting here is one that has to + // move further down: send it next to the first steady row that + // follows its place in the new order. + var floating = current[p] + var target = wanted.get(floating) + var anchor = -1 + for (var t = target + 1; t < keys.length; t++) { + if (steady.has(keys[t])) { + anchor = current.indexOf(keys[t]) + break + } + } + var to = anchor < 0 ? current.length - 1 : anchor - 1 + if (to <= p) { + root._reset(next, keys) + return false + } + root.move(p, to, 1) + current.splice(to, 0, current.splice(p, 1)[0]) + } + + st.keys = keys + root._update(next, keys) + return true + } + + /// Hand changed rows to their delegates; true when anything changed. + function _update(next, keys) { + var st = root._state + var changed = false + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + var prev = st.byKey.get(key) + var row = next[i] + if (prev === row) + continue + st.byKey.set(key, row) + if (prev !== undefined && root.sameRow(prev, row)) + continue + var rev = ++st.rev + st.revs.set(key, rev) + root.setProperty(i, "rowRev", rev) + changed = true + } + return changed + } + + /// Keys (of `items`) on one longest strictly increasing run of + /// `positions` — O(n log n) patience sorting. + function _longestIncreasing(positions, items) { + var n = positions.length + var tails = [] + var tailIdx = [] + var prevIdx = new Array(n) + for (var i = 0; i < n; i++) { + var v = positions[i] + var lo = 0 + var hi = tails.length + while (lo < hi) { + var mid = (lo + hi) >> 1 + if (tails[mid] < v) + lo = mid + 1 + else + hi = mid + } + tails[lo] = v + tailIdx[lo] = i + prevIdx[i] = lo > 0 ? tailIdx[lo - 1] : -1 + } + var out = new Set() + var k = tails.length > 0 ? tailIdx[tails.length - 1] : -1 + while (k >= 0) { + out.add(items[k]) + k = prevIdx[k] + } + return out + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowAdd.qml b/crates/qbz-qt/qml/controls/QbzRowAdd.qml new file mode 100644 index 000000000..71d36158f --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowAdd.qml @@ -0,0 +1,28 @@ +// QbzRowAdd — a row joining a QbzKeyedModel view fades in (a card also grows +// from a touch smaller) once its neighbours made room (2026-09-14). Assign +// it to the view's `add`, with `enabled: .animate`; a scope reset +// never runs it. + +import QtQuick + +Transition { + id: root + property int duration: 200 + /// 1.0 for list rows; cards read better growing from slightly smaller. + property real grow: 1.0 + + NumberAnimation { + property: "opacity" + from: 0.0 + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + from: root.grow + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml b/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml new file mode 100644 index 000000000..4cc694d6c --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml @@ -0,0 +1,36 @@ +// QbzRowDisplaced — the neighbours of an inserted, removed or moved row slide +// to their new place (QbzKeyedModel, 2026-09-14). Assign it to a ListView or +// GridView's `displaced` and `move`, with `enabled: .animate`. +// +// A row displaced while it was still fading in or out would otherwise keep +// that partial opacity or scale for good (Qt's ViewTransition notes), so the +// slide also settles both back to 1. +// +// Not a continuous animation: it runs once per structural change, for the +// rows actually on screen, and leaves nothing ticking (the repaint pulse +// rule in CLAUDE.md is about animations that keep presenting frames). + +import QtQuick + +Transition { + id: root + property int duration: 220 + + NumberAnimation { + properties: "x,y" + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "opacity" + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowRemove.qml b/crates/qbz-qt/qml/controls/QbzRowRemove.qml new file mode 100644 index 000000000..582bf3949 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowRemove.qml @@ -0,0 +1,40 @@ +// QbzRowRemove — a row leaving a QbzKeyedModel view fades out, a card also +// shrinks a touch (2026-09-14). Assign it to the view's `remove`, with +// `enabled: .animate`. The view keeps the delegate alive until this +// ends; its content stays because a removed row's roles are not re-notified. +// +// THE LAST STEP PUTS OPACITY AND SCALE BACK. With `reuseItems` the view pools +// the delegate the moment this transition finishes (QQuickItemViewPrivate:: +// viewItemTransitionFinished -> releaseItem -> setVisible(false), all in one +// call), and a pooled delegate is reused as it was left: without the restore +// the next row it renders is invisible. Measured 2026-09-14: 76 blank rows +// after three removals and a scroll. The restore runs before the release, in +// the same call chain, so it never paints. + +import QtQuick + +Transition { + id: root + property int duration: 180 + /// 1.0 for list rows; cards read better with a slight shrink. + property real shrink: 1.0 + + SequentialAnimation { + ParallelAnimation { + NumberAnimation { + property: "opacity" + to: 0.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + to: root.shrink + duration: root.duration + easing.type: Easing.OutCubic + } + } + PropertyAction { property: "opacity"; value: 1.0 } + PropertyAction { property: "scale"; value: 1.0 } + } +} diff --git a/scripts/qml-tests/tst_keyed_model.qml b/scripts/qml-tests/tst_keyed_model.qml new file mode 100644 index 000000000..c00d13ecc --- /dev/null +++ b/scripts/qml-tests/tst_keyed_model.qml @@ -0,0 +1,339 @@ +// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates +// instead of a model swap. Guards the order and identity contract under +// random edits, the minimal-move reorder, the silent reset, a removal that +// keeps the viewport and the delegates, the fade that keeps its content, and +// the pooled delegate that must come back opaque (76 blank rows, 2026-09-14). +import QtQuick +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 400; height: 400 + + property var countedRows: [] + property var fadeRows: [] + property var plainRows: [] + property int created: 0 + property int addRuns: 0 + property int removeRuns: 0 + property int moveSignals: 0 + + function make(n, prefix) { + var out = [] + for (var i = 0; i < n; i++) + out.push({ "id": prefix + i, "title": "T" + prefix + i }) + return out + } + + // --- counted transitions: the reset / structure tests ------------------ + QbzKeyedModel { id: counted; rows: root.countedRows; views: [countedView] } + Connections { + target: counted + function onRowsMoved() { root.moveSignals++ } + } + ListView { + id: countedView + width: 200; height: 400 + model: counted + reuseItems: true + add: Transition { + enabled: counted.animate + ScriptAction { script: root.addRuns++ } + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 } + } + remove: Transition { + enabled: counted.animate + ScriptAction { script: root.removeRuns++ } + SequentialAnimation { + NumberAnimation { property: "opacity"; to: 0; duration: 60 } + PropertyAction { property: "opacity"; value: 1 } + } + } + displaced: QbzRowDisplaced { enabled: counted.animate; duration: 60 } + delegate: Item { + required property string rowKey + required property int rowRev + readonly property var modelData: counted.row(rowKey, rowRev) + width: 200; height: 20 + Component.onCompleted: root.created++ + ListView.onReused: { opacity = 1; scale = 1 } + } + } + + // --- the shipped transitions: fade / reuse tests ----------------------- + QbzKeyedModel { id: faded; rows: root.fadeRows; views: [fadeView] } + ListView { + id: fadeView + x: 200 + width: 200; height: 400 + model: faded + reuseItems: true + remove: QbzRowRemove { enabled: faded.animate; duration: 240 } + displaced: QbzRowDisplaced { enabled: faded.animate; duration: 240 } + delegate: Item { + required property string rowKey + required property int rowRev + readonly property var modelData: faded.row(rowKey, rowRev) + readonly property string shown: modelData.title || "" + width: 200; height: 20 + } + } + + // --- no view: reconcile arithmetic ------------------------------------- + QbzKeyedModel { id: plain; rows: root.plainRows } + + TestCase { + name: "KeyedModel" + when: windowShown + + function settle(view) { wait(0); waitForRendering(view); wait(0) } + function order(m) { + var out = [] + for (var i = 0; i < m.count; i++) + out.push(m.get(i).rowKey) + return out + } + function topKey(view) { + var it = view.itemAt(10, view.contentY + 1) + return it ? it.rowKey : "" + } + function prng(seed) { + var a = seed >>> 0 + return function () { + a = (a + 0x6D2B79F5) >>> 0 + var t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + function test_a_random_edits_reconcile_order_identity_and_revs() { + var rnd = prng(20260914) + var rows = root.make(60, "r") + var serial = 60 + root.plainRows = rows + plain.sync() + for (var step = 0; step < 400; step++) { + var next = rows.slice() + var kind = Math.floor(rnd() * 6) + if (kind === 0 && next.length > 0) { + next.splice(Math.floor(rnd() * next.length), 1 + Math.floor(rnd() * 3)) + } else if (kind === 1) { + var at = Math.floor(rnd() * (next.length + 1)) + var burst = [] + for (var b = 0; b < 1 + Math.floor(rnd() * 4); b++) + burst.push({ "id": "r" + (serial++), "title": "new" }) + Array.prototype.splice.apply(next, [at, 0].concat(burst)) + } else if (kind === 2 && next.length > 1) { + var from = Math.floor(rnd() * next.length) + var moved = next.splice(from, 1)[0] + next.splice(Math.floor(rnd() * (next.length + 1)), 0, moved) + } else if (kind === 3 && next.length > 0) { + // A repeat of an existing entity (the queue allows it). + var dup = next[Math.floor(rnd() * next.length)] + next.splice(Math.floor(rnd() * (next.length + 1)), 0, + { "id": dup.id, "title": dup.title }) + } else if (kind === 4 && next.length > 0) { + var ci = Math.floor(rnd() * next.length) + next[ci] = { "id": next[ci].id, "title": "changed " + step } + } else { + // Same data, all-new objects (a republished document). + next = next.map(function (r) { return { "id": r.id, "title": r.title } }) + } + var keys = plain.keysFor(next) + var revsBefore = ({}) + for (var k = 0; k < plain.count; k++) + revsBefore[plain.get(k).rowKey] = plain.get(k).rowRev + var before = ({}) + var prevKeys = plain.keysFor(rows) + for (var p = 0; p < rows.length; p++) + before[prevKeys[p]] = rows[p] + root.plainRows = next + plain.sync() + compare(order(plain), keys, "order after step " + step) + for (var i = 0; i < next.length; i++) { + verify(plain.row(keys[i]) === next[i], "identity " + keys[i]) + var prev = before[keys[i]] + if (prev !== undefined && revsBefore[keys[i]] !== undefined) { + var contentChanged = prev.title !== next[i].title + compare(plain.get(i).rowRev !== revsBefore[keys[i]], contentChanged, + "rev bump iff content changed: " + keys[i] + " step " + step) + } + } + rows = next + } + } + + function test_b_one_row_moved_far_is_one_move() { + root.moveSignals = 0 + var rows = root.make(100, "m") + root.countedRows = rows + counted.scope = "moves" + counted.sync() + settle(countedView) + root.moveSignals = 0 + var next = rows.slice() + next.splice(90, 0, next.splice(3, 1)[0]) + root.countedRows = next + counted.sync() + compare(root.moveSignals, 1, "down") + compare(order(counted), counted.keysFor(next)) + root.moveSignals = 0 + var back = next.slice() + back.splice(3, 0, back.splice(90, 1)[0]) + root.countedRows = back + counted.sync() + compare(root.moveSignals, 1, "up") + compare(order(counted), counted.keysFor(back)) + } + + function test_c_scope_change_resets_without_transitions_at_the_top() { + root.countedRows = root.make(300, "s") + counted.scope = "tab-a" + counted.sync() + settle(countedView) + countedView.contentY = countedView.originY + 2000 + settle(countedView) + var adds = root.addRuns + var removes = root.removeRuns + counted.scope = "tab-b" + root.countedRows = root.make(120, "t") + wait(0) + settle(countedView) + wait(150) + compare(counted.count, 120) + compare(root.addRuns, adds, "no add transition for a reset") + compare(root.removeRuns, removes, "no remove transition for a reset") + verify(countedView.atYBeginning, "a reset starts at the top") + compare(topKey(countedView), "t0") + } + + function test_c2_reset_from_empty_does_not_animate_either() { + counted.scope = "empty" + root.countedRows = [] + wait(0) + settle(countedView) + var adds = root.addRuns + counted.scope = "filled" + root.countedRows = root.make(50, "e") + wait(0) + settle(countedView) + wait(150) + compare(counted.count, 50) + compare(root.addRuns, adds, "the first page of a scope appears at once") + } + + function test_d_removal_keeps_the_viewport_and_the_delegates() { + var rows = root.make(400, "v") + root.countedRows = rows + counted.scope = "viewport" + counted.sync() + settle(countedView) + countedView.contentY = countedView.originY + 1000 + settle(countedView) + var top = topKey(countedView) + compare(top, "v50") + var made = root.created + var removes = root.removeRuns + // Ten rows above the viewport and one inside it leave. + root.countedRows = rows.filter(function (r, i) { return i >= 10 && i !== 55 }) + counted.sync() + settle(countedView) + wait(150) + compare(topKey(countedView), top, "the row at the top stays at the top") + verify(root.created - made <= 2, "no rebuild: " + (root.created - made) + " delegates created") + verify(root.removeRuns > removes, "the visible row animated out") + } + + function test_e_removed_row_fades_out_with_its_content() { + root.fadeRows = root.make(40, "f") + faded.scope = "fade" + faded.sync() + settle(fadeView) + root.fadeRows = root.make(40, "f").filter(function (r) { return r.id !== "f5" }) + faded.sync() + wait(80) + var ghost = null + for (var i = 0; i < fadeView.contentItem.children.length; i++) { + var ch = fadeView.contentItem.children[i] + if (ch.rowKey === "f5" && ch.opacity > 0.05 && ch.opacity < 0.99) + ghost = ch + } + verify(ghost !== null, "the leaving row is mid-fade") + compare(ghost.shown, "Tf5", "and still shows its own content") + wait(400) + } + + function test_f_pooled_rows_come_back_opaque() { + var rows = root.make(40, "p") + root.fadeRows = rows + faded.scope = "reuse" + faded.sync() + settle(fadeView) + for (var pass = 0; pass < 3; pass++) { + rows = rows.filter(function (r, i) { return i % 7 !== 3 }) + root.fadeRows = rows + faded.sync() + wait(400) + } + root.fadeRows = rows.concat(root.make(300, "q")) + faded.sync() + settle(fadeView) + var blank = [] + for (var y = 0; y < 5000; y += 150) { + fadeView.contentY = fadeView.originY + y + wait(20) + for (var c = 0; c < fadeView.contentItem.children.length; c++) { + var it = fadeView.contentItem.children[c] + if (it.rowKey === undefined || it.index === -1) + continue + if (fadeView.itemAtIndex(faded.indexOfKey(it.rowKey)) !== it) + continue + if (it.opacity < 0.99) + blank.push(it.rowKey) + } + } + compare(blank.length, 0, "rows reused from the pool are opaque: " + blank.slice(0, 8).join(" ")) + } + + function test_g_paused_holds_rows_until_it_clears() { + root.plainRows = root.make(5, "h") + plain.scope = "paused" + plain.sync() + plain.paused = true + root.plainRows = root.make(8, "h") + plain.sync() + compare(plain.count, 5) + plain.paused = false + wait(0) + compare(plain.count, 8) + } + + function test_h_ignored_keys_do_not_count_as_a_change() { + plain.ignoredKeys = ["_order"] + root.plainRows = [{ "id": "a", "title": "A", "_order": 0 }, { "id": "b", "title": "B", "_order": 1 }] + plain.scope = "ignored" + plain.sync() + var revA = plain.get(0).rowRev + root.plainRows = [{ "id": "a", "title": "A", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }] + plain.sync() + compare(plain.get(0).rowRev, revA) + root.plainRows = [{ "id": "a", "title": "A2", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }] + plain.sync() + verify(plain.get(0).rowRev !== revA) + plain.ignoredKeys = [] + } + + function test_i_repeated_entities_are_distinct_rows() { + root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }] + plain.scope = "dups" + plain.sync() + compare(order(plain), ["x", "y", "x#1"]) + root.plainRows = [{ "id": "y" }, { "id": "x" }] + plain.sync() + compare(order(plain), ["y", "x"]) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index 0f7c14ec7..c57b95ebd 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -10,6 +10,7 @@ bash scripts/test-text-edit-menu-qml.sh bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh +bash scripts/test-keyed-model-qml.sh bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs diff --git a/scripts/test-keyed-model-qml.sh b/scripts/test-keyed-model-qml.sh new file mode 100755 index 000000000..db31f3b65 --- /dev/null +++ b/scripts/test-keyed-model-qml.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# QbzKeyedModel regression: rows reconcile into removes / inserts / moves / +# updates, a scope change resets silently at the top, a removal keeps the +# viewport and the delegates, and pooled delegates come back opaque. Same run +# in CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_keyed_model_bins="$QT_ROOT_DIR/bin" +else + qt_keyed_model_qmake="${QMAKE:-}" + if [[ -z "$qt_keyed_model_qmake" ]]; then + qt_keyed_model_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_keyed_model_bins="$("$qt_keyed_model_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_keyed_model_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_keyed_model.qml \ + -import scripts/qml-tests/imports From ce625e7e2881df553f9139bae3b20454958a3076 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:50:35 -0600 Subject: [PATCH 23/27] fix(qt): an album leaving the Library slides the grid instead of rebuilding it Removing an album (or a track, artist or playlist) from the Library made Rust republish the feed, the derive produced a new array, and every body bound to it took a model swap: the grid jumped to the top, every card was rebuilt and every cover faded in again. All four bodies (grid, list, albums list, playlists list) now share one QbzKeyedModel over the same derive: the leaving card shrinks and fades while its neighbours slide into the gap, the page stays where it was, and surviving cards keep their delegates and decoded covers. The Artists sidepanel rail gets its own keyed model with the same page identity. A different page is still a different page: tab, sort, search, source filters, genres and grouping form the model's scope and replace the rows at once, from the top, as before. A heart removed under "Favorites only" is not a scope change, so that card animates out. Supporting changes: the artwork window reports read the model's published rows and count from originY (a removal above the window moves the origin), scroll memory is origin-relative, the playlists and albums lists are height-gated like the grid so hidden bodies build nothing, pooled cells come back opaque, and a background reload with a page already on screen (a bulk removal, a language switch) keeps that page instead of blinking through the loading skeleton. tst_library_keyed mounts the real grid cell over the keyed model and checks that an album leaving through a republished feed keeps the other cards, their covers and the page offset; bound to the plain array it fails. cc --- crates/qbz-qt/qml/views/LibraryView.qml | 175 ++++++++++++++---- .../qbz-qt/qml/views/library/FeedGridCell.qml | 8 +- .../qml/views/library/LibraryAlbumsList.qml | 43 +++-- .../qml/views/library/LibraryArtistsPanel.qml | 31 +++- scripts/qml-tests/tst_library_keyed.qml | 167 +++++++++++++++++ scripts/test-keyed-model-qml.sh | 15 +- 6 files changed, 375 insertions(+), 64 deletions(-) create mode 100644 scripts/qml-tests/tst_library_keyed.qml diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 00455aaa2..bbb65fb0b 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -323,7 +323,10 @@ Rectangle { root.artistsGroup = p.artistsGroup root.artistsView = p.artistsView } - Component.onCompleted: root.applyPrefs() + Component.onCompleted: { + root.applyPrefs() + feedRows.views = [grid, list, playlistsList, albumsList.listView] + } Connections { target: QbzLibrary function onLibraryPrefsJsonChanged() { root.applyPrefs() } @@ -562,6 +565,59 @@ Rectangle { onVisibleRowsChanged: Qt.callLater(root.reportSurfaceState) onVisibleChanged: Qt.callLater(root.reportSurfaceState) + // THE MODEL every body binds to (2026-09-14). `visibleRows` stays the + // logical truth for everything that reasons about rows (selection, play + // context, the A-Z strip); the views see it through QbzKeyedModel, which + // turns a new derive into removes / inserts / moves instead of a model + // swap. That is the whole fix for "an album leaving the Library rebuilds + // the grid at the top": the heart settles, Rust republishes the feed + // (`publish_library_document`), the derive runs, and only the leaving + // card fades out while its neighbours slide into the gap — scroll offset, + // delegates and covers untouched. + // + // A new tab, sort, search or filter set is a different page, not a change + // to this one: `scope` replaces the rows at once and starts at the top, + // exactly as the swap did. `favoriteFilterRev` is deliberately NOT in the + // scope — a heart removed under "Favorites only" is precisely the row + // that should animate out. + // + // Index-coupled readers (the artwork window reports) read + // `feedRows.published`, the array the model reflects index for index; the + // reconcile is deferred one turn, and `onReconciled` re-reports because a + // removal above the window shifts every index. + /// The page identity `feedRows` resets on — for bodies that derive their + /// own keyed rows from the feed (the Artists sidepanel rail). + readonly property string rowsScope: feedRows.scope + QbzKeyedModel { + id: feedRows + rows: root.visibleRows + // The All feed shows a purchased favourite twice (a favourites row and + // a purchases row), so there the group is part of the identity; the + // tabs keep one representative per entity, whose group may change. + keyOf: function (row) { + if (row.kind === "group-header") + return row.id + return (row._membershipKey || (row.kind + ":" + row.id)) + + (root.activeTab === "all" ? "|" + (row.group || "") : "") + } + scope: JSON.stringify([root.activeTab, root.activeSort, root.search, + root.tabSearch, root.showPurchases, root.showFavorites, + root.showFollowing, root.showLocal, root.hiresOnly, root.genreNames, + root.tracksGroup, root.albumsGroup, root.artistsGroup, + root.artistsView, root.playlistsSubTab]) + // Positional cache from parseFeed: one row leaving renumbers it for + // every row after it, which is not a change to those rows. + ignoredKeys: ["_feedOrder"] + // `views` is handed over in Component.onCompleted below: all four + // bodies are declared after this model. + onReconciled: { + root.gridWindowReport() + root.listWindowReport() + albumsList.report() + playlistsList.report() + } + } + /// A-Z bucket for a title: its first letter uppercased, "#" for anything /// that is not a letter (a leading digit, "*", punctuation). Matches the /// jump strip's own buckets, so a letter in the strip always has a @@ -1093,13 +1149,14 @@ Rectangle { id: windowDebounce interval: 180 onTriggered: root.reportWindow( - pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast, pendingVisibleFirst) + pendingRows ? pendingRows : feedRows.published, pendingFirst, pendingLast, pendingVisibleFirst) property int pendingFirst: 0 property int pendingLast: 0 property int pendingVisibleFirst: 0 property var pendingRows: null } - /// `rows` is optional — omit it and the band is read off `visibleRows`. + /// `rows` is optional — omit it and the band is read off the array the + /// views show (`feedRows.published`). function queueWindowReport(first, last, rows, visibleFirst) { windowDebounce.pendingFirst = first windowDebounce.pendingLast = last @@ -1250,6 +1307,11 @@ Rectangle { readonly property int barInset: (root.activeTab === "tracks" && root.tracksMultiSelect) ? 52 : 0 + // A reload with a page already on screen (a bulk removal, a language + // switch) keeps that page until the new document lands, so the rows + // reconcile instead of the page blinking through the skeleton. + readonly property bool hasPage: root.feed.length > 0 + // Loading skeleton — a deliberate ADDITION: the Slint mounts a // bare centred 36px LoadingSpinner here (FavoritesView.slint: // 956/1012), which says "busy" but not "this is the shape of @@ -1269,6 +1331,7 @@ Rectangle { anchors.rightMargin: 32 anchors.topMargin: 16 visible: QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" + && !content.hasPage // Which shape the tab is about to render. readonly property bool listShape: (root.activeTab === "all" && root.viewMode === "list") @@ -1360,7 +1423,8 @@ Rectangle { } readonly property bool ready: - !QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" + (!QbzLibrary.libraryLoading || content.hasPage) + && QbzLibrary.libraryError === "" onReadyChanged: Qt.callLater(root.reportSurfaceState) // Which surface owns the tab right now — one predicate per body, // so no two can mount at once. @@ -1391,12 +1455,10 @@ Rectangle { // being spent on the body that was not showing. // // Collapsing the height to 0 is what actually stops the refill. - // The obvious alternative — `model: visible ? root.visibleRows : - // []` — is the one thing this file spends a paragraph warning - // against (see onLibraryFavoriteChanged): handing `model` a new - // value goes through QQuickItemView::setModel(), which resets the - // scroll offset to 0 and rebuilds every delegate. The height gate - // leaves `model` untouched. + // The obvious alternative — `model: visible ? feedRows : null` — + // is a model swap: QQuickItemView::setModel() resets the scroll + // offset to 0 and rebuilds every delegate. The height gate leaves + // `model` untouched. GridView { id: grid anchors.left: parent.left @@ -1416,7 +1478,12 @@ Rectangle { reuseItems: true clip: true boundsBehavior: Flickable.StopAtBounds - model: root.visibleRows + model: feedRows + // A card leaving shrinks and fades while the rest slide over. + add: QbzRowAdd { enabled: feedRows.animate; grow: 0.94 } + remove: QbzRowRemove { enabled: feedRows.animate; shrink: 0.92 } + move: QbzRowDisplaced { enabled: feedRows.animate } + displaced: QbzRowDisplaced { enabled: feedRows.animate } onContentYChanged: root.gridWindowReport(true) onModelChanged: root.gridWindowReport() @@ -1447,8 +1514,10 @@ Rectangle { Component.onCompleted: root.gridWindowReport() delegate: FeedGridCell { - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: feedRows.row(rowKey, rowRev) view: root item: modelData // Viewport-relative so the skeleton's 48-instance cap @@ -1474,7 +1543,11 @@ Rectangle { reuseItems: true clip: true boundsBehavior: Flickable.StopAtBounds - model: root.visibleRows + model: feedRows + add: QbzRowAdd { enabled: feedRows.animate } + remove: QbzRowRemove { enabled: feedRows.animate } + move: QbzRowDisplaced { enabled: feedRows.animate } + displaced: QbzRowDisplaced { enabled: feedRows.animate } onContentYChanged: root.listWindowReport() onModelChanged: root.listWindowReport() onVisibleChanged: root.listWindowReport() @@ -1484,8 +1557,10 @@ Rectangle { Component.onCompleted: root.listWindowReport() delegate: Loader { - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: feedRows.row(rowKey, rowRev) width: list.width height: modelData && modelData.kind === "group-header" ? 34 @@ -1583,7 +1658,12 @@ Rectangle { } onLoaded: activateLoadedRow() ListView.onPooled: releaseLoadedRow() - ListView.onReused: activateLoadedRow() + ListView.onReused: { + // A transition cut short can pool the row half faded. + opacity = 1 + scale = 1 + activateLoadedRow() + } } } @@ -1596,7 +1676,7 @@ Rectangle { anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 98 : 52) : 32 anchors.topMargin: 16 view: root - rows: content.showAlbumsList ? root.visibleRows : [] + rowsModel: feedRows } // ============ Playlists LIST mode ============================ @@ -1605,7 +1685,11 @@ Rectangle { ListView { id: playlistsList visible: content.showPlaylistsList - anchors.fill: parent + // Height-gated like the grid: hidden, it builds nothing. + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + height: playlistsList.visible ? parent.height - 16 : 0 anchors.leftMargin: 32 anchors.rightMargin: 32 anchors.topMargin: 16 @@ -1614,21 +1698,26 @@ Rectangle { cacheBuffer: 60 * 8 reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: content.showPlaylistsList ? root.visibleRows : [] + model: feedRows + add: QbzRowAdd { enabled: feedRows.animate } + remove: QbzRowRemove { enabled: feedRows.animate } + move: QbzRowDisplaced { enabled: feedRows.animate } + displaced: QbzRowDisplaced { enabled: feedRows.animate } // Gated on visibility for the same reason gridWindowReport and // listWindowReport are: `artMap` is ONE map, a report PRUNES - // outside its band, and this body still fires while hidden — - // `model` flips to [] on every tab switch (onModelChanged) and - // `anchors.fill` makes every window resize an onHeightChanged. + // outside its band, and this body still fires while hidden. // Ungated, a resize on the Artists sidepanel evicted the rail's - // avatars and requested a `visibleRows` band the rail is not - // even showing (its order is A-Z, not feed order). + // avatars and requested a feed band the rail is not even + // showing (its order is A-Z, not feed order). Offsets count + // from `originY`: a removal above the window moves the origin, + // not the rows on screen. function report() { if (!playlistsList.visible) return - var first = Math.max(0, Math.floor(playlistsList.contentY / 62) - 4) - var last = Math.ceil((playlistsList.contentY + playlistsList.height) / 62) + 4 - root.queueWindowReport(first, Math.min(root.visibleRows.length - 1, last)) + var scrollY = playlistsList.contentY - playlistsList.originY + var first = Math.max(0, Math.floor(scrollY / 62) - 4) + var last = Math.ceil((scrollY + playlistsList.height) / 62) + 4 + root.queueWindowReport(first, Math.min(feedRows.published.length - 1, last)) } onContentYChanged: playlistsList.report() onModelChanged: playlistsList.report() @@ -1637,8 +1726,11 @@ Rectangle { Component.onCompleted: playlistsList.report() delegate: PlaylistListRow { - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: feedRows.row(rowKey, rowRev) + ListView.onReused: { opacity = 1; scale = 1 } width: playlistsList.width item: modelData rowIndex: index @@ -1710,7 +1802,7 @@ Rectangle { // Back/forward scroll memory (controls/ScrollMemory.qml): reports // this container's offset while it is the live page, and restores it // when a back/forward step arms this route. - ScrollMemory { target: grid; scope: "library:" + root.activeTab } + ScrollMemory { target: grid; scope: "library:" + root.activeTab; relativeToOrigin: true } QbzScrollBar { anchors.right: parent.right anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 80 : 34) : 4 @@ -1722,7 +1814,7 @@ Rectangle { // Back/forward scroll memory (controls/ScrollMemory.qml): reports // this container's offset while it is the live page, and restores it // when a back/forward step arms this route. - ScrollMemory { target: list; scope: "library:" + root.activeTab } + ScrollMemory { target: list; scope: "library:" + root.activeTab; relativeToOrigin: true } QbzScrollBar { anchors.right: parent.right anchors.rightMargin: root.alphaVisible ? (root.dateGrouping ? 80 : 34) : 4 @@ -1734,7 +1826,7 @@ Rectangle { // Back/forward scroll memory (controls/ScrollMemory.qml): reports // this container's offset while it is the live page, and restores it // when a back/forward step arms this route. - ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab } + ScrollMemory { target: playlistsList; scope: "library:" + root.activeTab; relativeToOrigin: true } QbzScrollBar { anchors.right: parent.right anchors.rightMargin: 4 @@ -1747,10 +1839,12 @@ Rectangle { } } - // Both run from onContentYChanged, i.e. from INSIDE QQuickItemView:: - // setModel() — see the visibleRows comment. Everything they touch on the - // view (width/height/contentY/cellWidth/cellHeight) is a plain qreal on - // the private; the array comes from root, never from the view. + // Both run from onContentYChanged. Everything they touch on the view + // (width/height/contentY/originY/cellWidth/cellHeight) is a plain qreal on + // the private; the array comes from the keyed model's `published` — the + // rows index for index as the view holds them — never from `.model` (see + // the visibleRows comment). Offsets count from `originY`: rows leaving + // above the window move the content origin, not the rows on screen. // // Both early-return while their body is hidden. `artMap` is ONE map and a // report PRUNES everything outside the reported band, so a hidden body @@ -1766,11 +1860,12 @@ Rectangle { function gridWindowReport(motionOnly) { if (!grid.visible) return var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth)) - var visibleFirst = Math.max(0, Math.floor(grid.contentY / grid.cellHeight)) + var scrollY = grid.contentY - grid.originY + var visibleFirst = Math.max(0, Math.floor(scrollY / grid.cellHeight)) // Match the two rows of delegates already held by cacheBuffer. var firstRow = Math.max(0, visibleFirst - 2) - var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 2 - var m = root.visibleRows + var lastRow = Math.ceil((scrollY + grid.height) / grid.cellHeight) + 2 + var m = feedRows.published var band = firstRow + ":" + lastRow + ":" + visibleFirst + ":" + cols var now = Date.now() // Do not restart the bridge timer for every pixel in the same band. @@ -1796,12 +1891,12 @@ Rectangle { var first = list.indexAt(4, list.contentY + 1) var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1) if (first < 0) - first = Math.max(0, Math.floor(list.contentY / 50)) + first = Math.max(0, Math.floor((list.contentY - list.originY) / 50)) if (last < 0) - last = Math.ceil((list.contentY + list.height) / 50) + last = Math.ceil((list.contentY - list.originY + list.height) / 50) first = Math.max(0, first - 4) last += 4 - queueWindowReport(first, Math.min(root.visibleRows.length - 1, last)) + queueWindowReport(first, Math.min(feedRows.published.length - 1, last)) } // ============================ overlay ================================= diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index c43933838..4ddc67d19 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -63,7 +63,13 @@ Item { cell.scheduleMutableRestore() } GridView.onPooled: cell.releaseLoadedCard() - GridView.onReused: cell.scheduleMutableRestore() + GridView.onReused: { + // A row transition cut short (QbzKeyedModel in LibraryView) can pool + // the cell half faded or shrunk; the pool hands it back as it was. + cell.opacity = 1 + cell.scale = 1 + cell.scheduleMutableRestore() + } width: 200 height: 246 diff --git a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml index 8bfbb86de..590b26ffb 100644 --- a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml +++ b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml @@ -26,9 +26,11 @@ Item { /// The LibraryView root (artMap, the selection map, the window reporter). property var view: null - /// The derived album rows for this tab — `view.visibleRows`, passed in so - /// this file never re-derives and both surfaces see the same array. - property var rows: [] + /// The Library's keyed model (views/LibraryView.qml `feedRows`), shared by + /// every body so a row leaving animates instead of rebuilding the list. + property var rowsModel: null + /// For the model's synchronous reset layout. + readonly property alias listView: list QbzTheme { id: theme } @@ -39,6 +41,8 @@ Item { function positionAt(index) { list.positionViewAtIndex(index, ListView.Beginning) } + /// Re-report the artwork window (the host calls it after a reconcile). + function report() { list.report() } Column { anchors.fill: parent @@ -83,7 +87,10 @@ Item { anchors.topMargin: 4 anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom + // Height-gated like the host's grid: a hidden ListView with a + // model keeps refilling its viewport, and this one shares the + // Library's model with every other body. + height: root.visible ? parent.height - header.height - 4 : 0 // No strip inset here: the A-Z strip lives in LibraryView and // the HOST already widened this component's right margin for // it. Insetting again would double-count it. @@ -92,7 +99,11 @@ Item { cacheBuffer: 64 * 8 reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: root.rows + model: root.rowsModel + add: QbzRowAdd { enabled: root.rowsModel !== null && root.rowsModel.animate } + remove: QbzRowRemove { enabled: root.rowsModel !== null && root.rowsModel.animate } + move: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate } + displaced: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate } // Windowed artwork, same contract as the grid: never read // `list.model` back off the view (views/LibraryView.qml @@ -101,18 +112,19 @@ Item { // Gated on visibility like the host's own bodies // (LibraryView.gridWindowReport / listWindowReport): `artMap` // is ONE map and a report PRUNES outside its band, while this - // body keeps firing hidden — `rows` flips to [] on every tab - // switch (onModelChanged) and `anchors.fill` turns any window - // resize into onHeightChanged. Ungated it evicted whichever + // body keeps firing hidden (`anchors.fill` turns any window + // resize into onHeightChanged). Ungated it evicted whichever // body is actually on screen, the Artists sidepanel rail above - // all, whose band is NOT a `visibleRows` band. + // all, whose band is NOT a feed band. Offsets count from + // `originY`, which rows leaving above the window move. function report() { - if (!list.visible) return + if (!list.visible || root.rowsModel === null) return var stride = root.rowH + root.rowGap - var first = Math.max(0, Math.floor(list.contentY / stride) - 4) - var last = Math.ceil((list.contentY + list.height) / stride) + 4 + var scrollY = list.contentY - list.originY + var first = Math.max(0, Math.floor(scrollY / stride) - 4) + var last = Math.ceil((scrollY + list.height) / stride) + 4 root.view.queueWindowReport(first, - Math.min(root.rows.length - 1, last)) + Math.min(root.rowsModel.published.length - 1, last)) } onContentYChanged: list.report() onModelChanged: list.report() @@ -121,8 +133,11 @@ Item { Component.onCompleted: list.report() delegate: AlbumListRow { - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: root.rowsModel.row(rowKey, rowRev) + ListView.onReused: { opacity = 1; scale = 1 } width: list.width item: modelData rowIndex: index diff --git a/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml b/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml index fb15914f4..bae2ab9c2 100644 --- a/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml +++ b/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml @@ -95,7 +95,25 @@ Item { root._rebuilding = false root.report() } - Component.onCompleted: { root.rebuild(); root.reportSoon() } + // The rail's own keyed model (2026-09-14): an artist unfollowed from the + // pane fades out of the rail while the rows below slide up, instead of + // the rail being rebuilt from the top. Same page identity as the host's + // bodies, so a search or a sort still replaces it at once. + QbzKeyedModel { + id: railModel + rows: root.entries + keyOf: function (entry) { + return entry.t === 0 ? "letter:" + entry.label : "artist:" + entry.item.id + } + scope: root.view ? root.view.rowsScope : "" + onReconciled: root.report() + } + + Component.onCompleted: { + railModel.views = [railList] + root.rebuild() + root.reportSoon() + } Connections { target: root.view function onVisibleRowsChanged() { root.rebuild() } @@ -212,7 +230,11 @@ Item { cacheBuffer: 56 * 8 reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: root.entries + model: railModel + add: QbzRowAdd { enabled: railModel.animate } + remove: QbzRowRemove { enabled: railModel.animate } + move: QbzRowDisplaced { enabled: railModel.animate } + displaced: QbzRowDisplaced { enabled: railModel.animate } // The trigger set views/local/LocalArtistsTab.qml:161-165 already // uses for the same shape of rail: scroll, model swap, viewport @@ -225,7 +247,10 @@ Item { onVisibleChanged: if (visible) root.reportSoon() delegate: Loader { - required property var modelData + required property string rowKey + required property int rowRev + readonly property var modelData: railModel.row(rowKey, rowRev) + ListView.onReused: { opacity = 1; scale = 1 } width: railList.width height: modelData.t === 0 ? root.headerH : root.rowH sourceComponent: modelData.t === 0 ? letterComp : artistComp diff --git a/scripts/qml-tests/tst_library_keyed.qml b/scripts/qml-tests/tst_library_keyed.qml new file mode 100644 index 000000000..2a890a08f --- /dev/null +++ b/scripts/qml-tests/tst_library_keyed.qml @@ -0,0 +1,167 @@ +// The Library grid over QbzKeyedModel, with the REAL FeedGridCell delegate and +// LibraryView's key and transitions: an album leaving (a republished feed with +// all-new row objects, exactly what publish_library_document produces) must +// keep every other card's delegate and its decoded cover — no rebuild, no +// covers fading back in — and keep the page where it was. +import QtQuick +import QtTest +import com.blitzfc.qbz +import "../../crates/qbz-qt/qml/controls" +import "../../crates/qbz-qt/qml/views/library" as Library + +Item { + id: root + width: 880; height: 800 + + property var feed: [] + property int created: 0 + + QtObject { + id: host + property var artMap: ({}) + property bool showSourceBadges: false + property string activeTab: "albums" + property bool skelPhase: false + function askRemoveReleaseFavorites(item) {} + function isLocalFeedItem(item) { return false } + } + + function album(i) { + return { "kind": "album", "id": String(1000 + i), "artKey": "cover" + i, + "title": "Album " + i, "artist": "Artist", "artistId": "1", + "genre": "", "year": "2000", "qualityTier": "", "source": "qobuz", + "group": "favorites", "imageUrl": "fixture", "isFavorite": true, + "isPinned": false, "_membershipKey": "album:" + (1000 + i), + "_feedOrder": i } + } + // A republished document: same data, all-new objects, renumbered order. + function publish(ids) { + var out = [] + for (var i = 0; i < ids.length; i++) { + var row = album(ids[i]) + row._feedOrder = i + out.push(row) + } + root.feed = out + } + + QbzKeyedModel { + id: feedRows + rows: root.feed + keyOf: function (row) { + if (row.kind === "group-header") + return row.id + return (row._membershipKey || (row.kind + ":" + row.id)) + + (host.activeTab === "all" ? "|" + (row.group || "") : "") + } + scope: "albums" + ignoredKeys: ["_feedOrder"] + views: [grid] + } + + GridView { + id: grid + width: 880; height: 800 + cellWidth: 220; cellHeight: 266 + cacheBuffer: 266 * 2 + reuseItems: true + clip: true + model: feedRows + add: QbzRowAdd { enabled: feedRows.animate; grow: 0.94 } + remove: QbzRowRemove { enabled: feedRows.animate; shrink: 0.92 } + move: QbzRowDisplaced { enabled: feedRows.animate } + displaced: QbzRowDisplaced { enabled: feedRows.animate } + delegate: Library.FeedGridCell { + required property string rowKey + required property int rowRev + required property int index + readonly property var modelData: feedRows.row(rowKey, rowRev) + view: host + item: modelData + Component.onCompleted: root.created++ + } + } + + TestCase { + name: "LibraryKeyed" + when: windowShown + + function card(delegate) { + for (var i = 0; i < delegate.children.length; i++) { + var child = delegate.children[i] + if (child.item && child.item.artworkReady !== undefined) + return child.item + } + return null + } + // GridView fills its cache buffer a few cells per frame, so creation + // counts are only comparable once they stop moving. + function settleCreation() { + var last = -1 + var stable = 0 + while (stable < 3) { + wait(120) + if (root.created === last) + stable++ + else + stable = 0 + last = root.created + } + } + function delegateFor(key) { + var i = feedRows.indexOfKey(key) + return i < 0 ? null : grid.itemAtIndex(i) + } + + function test_album_leaving_keeps_cards_covers_and_page() { + var ids = [] + for (var i = 0; i < 120; i++) + ids.push(i) + var paths = ({}) + for (i = 0; i < 120; i++) + paths["cover" + i] = Qt.resolvedUrl("fixtures/red.ppm").toString() + host.artMap = paths + root.publish(ids) + wait(0) + waitForRendering(grid) + // Scroll to the fourth row of cards and let its covers decode. + grid.contentY = grid.originY + 3 * 266 + waitForRendering(grid) + var watched = ["album:1013", "album:1014", "album:1015", "album:1019"] + for (var w = 0; w < watched.length; w++) { + var key = watched[w] + tryVerify(function () { + var d = delegateFor(key) + return d !== null && card(d) !== null && card(d).artworkReady + }, 3000, "cover decoded for " + key) + } + settleCreation() + var before = ({}) + for (w = 0; w < watched.length; w++) + before[watched[w]] = delegateFor(watched[w]) + var madeBefore = root.created + var offsetBefore = grid.contentY - grid.originY + + // Album 1013 (on screen) leaves: the feed is republished. + root.publish(ids.filter(function (id) { return id !== 13 })) + wait(0) + wait(400) + waitForRendering(grid) + settleCreation() + + compare(feedRows.indexOfKey("album:1013"), -1) + var survivors = ["album:1014", "album:1015", "album:1019"] + for (w = 0; w < survivors.length; w++) { + var d = delegateFor(survivors[w]) + verify(d === before[survivors[w]], "same delegate for " + survivors[w]) + verify(card(d).artworkReady, "cover still decoded for " + survivors[w]) + compare(d.opacity, 1) + } + // One card left, so at most one row's worth of cells is new; a + // rebuild creates every visible and buffered cell again (20+). + verify(root.created - madeBefore <= 4, + "no rebuild: " + (root.created - madeBefore) + " cells created") + compare(grid.contentY - grid.originY, offsetBefore, "the page stays where it was") + } + } +} diff --git a/scripts/test-keyed-model-qml.sh b/scripts/test-keyed-model-qml.sh index db31f3b65..f18d247db 100755 --- a/scripts/test-keyed-model-qml.sh +++ b/scripts/test-keyed-model-qml.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # QbzKeyedModel regression: rows reconcile into removes / inserts / moves / # updates, a scope change resets silently at the top, a removal keeps the -# viewport and the delegates, and pooled delegates come back opaque. Same run -# in CI and locally. +# viewport and the delegates, pooled delegates come back opaque, and the +# Library grid keeps its cards and covers when an album leaves. Same run in CI +# and locally. set -euo pipefail cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." if [[ -n "${QT_ROOT_DIR:-}" ]]; then @@ -14,7 +15,9 @@ else fi qt_keyed_model_bins="$("$qt_keyed_model_qmake" -query QT_INSTALL_BINS)" fi -QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ - "$qt_keyed_model_bins/qmltestrunner" \ - -input scripts/qml-tests/tst_keyed_model.qml \ - -import scripts/qml-tests/imports +for suite in tst_keyed_model tst_library_keyed; do + QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_keyed_model_bins/qmltestrunner" \ + -input "scripts/qml-tests/$suite.qml" \ + -import scripts/qml-tests/imports +done From 408a7af0c9fe441b6ee8c7a247df0ebd43a12243 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:56:58 -0600 Subject: [PATCH 24/27] feat(qt): queue rows slide into place instead of the list being rebuilt The Listen list and the sidebar queue now reach their views through QbzKeyedModel. In the Listen list a removed track fades out while the rows below slide up, "play next" slides the queue open, a history row trimmed above the window leaves the rows on screen where they are, and a committed drag lands the row where it was dropped while its neighbours slide (the chevrons keep the animated swap). Identity is the track (source + id, repeats by occurrence); phase, section and phaseIndex are data, so a track ending updates rows in place and moves nothing. The follow-playing position and the cover window now run after the model holds the new rows. A search is a different list and is replaced at once. The sidebar's Up Next and Recently played are Repeaters in Columns, which have no remove transition: a leaving row disappears and the rows below slide up, a joining row fades in, and a page or search change is laid out synchronously without animation. Drop slots count the Repeater's own items. tst_keyed_model gains the Repeater-in-a-Column case (silent first page and page change, sliding rows that keep their items, fading insert). cc --- crates/qbz-qt/qml/shell/QueuePanel.qml | 47 +++++++++++++++-- crates/qbz-qt/qml/views/QueueView.qml | 68 +++++++++++++++++++++---- scripts/qml-tests/tst_keyed_model.qml | 70 +++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 17 deletions(-) diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index 08fb4ff12..f39c997d9 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -38,6 +38,24 @@ Rectangle { readonly property var currentRow: doc.current || null readonly property var upcoming: doc.upcoming || [] readonly property var historyRows: doc.history || [] + // Keyed models for both lists (2026-09-14): a track leaving Up Next (it + // started playing, it was removed) disappears and the rows below slide up; + // a track joining slides the list open; nothing is rebuilt. The rows are + // Repeaters in a Column, so the motion is the Column's own add / move + // transitions — a positioner has no remove transition. Identity is the + // track (source + id, repeats by occurrence); another page or search + // replaces the list at once. + QbzKeyedModel { + id: upcomingModel + rows: root.upcoming + keyOf: function (row) { return (row.source || "") + ":" + row.id } + scope: JSON.stringify([root.doc.searchQuery || "", root.doc.page || 0]) + } + QbzKeyedModel { + id: historyModel + rows: root.historyRows + keyOf: function (row) { return (row.source || "") + ":" + row.id } + } readonly property var settingsDoc: { try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) } @@ -138,7 +156,8 @@ Rectangle { // arithmetic model to keep in sync. Top half of row k -> slot k, bottom // half -> slot k+1, past the last row -> N (append). <=40 rows per page. function slotFromPointer(winY) { - var n = root.upcoming.length + // The Repeater's own count: its items are what the pointer is over. + var n = upcomingRepeater.count for (var i = 0; i < n; i++) { var d = upcomingRepeater.itemAt(i) if (d && winY < d.rowCenterY()) @@ -223,7 +242,13 @@ Rectangle { root.coverMap = Object.assign({}, m) } } - Component.onCompleted: dispatchCovers() + Component.onCompleted: { + // The lists' Columns, laid out synchronously around a reset so a new + // page or search appears at once (QbzKeyedModel). + upcomingModel.views = [upNextSection] + historyModel.views = [historyBody] + dispatchCovers() + } onDocChanged: dispatchCovers() onQueueTrackArtworkChanged: dispatchCovers() // Collect the covers of the document CURRENTLY on the property and ask the @@ -1026,13 +1051,18 @@ Rectangle { font.letterSpacing: 0.5 } + add: QbzRowAdd { enabled: upcomingModel.animate } + move: QbzRowDisplaced { enabled: upcomingModel.animate } + Repeater { id: upcomingRepeater - model: root.upcoming + model: upcomingModel delegate: Column { id: upDelegate - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: upcomingModel.row(rowKey, rowRev) width: parent ? parent.width : 0 // Row center in window space — slotFromPointer() // reads it off the Repeater's items. @@ -1167,9 +1197,16 @@ Rectangle { font.weight: theme.weightSemibold font.letterSpacing: 0.5 } + add: QbzRowAdd { enabled: historyModel.animate } + move: QbzRowDisplaced { enabled: historyModel.animate } + Repeater { - model: root.historyRows + model: historyModel delegate: QueueRow { + required property string rowKey + required property int rowRev + required property int index + readonly property var modelData: historyModel.row(rowKey, rowRev) width: parent.width row: modelData rowIndex: index diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index f45e674a4..248b900ac 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -40,6 +40,39 @@ Rectangle { && root.doc.currentIndex < root.rows.length readonly property int actionRailWidth: kioskHost ? 72 : 52 + // The list's model (2026-09-14): queue changes reach the ListView as + // removes / inserts / moves instead of a new array, so removing a track + // fades that row out while the rest slide up, "play next" slides the + // queue open, a history row trimmed above the window leaves the rows on + // screen where they are, and nothing is rebuilt from the top. + // + // Identity is the track (source + id); a repeat of the same track is its + // next occurrence. Phase, section and phaseIndex are data, not identity: + // when a track ends, the chronological order does not change, only which + // row is current, so those rows update in place and nothing moves. A + // search is a different list (the scope), replaced at once. + QbzKeyedModel { + id: rowsModel + rows: root.rows + keyOf: function (row) { return (row.source || "") + ":" + row.id } + scope: root.doc.searchQuery || "" + onReconciled: { + // The follow and the covers read view indices: run them once the + // view holds the new rows. + root.scheduleVisibleCovers() + if (root.hasPlayingRow && root.followPlaying) + root.followPlayingRow() + } + } + // A committed drop lands the dragged row where the pointer left it; only + // its neighbours slide. The chevrons keep the move animation (a swap). + property bool dropSettling: false + Timer { + id: dropSettle + interval: 600 + onTriggered: root.dropSettling = false + } + // Timing for the chronological projection currently on screen. History is // fully elapsed, the current row contributes the player's live position, // and upcoming rows are fully remaining. A filtered listen list therefore @@ -128,6 +161,7 @@ Rectangle { } Component.onCompleted: { + rowsModel.views = [queueList] QbzQueue.queueExtendedOpened() root.scheduleVisibleCovers() } @@ -139,8 +173,6 @@ Rectangle { root.scheduleVisibleCovers() if (!root.hasPlayingRow) root.cancelPlayingFollow() - else if (root.followPlaying) - Qt.callLater(function () { root.followPlayingRow() }) } onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow() @@ -170,20 +202,22 @@ Rectangle { } function requestVisibleCovers() { - if (root.rows.length === 0 || queueList.height <= 0) + // View indices index the rows the model holds, not a newer `rows`. + var shown = rowsModel.published + if (shown.length === 0 || queueList.height <= 0) return var first = queueList.indexAt(4, queueList.contentY + 2) if (first < 0) first = 0 var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2) if (last < first) - last = Math.min(root.rows.length - 1, first + 16) + last = Math.min(shown.length - 1, first + 16) first = Math.max(0, first - 3) - last = Math.min(root.rows.length - 1, last + 3) + last = Math.min(shown.length - 1, last + 3) var urls = [] var wanted = ({}) for (var i = first; i <= last; i++) { - var url = root.artUrl(root.rows[i]) + var url = root.artUrl(shown[i]) if (url !== "" && !root.coverMap[url] && wanted[url] !== true) { wanted[url] = true urls.push(url) @@ -493,9 +527,12 @@ Rectangle { root.dropSlot = -1 root.dropHot = false QbzShell.dragInlineVisual = false - if (shouldCommit) + if (shouldCommit) { + root.dropSettling = true + dropSettle.restart() QbzQueue.queueExtendedDrop(phase, phaseIndex, trackId, targetPhase, slot) + } } function dragProxyY() { @@ -806,7 +843,11 @@ Rectangle { spacing: 3 boundsBehavior: Flickable.StopAtBounds reuseItems: true - model: root.rows + model: rowsModel + add: QbzRowAdd { enabled: rowsModel.animate } + remove: QbzRowRemove { enabled: rowsModel.animate } + move: QbzRowDisplaced { enabled: rowsModel.animate && !root.dropSettling } + displaced: QbzRowDisplaced { enabled: rowsModel.animate } onContentYChanged: root.scheduleVisibleCovers() onMovementStarted: { if (!root.programmaticListPosition) @@ -815,8 +856,10 @@ Rectangle { delegate: Item { id: rowHost - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: rowsModel.row(rowKey, rowRev) readonly property string heading: root.sectionText(index, modelData) readonly property var displayItem: Object.assign({}, modelData, { @@ -832,7 +875,12 @@ Rectangle { trackRow.recycleActive = false trackRow.releaseForReuse() } - ListView.onReused: trackRow.recycleActive = true + ListView.onReused: { + // A transition cut short can pool the row half faded. + rowHost.opacity = 1 + rowHost.scale = 1 + trackRow.recycleActive = true + } Text { visible: rowHost.heading !== "" diff --git a/scripts/qml-tests/tst_keyed_model.qml b/scripts/qml-tests/tst_keyed_model.qml index c00d13ecc..6a3ffd137 100644 --- a/scripts/qml-tests/tst_keyed_model.qml +++ b/scripts/qml-tests/tst_keyed_model.qml @@ -1,8 +1,9 @@ // QbzKeyedModel: rows reconcile into removes / inserts / moves / updates // instead of a model swap. Guards the order and identity contract under // random edits, the minimal-move reorder, the silent reset, a removal that -// keeps the viewport and the delegates, the fade that keeps its content, and -// the pooled delegate that must come back opaque (76 blank rows, 2026-09-14). +// keeps the viewport and the delegates, the fade that keeps its content, the +// pooled delegate that must come back opaque (76 blank rows, 2026-09-14), and +// the Repeater-in-a-Column shape the sidebar queue uses. import QtQuick import QtTest import "../../crates/qbz-qt/qml/controls" @@ -83,6 +84,39 @@ Item { // --- no view: reconcile arithmetic ------------------------------------- QbzKeyedModel { id: plain; rows: root.plainRows } + // --- a Repeater in a Column (the sidebar queue shape) ------------------- + property var columnRows: [] + property int columnAdds: 0 + property int columnMoves: 0 + QbzKeyedModel { id: columnModel; rows: root.columnRows; views: [column] } + Column { + id: column + x: 0; y: 0 + width: 200 + add: Transition { + enabled: columnModel.animate + ScriptAction { script: root.columnAdds++ } + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 } + } + move: Transition { + enabled: columnModel.animate + ScriptAction { script: root.columnMoves++ } + NumberAnimation { properties: "y"; duration: 120 } + } + Repeater { + id: columnRepeater + model: columnModel + delegate: Rectangle { + required property string rowKey + required property int rowRev + required property int index + readonly property var modelData: columnModel.row(rowKey, rowRev) + width: 200; height: 10 + color: "transparent" + } + } + } + TestCase { name: "KeyedModel" when: windowShown @@ -326,6 +360,38 @@ Item { plain.ignoredKeys = [] } + function test_j_column_repeater_resets_silently_and_slides_on_removal() { + columnModel.scope = "page-1" + root.columnRows = root.make(12, "c") + wait(0) + wait(150) + compare(columnRepeater.count, 12) + compare(root.columnAdds, 0, "the first page is not animated in") + var survivor = columnRepeater.itemAt(4) + // One row leaves: the rows below slide up, nothing is re-created. + root.columnRows = root.make(12, "c").filter(function (r) { return r.id !== "c2" }) + columnModel.sync() + wait(30) + verify(root.columnMoves > 0, "the rows below slide") + wait(200) + compare(columnRepeater.itemAt(3), survivor, "the same row item, one slot up") + compare(survivor.y, 30) + // A row joins at the top: it fades in. + var adds = root.columnAdds + root.columnRows = [{ "id": "new", "title": "N" }].concat(root.columnRows) + columnModel.sync() + wait(150) + verify(root.columnAdds > adds, "the new row fades in") + // Another page replaces the list without animation. + adds = root.columnAdds + columnModel.scope = "page-2" + root.columnRows = root.make(8, "d") + wait(0) + wait(150) + compare(columnRepeater.count, 8) + compare(root.columnAdds, adds, "a page change is not animated") + } + function test_i_repeated_entities_are_distinct_rows() { root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }] plain.scope = "dups" From 354a38f16c19c9df24cd3e4e784d872124b60421 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:00:24 -0600 Subject: [PATCH 25/27] fix(qt): the log viewer search and the Move-to-folder search open empty Two more fields kept text across openings. The log viewer's search box and the filter it drives both outlived a close, so the modal reopened already filtered; opening it now clears both. The playlist manager's Move to folder menu reset its query but not the box, which QbzLineEdit re-seeds only while unfocused; it now resets the box too. The remaining text fields in modals, popups and menus were checked and already reset on open (genre filter, playlist picker, disc metadata, replacement search, DAC wizard, sidebar row menu) or are read-only. cc --- crates/qbz-qt/qml/shell/LogViewerModal.qml | 6 ++++++ crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml | 3 +++ 2 files changed, 9 insertions(+) diff --git a/crates/qbz-qt/qml/shell/LogViewerModal.qml b/crates/qbz-qt/qml/shell/LogViewerModal.qml index 28a13924a..d3bdc8f61 100644 --- a/crates/qbz-qt/qml/shell/LogViewerModal.qml +++ b/crates/qbz-qt/qml/shell/LogViewerModal.qml @@ -66,6 +66,12 @@ Item { onVisibleChanged: { if (visible) { root.advancedOpen = false + // A modal's fields open clean (2026-09-14): the search box and the + // filter it drives both outlived the close. + if (searchInput.text !== "") { + searchInput.text = "" + QbzShell.logSetSearch("") + } keyScope.forceActiveFocus() } } diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml index 2f982d745..788c0fab1 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml @@ -83,6 +83,8 @@ QbzContextMenu { menuRoot.playlistId = String(pid || "") menuRoot.currentFolderId = String(fid || "") menuRoot.folderQuery = "" + // The box too: QbzLineEdit re-seeds only while unfocused. + folderSearchField.reset("") menuRoot.openBelowRight(anchorItem) } @@ -103,6 +105,7 @@ QbzContextMenu { height: visible ? (menuRoot.kioskHost ? 44 : 32) : 0 QbzLineEdit { + id: folderSearchField kioskHost: menuRoot.kioskHost anchors.verticalCenter: parent.verticalCenter // The plain arm is a FIXED width: 240 — a modal/menu must set it. From 6b1fcd30abc52c17315903faab72bc8246ac3eff Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:04:00 -0600 Subject: [PATCH 26/27] fix(qt): rename the Wallpaper field's platform flag off the handler namespace The field's platform flag was named `onWayland`. With no `wayland` member beside it the binding happens to work, but the on-prefixed property audit (which qt-run.sh runs) rejects any `on` name, and an instance cannot assign it: `onWayland: false` is parsed as a handler for a nonexistent signal and the component fails to load. It is `isWayland` now, writable so a test can take the Wayland branch under the offscreen platform, and a change of it also asks Rust to follow the window. tst_wallpaper_field covers the Qt position on X11 / macOS / Windows, the compositor rectangle picked by this window's size on Wayland (a miniplayer-sized window is ignored, a move is followed), the centred fallback, and an invisible field that reads nothing. It runs in the runtime gate. cc --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/qml/shell/WallpaperField.qml | 16 ++-- .../imports/com/blitzfc/qbz/QbzShell.qml | 5 +- scripts/qml-tests/tst_wallpaper_field.qml | 85 +++++++++++++++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-wallpaper-field-qml.sh | 20 +++++ 6 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 scripts/qml-tests/tst_wallpaper_field.qml create mode 100755 scripts/test-wallpaper-field-qml.sh diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index 35721081d..106774c6b 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -88,6 +88,7 @@ on: - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" - "scripts/test-keyed-model-qml.sh" + - "scripts/test-wallpaper-field-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" @@ -119,6 +120,7 @@ on: - "scripts/test-library-folders-qml.sh" - "scripts/test-array-model-qml.sh" - "scripts/test-keyed-model-qml.sh" + - "scripts/test-wallpaper-field-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" - ".github/workflows/test-crates.yml" diff --git a/crates/qbz-qt/qml/shell/WallpaperField.qml b/crates/qbz-qt/qml/shell/WallpaperField.qml index 96a7f5dda..2497d00d4 100644 --- a/crates/qbz-qt/qml/shell/WallpaperField.qml +++ b/crates/qbz-qt/qml/shell/WallpaperField.qml @@ -36,23 +36,27 @@ Item { clip: true - readonly property bool onWayland: Qt.platform.pluginName === "wayland" + /// Where the window's place comes from: the compositor's report on + /// Wayland, Qt everywhere else. Writable only so a test can take the + /// Wayland branch under the offscreen platform. + property bool isWayland: Qt.platform.pluginName === "wayland" // Ask the compositor as soon as this field is actually painting on a // Wayland session (idempotent on the Rust side; a no-op elsewhere). function _track() { - if (root.visible && root.onWayland && root.source !== "") + if (root.visible && root.isWayland && root.source !== "") QbzShell.trackWindowPosition() } onVisibleChanged: _track() onSourceChanged: _track() + onIsWaylandChanged: _track() Component.onCompleted: _track() // Read only while this field is the background: the tracker keeps // reporting moves after the mode is switched away, and an invisible item // whose geometry changes still dirties the window (the repaint rule). readonly property var compositorRects: { - if (!root.visible || !root.onWayland) + if (!root.visible || !root.isWayland) return [] try { var a = JSON.parse(QbzShell.wallpaperWindowsJson || "[]") @@ -89,16 +93,16 @@ Item { } readonly property bool positionKnown: root.visible && root.hostWindow !== null - && (!root.onWayland || root.compositorRect !== null) + && (!root.isWayland || root.compositorRect !== null) readonly property real screenW: Math.max(1, Screen.width) readonly property real screenH: Math.max(1, Screen.height) // The window's origin inside its screen; centred when unknown. readonly property real offX: !root.positionKnown ? Math.max(0, (root.screenW - root.width) / 2) - : (root.onWayland ? root.compositorRect.x : root.hostWindow.x) - Screen.virtualX + : (root.isWayland ? root.compositorRect.x : root.hostWindow.x) - Screen.virtualX readonly property real offY: !root.positionKnown ? Math.max(0, (root.screenH - root.height) / 2) - : (root.onWayland ? root.compositorRect.y : root.hostWindow.y) - Screen.virtualY + : (root.isWayland ? root.compositorRect.y : root.hostWindow.y) - Screen.virtualY Image { id: picture diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml index 7894d5d98..c653af935 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml @@ -7,4 +7,7 @@ QtObject { property string restoreScope: "" property real scrollRestore: 0 function reportScroll(scope, y) {} - property string themeJson: ""; property int ambientMode: 0 } + property string themeJson: ""; property int ambientMode: 0 + property string wallpaperWindowsJson: "[]" + property int trackRequests: 0 + function trackWindowPosition() { trackRequests++ } } diff --git a/scripts/qml-tests/tst_wallpaper_field.qml b/scripts/qml-tests/tst_wallpaper_field.qml new file mode 100644 index 000000000..e162e0f38 --- /dev/null +++ b/scripts/qml-tests/tst_wallpaper_field.qml @@ -0,0 +1,85 @@ +// WallpaperField: the wallpaper crop sits under the window. On X11 / macOS / +// Windows the offset is the window's own position; on Wayland it is the +// compositor's rectangle for this window (picked by size out of this +// process's windows); with neither, the crop is centred. An invisible field +// reads nothing and asks for nothing. +import QtQuick +import QtQuick.Window +import QtTest +import com.blitzfc.qbz +import "../../crates/qbz-qt/qml/shell" + +Item { + id: root + width: 800; height: 600 + + QtObject { + id: fakeWindow + property real x: 300 + property real y: 100 + property real width: 800 + property real height: 600 + } + + WallpaperField { + id: field + width: 800; height: 600 + hostWindow: fakeWindow + source: Qt.resolvedUrl("fixtures/red.ppm") + isWayland: false + } + + TestCase { + name: "WallpaperField" + when: windowShown + + function init() { + QbzShell.wallpaperWindowsJson = "[]" + field.visible = true + field.isWayland = false + fakeWindow.x = 300 + fakeWindow.y = 100 + } + + function test_a_x11_follows_the_window_position() { + compare(field.offX, 300 - Screen.virtualX) + compare(field.offY, 100 - Screen.virtualY) + fakeWindow.x = 20 + compare(field.offX, 20 - Screen.virtualX) + } + + function test_b_wayland_uses_the_compositor_rect_for_this_window() { + var before = QbzShell.trackRequests + field.isWayland = true + verify(QbzShell.trackRequests > before, "asks Rust to follow the window") + // Without a report the crop is centred. + verify(!field.positionKnown) + compare(field.offX, Math.max(0, (field.screenW - 800) / 2)) + // Two of our windows: the miniplayer and the main window. + QbzShell.wallpaperWindowsJson = JSON.stringify([ + { "x": 10, "y": 10, "w": 320, "h": 96 }, + { "x": 640, "y": 120, "w": 800, "h": 600 } + ]) + verify(field.positionKnown) + compare(field.offX, 640 - Screen.virtualX) + compare(field.offY, 120 - Screen.virtualY) + // The window moved: the compositor says so. + QbzShell.wallpaperWindowsJson = JSON.stringify([{ "x": 900, "y": 40, "w": 800, "h": 600 }]) + compare(field.offX, 900 - Screen.virtualX) + } + + function test_c_wayland_ignores_windows_that_are_not_this_size() { + field.isWayland = true + QbzShell.wallpaperWindowsJson = JSON.stringify([{ "x": 10, "y": 10, "w": 320, "h": 96 }]) + verify(!field.positionKnown, "the miniplayer is not this window") + } + + function test_d_invisible_field_reads_nothing() { + field.isWayland = true + QbzShell.wallpaperWindowsJson = JSON.stringify([{ "x": 640, "y": 120, "w": 800, "h": 600 }]) + field.visible = false + compare(field.compositorRects.length, 0) + verify(!field.positionKnown) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index c57b95ebd..f845cea3b 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -11,6 +11,7 @@ bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh bash scripts/test-keyed-model-qml.sh +bash scripts/test-wallpaper-field-qml.sh bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs diff --git a/scripts/test-wallpaper-field-qml.sh b/scripts/test-wallpaper-field-qml.sh new file mode 100755 index 000000000..631cffad6 --- /dev/null +++ b/scripts/test-wallpaper-field-qml.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# WallpaperField regression: the Wallpaper background's crop follows the +# window — Qt's position on X11/macOS/Windows, the compositor's rectangle for +# this window on Plasma Wayland, centred otherwise — and an invisible field +# reads nothing. Same run in CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_wallpaper_bins="$QT_ROOT_DIR/bin" +else + qt_wallpaper_qmake="${QMAKE:-}" + if [[ -z "$qt_wallpaper_qmake" ]]; then + qt_wallpaper_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_wallpaper_bins="$("$qt_wallpaper_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_wallpaper_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_wallpaper_field.qml \ + -import scripts/qml-tests/imports From 49c5d64fc0346d2858605f41c625297de32f91c2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:13:54 -0600 Subject: [PATCH 27/27] style(qt): a touch more blur on the Wallpaper background The Wallpaper mode's blur goes from 0.6 to 0.75 of MultiEffect's 64 px maximum (about 38 to 48 px), so the picture behind the window stays recognisable while competing even less with the text. cc --- crates/qbz-qt/qml/shell/AppShell.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 6c3f37a5d..9f9806163 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -140,7 +140,7 @@ Rectangle { visible: root.wallpaperModeOn source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : "" hostWindow: root.hostWindow - blur: 0.6 + blur: 0.75 dim: theme.isDark ? QbzShell.ambientDim : 0.0 } Rectangle {