merge: integrate rounds 7-10 (search boxes, exact page restore, ambient chrome, Wallpaper modes, keyed lists, clean modal forms, bulk playlist removal) into pre-release

cc
This commit is contained in:
vicrodh committed 2026-09-14 08:26:38 -06:00
commit 6456dc40bd
145 files changed
+5506 -569

No files matched your search

+6 -1
View File
@@ -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
+8
View File
@@ -86,6 +86,10 @@ 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-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"
push:
@@ -114,6 +118,10 @@ 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-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"
workflow_dispatch:
+78
View File
@@ -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"
@@ -7695,3 +7695,31 @@ 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"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "{} Titel werden aus dieser Playlist entfernt."
@@ -3350,3 +3350,31 @@ 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"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "{} tracks will be removed from this playlist."
@@ -7701,3 +7701,31 @@ 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"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "Se quitarán {} pistas de esta playlist."
@@ -7693,3 +7693,31 @@ 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"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "{} titres seront retirés de cette playlist."
@@ -7676,3 +7676,31 @@ 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 "システムの壁紙"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "{} 曲をこのプレイリストから削除します。"
@@ -7683,3 +7683,31 @@ 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"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "{} nummers worden uit deze afspeellijst verwijderd."
@@ -7693,3 +7693,31 @@ 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"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "{} faixas serão removidas desta playlist."
@@ -7714,3 +7714,31 @@ 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 "Обои системы"
#: crates/qbz-qt/qml/views/PlaylistView.qml
msgid "{} tracks will be removed from this playlist."
msgstr "Из этого плейлиста будет удалено {} треков."
+5
View File
@@ -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"
+7
View File
@@ -574,6 +574,11 @@ fn main() {
"qml/controls/PlaylistImportModal.qml",
"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",
@@ -597,6 +602,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",
@@ -654,6 +660,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",
@@ -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
@@ -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
+120 -5
View File
@@ -34,6 +34,98 @@ 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
// 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
if (cmRoot.pairHovered)
leaveTimer.stop()
else
leaveTimer.restart()
}
Timer {
id: leaveTimer
interval: 350
onTriggered: {
if (cmRoot.subOpen && !cmRoot.pairHovered) {
cmRoot.closeSubmenu()
cmRoot.close()
}
}
}
onClosed: {
cmRoot._hotRow = null
closeSubmenu()
}
QbzTheme { id: theme }
Repeater {
@@ -43,14 +135,19 @@ 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)
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
@@ -80,7 +177,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 +185,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 +206,28 @@ 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) {
if (cmRoot._hotRow === row)
cmRoot._hotRow = null
return
}
cmRoot._hotRow = row
if (row.hasSub)
cmRoot.openSubmenu(row)
else if (cmRoot.subOpen)
cmRoot.closeSubmenu()
}
}
}
}
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -180,7 +182,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 +219,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.
@@ -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 {
@@ -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)
@@ -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.elevatedHoverFill : theme.elevatedFill
opacity: root.btnEnabled ? 1.0 : 0.4
Row {
@@ -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
+22 -3
View File
@@ -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
@@ -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
@@ -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
@@ -410,7 +415,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
@@ -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
@@ -360,6 +366,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
@@ -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
@@ -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"
@@ -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
@@ -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()
})
}
}
@@ -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,
@@ -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
@@ -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
@@ -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
}
}
+61 -1
View File
@@ -19,6 +19,7 @@
// detail, queue) is this control.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
@@ -100,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
@@ -110,6 +125,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 +285,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
}
+1 -1
View File
@@ -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 {
@@ -47,7 +47,7 @@ Rectangle {
height: 44
radius: 8
color: theme.surfaceElevated
color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated)
Row {
anchors.fill: parent
@@ -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
+28
View File
@@ -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: <model>.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
}
}
@@ -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: <model>.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
}
}
@@ -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: <model>.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 }
}
}
@@ -0,0 +1,165 @@
// 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: ""
/// 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
/// 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
// 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
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 {
visible: root.showGlyph
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.showGlyph ? root.glyphSize + 7 : 0)
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()
root.escaped()
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 !== "" ? root.clearSize : 0
height: parent.height
visible: input.text !== ""
Rectangle {
anchors.centerIn: parent
width: root.clearSize
height: root.clearSize
radius: root.clearSize / 2
color: clearArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
name: "x"
width: Math.round(root.clearSize * 0.55)
height: Math.round(root.clearSize * 0.55)
anchors.centerIn: parent
tintName: clearArea.containsMouse ? "textPrimary" : "muted"
}
MouseArea {
id: clearArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.clear()
input.forceActiveFocus()
}
}
}
}
}
+2 -2
View File
@@ -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
+7 -1
View File
@@ -28,12 +28,18 @@ 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
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
+1 -1
View File
@@ -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
@@ -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 }
}
+6 -4
View File
@@ -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
@@ -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.elevatedHoverFill : theme.elevatedFill
opacity: enabled ? 1.0 : 0.4
activeFocusOnTab: enabled
Accessible.role: Accessible.Button
@@ -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 || "")
+17 -3
View File
@@ -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 }
}
@@ -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
+9 -2
View File
@@ -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 }
}
+78 -51
View File
@@ -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 `<a>`
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
@@ -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 '<a href="' + href + '"><font color="'
+ hex(hot ? theme.textPrimary : theme.textMuted) + '">' + esc(name) + '</font></a>'
function span(i) {
var s = artistLine.spans[i]
var hot = artistLine.hotSpan === i && s.href !== ""
return '<font color="' + hex(hot ? theme.textPrimary : theme.textMuted) + '">' + esc(s.name) + '</font>'
}
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)
}
@@ -1275,43 +1323,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 +1450,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 +1480,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
@@ -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"
@@ -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
@@ -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()
}
@@ -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
@@ -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
+3 -3
View File
@@ -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
+53
View File
@@ -121,6 +121,59 @@ Rectangle {
opacity: root.veilStrength
}
// 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
WallpaperField {
anchors.fill: parent
visible: root.wallpaperModeOn
source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : ""
hostWindow: root.hostWindow
blur: 0.75
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: root.wallpaperBlurOn && QbzPlayer.npPlaying
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
+1 -1
View File
@@ -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
+41
View File
@@ -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()
}
}
@@ -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
+58 -17
View File
@@ -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"
@@ -942,15 +945,39 @@ 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
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 }
@@ -976,9 +1003,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
@@ -1001,7 +1032,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 +1080,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 +1114,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 +1124,14 @@ 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
id: clearCross
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
@@ -1230,7 +1271,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
@@ -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
@@ -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()
}
}
+1 -1
View File
@@ -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
+15 -24
View File
@@ -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") {
@@ -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
+93 -21
View File
@@ -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
@@ -316,6 +341,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
@@ -429,7 +489,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 {
@@ -556,19 +616,16 @@ 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
// (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" })
}
@@ -609,6 +666,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)
}
}
}
@@ -893,7 +952,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 +966,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
@@ -991,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.
@@ -1132,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
@@ -1271,7 +1343,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)
+1 -1
View File
@@ -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
+30 -60
View File
@@ -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.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.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()
}
}
}
+138
View File
@@ -0,0 +1,138 @@
// WallpaperField — the desktop wallpaper behind the window (2026-09-14): the
// 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 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
/// 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
/// 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.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.isWayland)
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.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.isWayland ? root.compositorRect.x : root.hostWindow.x) - Screen.virtualX
readonly property real offY: !root.positionKnown
? Math.max(0, (root.screenH - root.height) / 2)
: (root.isWayland ? root.compositorRect.y : root.hostWindow.y) - Screen.virtualY
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: 64
}
Rectangle {
anchors.fill: parent
color: "#000000"
opacity: root.dim
}
}
+16 -1
View File
@@ -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):
@@ -149,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 -----------
//
+47 -61
View File
@@ -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
@@ -1560,43 +1562,24 @@ Rectangle {
- 168 - 30 - 2 * 16)
height: 1
}
Rectangle {
// Track filter (QbzSearchField: Escape clears + blurs,
// clear cross). The keyboard survives the tape rebuild
// because the tape lives on a QbzArrayModel.
QbzSearchField {
id: trackSearch
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 }
// 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
}
}
}
@@ -1608,7 +1591,8 @@ Rectangle {
height: 30
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: selectToggleArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
color: selectToggleArea.containsMouse ? theme.elevatedHoverFill
: theme.elevatedFill
border.width: 1
border.color: root.multiSelect ? theme.accent : theme.borderSubtle
QbzIcon {
@@ -2060,7 +2044,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
@@ -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
+13 -23
View File
@@ -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) }
}
}
@@ -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
+1 -1
View File
@@ -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 {
+136 -41
View File
@@ -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")
@@ -1339,7 +1402,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 {
@@ -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 =================================
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+38 -33
View File
@@ -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 || ""))
@@ -722,39 +731,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 {
@@ -765,8 +749,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 {
@@ -898,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) }
}
@@ -1100,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 = ({})
}
}
}
@@ -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 {
+2 -2
View File
@@ -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) }
}
@@ -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 {
+109 -15
View File
@@ -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)
@@ -258,6 +292,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,10 +346,17 @@ 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",
"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
@@ -315,6 +391,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) {
@@ -447,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() {
@@ -760,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)
@@ -769,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, {
@@ -786,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 !== ""
@@ -885,7 +979,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 +1002,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 {
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
@@ -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 {
@@ -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.elevatedHoverFill : theme.elevatedFill
QbzIcon {
name: root.iconName
width: 15
+1 -1
View File
@@ -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.
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more