diff --git a/.github/workflows/build-qt-linux.yml b/.github/workflows/build-qt-linux.yml index 7175e1100..f1944d246 100644 --- a/.github/workflows/build-qt-linux.yml +++ b/.github/workflows/build-qt-linux.yml @@ -221,7 +221,12 @@ jobs: # The desktop file goes in under the MPRIS DesktopEntry basename # (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the # media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png. - cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop + # linuxdeploy looks the Exec name up under AppDir/usr/bin, so the + # AppImage entry keeps the bare name; the deb/rpm install the source + # entry as-is, with the absolute Exec KWin matches against the + # running binary (packaging/linux/qbz.desktop). + sed 's|^Exec=/usr/bin/qbz |Exec=qbz |' packaging/linux/qbz.desktop > com.blitzfc.qbz.desktop + grep -q '^Exec=qbz ' com.blitzfc.qbz.desktop cp packaging/icons/256x256.png qbz.png ./linuxdeploy --appimage-extract-and-run --appdir AppDir \ -e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index 2d42b924d..106774c6b 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -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: diff --git a/crates/Cargo.lock b/crates/Cargo.lock index db999b796..db7bb6656 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -1458,6 +1458,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dst-decoder" version = "0.1.2" @@ -4494,6 +4500,8 @@ dependencies = [ "url", "urlencoding", "uuid 1.26.0", + "wayland-client", + "wayland-protocols-plasma", "windows-sys 0.61.2", "winresource", "zbus 5.19.0", @@ -6812,6 +6820,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 32f2efadf..9350ec72b 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7695,3 +7695,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." diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 3d21c3910..89d4a7dfb 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3350,3 +3350,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." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 5179bf451..71487a0ae 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7701,3 +7701,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." diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index beafc9386..cf8555f94 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7693,3 +7693,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." diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 01603bf64..1ff5a9aaf 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7676,3 +7676,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 "{} 曲をこのプレイリストから削除します。" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 9a53d4814..442e84d83 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7683,3 +7683,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." diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index e64cdb11f..a862cf6c7 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7693,3 +7693,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." diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index aea8cc68e..e17cb65d9 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7714,3 +7714,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 "Из этого плейлиста будет удалено {} треков." diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index bff6c3235..9bff83860 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -152,6 +152,11 @@ ksni = { version = "0.3", default-features = false, features = ["blocking", "asy # Session-bus single-instance guard. Keep the same async-io executor as ksni; # enabling zbus/tokio would poison the graph-wide executor choice. zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } +# The "Wallpaper" background reads this window's place on a Plasma Wayland +# desktop through org_kde_plasma_window_management (wallpaper_wayland_qt.rs) +# on its OWN client connection; Qt's display is never shared. +wayland-client = "0.31" +wayland-protocols-plasma = { version = "0.3", features = ["client"] } [build-dependencies] cxx-qt-build = "0.7" diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 366377303..454c413de 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -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", diff --git a/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml b/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml index ec794fd53..a8a09e491 100644 --- a/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml +++ b/crates/qbz-qt/qml/controls/AddToMixtapeModal.qml @@ -650,7 +650,7 @@ Item { height: 38 radius: theme.radiusSm - color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml index d3210c09e..1051120cc 100644 --- a/crates/qbz-qt/qml/controls/BrowseGenreButton.qml +++ b/crates/qbz-qt/qml/controls/BrowseGenreButton.qml @@ -77,8 +77,8 @@ Rectangle { // .slint:77 — the same chip in three mounts). Accent and hover keep theirs. color: root.active ? theme.accent : genreArea.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill Row { id: genreRow diff --git a/crates/qbz-qt/qml/controls/CardMenu.qml b/crates/qbz-qt/qml/controls/CardMenu.qml index fb78981f1..c921750ce 100644 --- a/crates/qbz-qt/qml/controls/CardMenu.qml +++ b/crates/qbz-qt/qml/controls/CardMenu.qml @@ -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() + } } } } diff --git a/crates/qbz-qt/qml/controls/DiscMetaModal.qml b/crates/qbz-qt/qml/controls/DiscMetaModal.qml index ac6683f77..79b74c761 100644 --- a/crates/qbz-qt/qml/controls/DiscMetaModal.qml +++ b/crates/qbz-qt/qml/controls/DiscMetaModal.qml @@ -162,7 +162,7 @@ Item { height: 30 radius: 6 color: active ? theme.accent - : (pArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (pArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) Text { id: pLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml index 12dfffeea..c9b5852ec 100644 --- a/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml +++ b/crates/qbz-qt/qml/controls/DiscoverConfigModal.qml @@ -408,7 +408,7 @@ Item { width: refreshRow.width + 24 height: 34 radius: theme.radiusSm - color: refreshArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: refreshArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Row { @@ -481,7 +481,7 @@ Item { width: footerRow.width + 24 height: 34 radius: theme.radiusSm - color: footerArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: footerArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/FolderEditPanel.qml b/crates/qbz-qt/qml/controls/FolderEditPanel.qml index b95868d20..b564c3846 100644 --- a/crates/qbz-qt/qml/controls/FolderEditPanel.qml +++ b/crates/qbz-qt/qml/controls/FolderEditPanel.qml @@ -95,6 +95,8 @@ Item { root.draftPreset = root.doc.iconPreset || "folder" root.draftColor = root.doc.iconColor || "" root.draftHidden = root.doc.isHidden === true + // The box as well as the draft (QbzLineEdit.reset()). + nameField.reset() } /// Focus the name field. Split out so the host can call it AFTER the panel @@ -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. diff --git a/crates/qbz-qt/qml/controls/FolderModals.qml b/crates/qbz-qt/qml/controls/FolderModals.qml index b25432249..d49064a6a 100644 --- a/crates/qbz-qt/qml/controls/FolderModals.qml +++ b/crates/qbz-qt/qml/controls/FolderModals.qml @@ -103,6 +103,7 @@ Item { if (root.createOpen) { // Its OWN draft, reset on its OWN open transition. root.draftCreateName = "" + createField.reset("") createScope.forceActiveFocus() createField.focusField() } else { diff --git a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml index de599ff9d..769f97410 100644 --- a/crates/qbz-qt/qml/controls/GenreFilterPopup.qml +++ b/crates/qbz-qt/qml/controls/GenreFilterPopup.qml @@ -415,7 +415,7 @@ Item { height: 38 radius: 6 color: chip.modelData.selected ? theme.accent - : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + : chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { anchors.fill: parent @@ -508,7 +508,7 @@ Item { radius: 6 opacity: root.liveCount > 0 ? 1.0 : 0.5 color: clearArea.containsMouse && root.liveCount > 0 - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated Text { anchors.centerIn: parent text: QbzSession.tr("Clear filter", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/controls/IconTextButton.qml b/crates/qbz-qt/qml/controls/IconTextButton.qml index ae4e4bc42..264bfeb57 100644 --- a/crates/qbz-qt/qml/controls/IconTextButton.qml +++ b/crates/qbz-qt/qml/controls/IconTextButton.qml @@ -36,7 +36,7 @@ Rectangle { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: hoverArea.containsMouse && root.btnEnabled ? theme.surfaceHover : theme.surfaceElevated + color: hoverArea.containsMouse && root.btnEnabled ? theme.elevatedHoverFill : theme.elevatedFill opacity: root.btnEnabled ? 1.0 : 0.4 Row { diff --git a/crates/qbz-qt/qml/controls/LinkResolverModal.qml b/crates/qbz-qt/qml/controls/LinkResolverModal.qml index 6d328a7a5..77133b299 100644 --- a/crates/qbz-qt/qml/controls/LinkResolverModal.qml +++ b/crates/qbz-qt/qml/controls/LinkResolverModal.qml @@ -25,6 +25,9 @@ Item { onVisibleChanged: { if (visible) { + // Rust cleared `url` before opening; sync the box regardless of + // where focus sat at the last close (QbzLineEdit.reset()). + urlInput.reset(QbzLink.url) urlInput.focusField() } else { // Return focus only if it still belongs to this modal. A search diff --git a/crates/qbz-qt/qml/controls/MyQbzModals.qml b/crates/qbz-qt/qml/controls/MyQbzModals.qml index 139236af4..29b0facb7 100644 --- a/crates/qbz-qt/qml/controls/MyQbzModals.qml +++ b/crates/qbz-qt/qml/controls/MyQbzModals.qml @@ -76,10 +76,19 @@ Item { property string name: "" readonly property bool canCreate: createPanel.name !== "" && !createPanel.creating - // A fresh open starts with an empty field. + // A fresh open starts with an empty field — reset on the OPEN edge, + // never the close edge: at close the box still holds focus for a + // frame, its focus-gated re-seed skips, and the next open found the + // draft already "" with the old text still in the box (the + // dirty-form report of 2026-09-14). `reset()` syncs the box itself. Connections { target: root - function onCreateOpenChanged() { createPanel.name = "" } + function onCreateOpenChanged() { + if (!root.createOpen) + return + createPanel.name = "" + createNameField.reset("") + } } Rectangle { @@ -166,6 +175,7 @@ Item { font.letterSpacing: 1.5 } QbzLineEdit { + id: createNameField width: parent.width height: 34 text: createPanel.name @@ -255,10 +265,17 @@ Item { // frame, and then `open` never changes and the prefill would be stale. // Keying it on the whole document instead would clobber what the user // is typing every time an unrelated field (`busy`) flips. - readonly property string editKey: editPanel.mode + "|" + (root.editDoc.name || "") + // The open flag rides along so a reopen on the SAME collection + // re-seeds too (a cancelled rename left its draft behind). + readonly property string editKey: (root.editOpen ? "1" : "0") + "|" + + editPanel.mode + "|" + (root.editDoc.name || "") onEditKeyChanged: { editPanel.draftName = root.editDoc.name || "" editPanel.draftDescription = root.editDoc.description || "" + if (root.editOpen) { + renameField.reset() + descriptionField.reset() + } } Rectangle { @@ -338,6 +355,7 @@ Item { // --- Body: rename QbzLineEdit { + id: renameField visible: editPanel.isRename width: parent.width height: visible ? 34 : 0 @@ -350,6 +368,7 @@ Item { // --- Body: description QbzTextArea { + id: descriptionField visible: editPanel.isDescription width: parent.width // A FLAT 96, never `visible ? 96 : 0`: a Column does not diff --git a/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml b/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml index 6eb67ac9a..81887caca 100644 --- a/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml +++ b/crates/qbz-qt/qml/controls/OfflineCacheChoiceModal.qml @@ -128,7 +128,7 @@ Item { width: parent.width height: allCol.implicitHeight + 24 radius: theme.radiusSm - color: allArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: allArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: allArea.containsMouse ? theme.accent : theme.borderSubtle Row { @@ -178,7 +178,7 @@ Item { width: parent.width height: missingCol.implicitHeight + 24 radius: theme.radiusSm - color: missingArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: missingArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: missingArea.containsMouse ? theme.accent : theme.borderSubtle Row { @@ -229,7 +229,7 @@ Item { width: cancelLabel.implicitWidth + 32 height: 36 radius: theme.radiusSm - color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: cancelLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml index 0cbef2fe1..d2eaba1cd 100644 --- a/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistCreateModal.qml @@ -103,6 +103,10 @@ Item { root.draftIsPublic = false // Offline forces it ON and locks it (reference :184-216). root.draftOfflineOnly = root.offlineLocked + // The boxes too (QbzLineEdit.reset()): "" is also what the + // drafts held after the last cancel, so nothing above re-seeds. + nameField.reset("") + descriptionField.reset("") scope.forceActiveFocus() nameField.focusField() } else { @@ -274,6 +278,7 @@ Item { font.weight: theme.weightMedium } QbzTextArea { + id: descriptionField width: parent.width height: 80 text: root.draftDescription @@ -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 diff --git a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml index 55e7cf195..449518fd5 100644 --- a/crates/qbz-qt/qml/controls/PlaylistEditModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistEditModal.qml @@ -135,6 +135,11 @@ Item { root.draftDescription = root.doc.description || "" root.draftOfflineOnly = root.doc.offlineOnly === true root.draftFolderId = root.doc.folderId || "" + // The boxes, not just the drafts: a seed equal to the previous + // one changes nothing above, and a box that was still focused + // at close never re-seeded (QbzLineEdit.reset()). + nameField.reset() + descriptionField.reset() scope.forceActiveFocus() nameField.focusField() } @@ -332,6 +337,7 @@ Item { font.pixelSize: theme.fontLegal } QbzTextArea { + id: descriptionField width: parent.width height: 90 text: root.draftDescription @@ -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 diff --git a/crates/qbz-qt/qml/controls/PlaylistImportModal.qml b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml index 619b985eb..b5d49ec16 100644 --- a/crates/qbz-qt/qml/controls/PlaylistImportModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistImportModal.qml @@ -462,7 +462,7 @@ Item { height: 36 radius: theme.radiusSm color: (pickArea.containsMouse && !root.loading) - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: root.loading ? 0.5 : 1.0 Text { id: pickLabel diff --git a/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml index c283d747a..bafcce8c3 100644 --- a/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml +++ b/crates/qbz-qt/qml/controls/PlaylistPickerModal.qml @@ -317,7 +317,7 @@ Item { width: 20 height: 20 radius: 10 - color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent name: "x" diff --git a/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml b/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml index 8369182a3..0396ba82b 100644 --- a/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml +++ b/crates/qbz-qt/qml/controls/PlaylistTagFilterButton.qml @@ -31,8 +31,8 @@ Rectangle { // Translucent at rest under the dynamic background, like every other chip // in this toolbar band (PlaylistTagFilter.slint:77). color: tagArea.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill QbzFilterTip { id: tagTip diff --git a/crates/qbz-qt/qml/controls/QbzArrayModel.qml b/crates/qbz-qt/qml/controls/QbzArrayModel.qml new file mode 100644 index 000000000..b5fe9677a --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzArrayModel.qml @@ -0,0 +1,87 @@ +// QbzArrayModel — a DelegateModel over a JS array that a view can SWAP +// (2026-09-13). Assigning a fresh array straight to `ListView.model` after +// the view has completed has two side effects in Qt 6.11 +// (qquickitemview.cpp, QQuickItemViewPrivate::connectModel): +// +// 1. `currentIndex` is forced to 0 and updateCurrent() calls +// setFocus(true) on delegate 0. The view is a focus scope, so a search +// box living in its header loses the keyboard on every rebuild — the +// album track filter lost focus on each keystroke (qt.quick.focus +// trace: "focus trackCell in scope pageFlick", activeFocusItem +// TextInput -> trackCell). A `currentIndex: -1` on the view does not +// survive the assignment. +// 2. setModel() clears the view and resets contentY to the content start. +// +// Swapping the array HERE instead reaches the view as remove+insert changes: +// `currentIndex: -1` stays honoured (nothing is ever focused) and the view +// keeps its rows. ListView re-seats the inserted rows around the viewport, +// which drifts the content origin, so the viewport is re-expressed relative +// to that origin (0 = the header's top edge) before the change and restored +// once it is laid out. A swap that replaces the page calls `scrollToTop()`. +// +// Probe that measured both behaviours against Qt 6.11.2: +// scripts/qml-tests/tst_array_model.qml. + +import QtQuick +import QtQml.Models + +DelegateModel { + id: swap + + /// The rows. Replace the array freely; the view keeps its place. + property var rows: [] + /// The ListView (or GridView) this model feeds — needed for the restore. + property Item view: null + + model: rows + + property bool _live: false + property int _rowsBefore: 0 + property real _offset: 0 + property int _epoch: 0 + property bool _toTop: false + + Component.onCompleted: { + swap._live = true + swap._rowsBefore = swap.count + } + + onModelChanged: { + if (!swap._live || !swap.view) + return + var had = swap._rowsBefore + swap._rowsBefore = swap.count + // Nothing to keep: the page had no rows (first document, or the + // empty document a detail publishes while the next one loads), or a + // page replacement already asked for the top. + if (had === 0 || swap._toTop) + return + // Before the pending remove+insert is laid out: how far the viewport + // sits below the content origin. + swap._offset = swap.view.contentY - swap.view.originY + var epoch = ++swap._epoch + Qt.callLater(function () { + if (epoch !== swap._epoch || !swap.view) + return + swap.view.forceLayout() + var minY = swap.view.originY + var maxY = minY + Math.max(0, swap.view.contentHeight - swap.view.height) + swap.view.contentY = Math.max(minY, Math.min(minY + swap._offset, maxY)) + }) + } + + /// A swap that replaces the page (a different document): the viewport + /// goes to the top once the new rows are laid out. Works whether it is + /// called before or after the `rows` assignment in the same turn. + function scrollToTop() { + swap._toTop = true + var epoch = ++swap._epoch + Qt.callLater(function () { + swap._toTop = false + if (epoch !== swap._epoch || !swap.view) + return + swap.view.forceLayout() + swap.view.positionViewAtBeginning() + }) + } +} diff --git a/crates/qbz-qt/qml/controls/QbzCircleAction.qml b/crates/qbz-qt/qml/controls/QbzCircleAction.qml index 466b326c0..c9855cbb0 100644 --- a/crates/qbz-qt/qml/controls/QbzCircleAction.qml +++ b/crates/qbz-qt/qml/controls/QbzCircleAction.qml @@ -111,8 +111,8 @@ Rectangle { // (CircleAction.slint:53-55). The overlay variant above sits on // artwork and keeps its white scale. : ((cbArea.containsMouse || active) - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated))) + ? theme.elevatedHoverFill + : theme.elevatedFill)) border.width: primary ? 0 : 1.5 border.color: overlay ? "#ccffffff" : theme.borderStrong // A loading button keeps FULL opacity — it is working, not unavailable, diff --git a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml index e929f344a..4bbecf516 100644 --- a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml +++ b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml @@ -220,7 +220,7 @@ Item { width: cancelLbl.implicitWidth + 32 height: root.kioskHost ? 44 : 36 radius: theme.radiusSm - color: cancelArea.containsMouse ? theme.surfaceHover + color: cancelArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated activeFocusOnTab: root.opened border.width: activeFocus ? 2 : 0 diff --git a/crates/qbz-qt/qml/controls/QbzContextMenu.qml b/crates/qbz-qt/qml/controls/QbzContextMenu.qml index ea97ed870..14f2b2890 100644 --- a/crates/qbz-qt/qml/controls/QbzContextMenu.qml +++ b/crates/qbz-qt/qml/controls/QbzContextMenu.qml @@ -49,11 +49,16 @@ Popup { QbzTheme { id: theme } + /// The pointer is over the panel (padding included). CardMenu's + /// submenus read it on both menus to decide when leaving closes them. + readonly property bool menuHovered: panelHover.hovered + background: Rectangle { color: theme.surfaceMain radius: theme.radiusSm border.width: 1 border.color: theme.borderMuted + HoverHandler { id: panelHover } } implicitHeight: col.implicitHeight + topPadding + bottomPadding height: kioskHost && parent ? Math.min(implicitHeight, parent.height - 16) : implicitHeight diff --git a/crates/qbz-qt/qml/controls/QbzKeyedModel.qml b/crates/qbz-qt/qml/controls/QbzKeyedModel.qml new file mode 100644 index 000000000..e1ac683a4 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzKeyedModel.qml @@ -0,0 +1,405 @@ +// QbzKeyedModel — a list model that CHANGES instead of being replaced +// (2026-09-14). +// +// Handing an item view a new JS array is a model swap: QQuickItemView:: +// setModel() throws every delegate away, resets the scroll offset and builds +// the page again, so one album leaving the Library grid used to rebuild the +// whole grid at the top with every cover fading back in. This model takes the +// same arrays (`rows`) but tells the view only what changed: rows whose key +// disappeared are REMOVED, new keys are INSERTED where they belong, keys that +// changed places are MOVED, and a row whose data changed is UPDATED in place. +// The view keeps its delegates, its scroll offset and its covers, and with the +// row transitions next door (QbzRowAdd / QbzRowRemove / QbzRowDisplaced) the +// leaving row fades out while its neighbours slide into the gap. +// +// WHAT THE VIEW SEES. Each model row carries two roles, `rowKey` and `rowRev`, +// never the row itself. A delegate looks its row up by key: +// +// required property string rowKey +// required property int rowRev +// readonly property var modelData: keyedModel.row(rowKey, rowRev) +// ListView.onReused: { opacity = 1; scale = 1 } // GridView.onReused too +// +// The delegate reads the SAME JS object the host derived (no copy into a +// ListModel map, no conversion per read, and an in-place mutation of a row is +// visible to delegates built later exactly as it was with a plain array). +// `rowRev` is bumped when a row's data changed, which re-runs that binding and +// hands the delegate its new object; an unchanged row keeps its delegate +// untouched. A removed row stays readable (a small graveyard) because its +// delegate may still be fading out: Qt does not re-notify a removed item's +// roles (QQmlDMAbstractItemModelData reads its cache once the index is -1), so +// its binding keeps the value it had. The `onReused` restore matters because +// a transition cut short (a fast flick during an insert) can pool a delegate +// half faded, and the pool hands it back exactly as it was left. +// +// SCOPE. A change of `scope` (another tab, sort, search, filter set) is not a +// reconcile — the rows are replaced wholesale, WITHOUT animation (`animate` +// drops for the reset and the registered `views` lay out synchronously, so no +// transition ever sees it), which is what a tab switch has always looked like. +// Inside one scope everything reconciles and animates. +// +// COST. Keys are compared as strings in one pass; unchanged row objects are +// skipped by identity; a new object for an existing key is compared field by +// field (`ignoredKeys` excluded) and only a real difference bumps its rev. A +// reorder is applied with the fewest moves (the longest increasing run stays +// put); past `maxMoves` a reset is both cheaper and calmer than a cascade of +// slides. The reconcile is deferred with Qt.callLater, so a derive that +// changes `rows` and `scope` together lands as one operation. + +import QtQuick + +ListModel { + id: root + + /// The rows to show, in order. Assign a new array whenever the source + /// changes; the array itself does not have to be stable. + property var rows: [] + /// A row's identity. Equal keys are the same entity; repeats are told + /// apart by occurrence (the second "x" is "x#1"). + property var keyOf: function (row) { return row.id } + /// Another scope replaces the rows without animation. + property string scope: "" + /// Row fields whose change is not a change (positional caches). + property var ignoredKeys: [] + /// While true, `rows` changes wait and apply when it clears. + property bool paused: false + /// Item views bound to this model: laid out synchronously around a reset + /// so no add/remove transition runs for it. + property var views: [] + /// Beyond this many moves in one reconcile, reset instead. + property int maxMoves: 48 + + /// Bind each row transition's `enabled` to this. + readonly property bool animate: root._animate + /// The array the model currently reflects, index for index — what an + /// index-based reader (an artwork window report) must use. + readonly property var published: root._published + + /// After every applied change; `reset` is true for a wholesale replace. + signal reconciled(bool reset) + + property bool _animate: true + property var _published: [] + // Registries live on one never-reassigned object so that reading them + // inside a delegate binding does not subscribe the binding to anything. + property var _state: ({ + "initialized": false, + "scope": "", + "keys": [], + "byKey": new Map(), + "revs": new Map(), + "graveyard": new Map(), + "rev": 0 + }) + + onRowsChanged: Qt.callLater(root.sync) + onScopeChanged: Qt.callLater(root.sync) + onPausedChanged: if (!root.paused) Qt.callLater(root.sync) + Component.onCompleted: Qt.callLater(root.sync) + + /// The row behind `key` — also for a row that just left, while its + /// delegate fades out. `rev` is only a binding dependency. + function row(key, rev) { + var st = root._state + var r = st.byKey.get(key) + if (r === undefined) + r = st.graveyard.get(key) + return r === undefined ? ({}) : r + } + + /// Model index of `key`, or -1. + function indexOfKey(key) { + return root._state.keys.indexOf(key) + } + + /// Re-hand `key`'s row to its delegate after the host mutated that row + /// in place and a mounted delegate must re-read it. + function touch(key) { + var st = root._state + var i = st.keys.indexOf(key) + if (i < 0) + return + var rev = ++st.rev + st.revs.set(key, rev) + root.setProperty(i, "rowRev", rev) + } + + function keysFor(rows) { + var seen = new Map() + var out = new Array(rows.length) + for (var i = 0; i < rows.length; i++) { + var k = String(root.keyOf(rows[i])) + var n = seen.get(k) || 0 + seen.set(k, n + 1) + out[i] = n === 0 ? k : k + "#" + n + } + return out + } + + /// Field-by-field equality, `ignoredKeys` excluded. Arrays of scalars are + /// compared element-wise; anything deeper falls back to JSON. + function sameRow(a, b) { + if (a === b) + return true + if (!a || !b || typeof a !== "object" || typeof b !== "object") + return false + var ignored = root.ignoredKeys + var k + var counted = 0 + for (k in a) { + if (ignored.indexOf(k) >= 0) + continue + counted++ + if (!(k in b) || !root._sameValue(a[k], b[k])) + return false + } + for (k in b) { + if (ignored.indexOf(k) >= 0) + continue + counted-- + } + return counted === 0 + } + + function _sameValue(x, y) { + if (x === y) + return true + if (x === null || y === null || typeof x !== "object" || typeof y !== "object") + return false + if (Array.isArray(x) && Array.isArray(y) && x.length === y.length) { + for (var i = 0; i < x.length; i++) { + if (x[i] !== y[i]) { + if (typeof x[i] !== "object" || typeof y[i] !== "object") + return false + return JSON.stringify(x) === JSON.stringify(y) + } + } + return true + } + return JSON.stringify(x) === JSON.stringify(y) + } + + function sync() { + if (root.paused) + return + var next = root.rows || [] + var keys = root.keysFor(next) + var st = root._state + if (!st.initialized || st.scope !== root.scope) { + root._reset(next, keys) + return + } + var old = st.keys + var sameOrder = old.length === keys.length + for (var s = 0; sameOrder && s < keys.length; s++) + sameOrder = old[s] === keys[s] + if (!sameOrder && !root._restructure(next, keys)) + return + var changed = sameOrder ? root._update(next, keys) : true + root._published = next + if (changed) + root.reconciled(false) + } + + function _reset(next, keys) { + var st = root._state + var had = root.count > 0 + root._animate = false + st.byKey = new Map() + st.revs = new Map() + st.graveyard = new Map() + var batch = new Array(next.length) + for (var i = 0; i < next.length; i++) { + st.byKey.set(keys[i], next[i]) + batch[i] = { "rowKey": keys[i], "rowRev": 0 } + } + root.clear() + if (batch.length > 0) + root.append(batch) + st.keys = keys + st.scope = root.scope + st.initialized = true + root._published = next + root._layoutViews(had) + root._animate = true + root.reconciled(true) + } + + /// Apply the reset now, with transitions off, and — when it replaced + /// rows the user was looking at — start from the top, as the model swap + /// it replaces did. (A ListView lands there by itself; a GridView keeps + /// its offset clamped into the new, shorter content.) + function _layoutViews(toTop) { + var vs = root.views || [] + for (var i = 0; i < vs.length; i++) { + if (!vs[i] || typeof vs[i].forceLayout !== "function") + continue + vs[i].forceLayout() + if (toTop && typeof vs[i].positionViewAtBeginning === "function") + vs[i].positionViewAtBeginning() + } + } + + /// Removals, then inserts and moves; false when it fell back to a reset. + function _restructure(next, keys) { + var st = root._state + var wanted = new Map() + for (var w = 0; w < keys.length; w++) + wanted.set(keys[w], w) + + // Survivors in their new order, and the longest run of them that is + // already in order: those never move. + var current = st.keys.slice() + var positions = [] + var survivors = [] + for (var c = 0; c < current.length; c++) { + if (wanted.has(current[c])) { + survivors.push(current[c]) + positions.push(wanted.get(current[c])) + } + } + var steady = root._longestIncreasing(positions, survivors) + if (survivors.length - steady.size > root.maxMoves) { + root._reset(next, keys) + return false + } + + // 1. Removals, bottom-up, contiguous runs in one call. + var i = current.length - 1 + while (i >= 0) { + if (wanted.has(current[i])) { + i-- + continue + } + var end = i + while (i - 1 >= 0 && !wanted.has(current[i - 1])) + i-- + if (st.graveyard.size > 512) + st.graveyard = new Map() + for (var g = i; g <= end; g++) { + st.graveyard.set(current[g], st.byKey.get(current[g])) + st.byKey.delete(current[g]) + st.revs.delete(current[g]) + } + root.remove(i, end - i + 1) + current.splice(i, end - i + 1) + i-- + } + + // 2. Inserts (contiguous runs) and moves, walking the new order. + var p = 0 + var guard = 3 * keys.length + 16 + while (p < keys.length) { + if (--guard < 0) { + root._reset(next, keys) + return false + } + if (current[p] === keys[p]) { + p++ + continue + } + var key = keys[p] + if (!st.byKey.has(key)) { + var runEnd = p + while (runEnd + 1 < keys.length && !st.byKey.has(keys[runEnd + 1])) + runEnd++ + var batch = [] + for (var r = p; r <= runEnd; r++) { + batch.push({ "rowKey": keys[r], "rowRev": 0 }) + st.byKey.set(keys[r], next[r]) + } + root.insert(p, batch) + Array.prototype.splice.apply(current, [p, 0].concat(keys.slice(p, runEnd + 1))) + p = runEnd + 1 + continue + } + if (!steady.has(key)) { + // A row that has to move: bring it here. + var from = current.indexOf(key, p + 1) + if (from < 0) { + root._reset(next, keys) + return false + } + root.move(from, p, 1) + current.splice(p, 0, current.splice(from, 1)[0]) + p++ + continue + } + // `key` stays put, so the row sitting here is one that has to + // move further down: send it next to the first steady row that + // follows its place in the new order. + var floating = current[p] + var target = wanted.get(floating) + var anchor = -1 + for (var t = target + 1; t < keys.length; t++) { + if (steady.has(keys[t])) { + anchor = current.indexOf(keys[t]) + break + } + } + var to = anchor < 0 ? current.length - 1 : anchor - 1 + if (to <= p) { + root._reset(next, keys) + return false + } + root.move(p, to, 1) + current.splice(to, 0, current.splice(p, 1)[0]) + } + + st.keys = keys + root._update(next, keys) + return true + } + + /// Hand changed rows to their delegates; true when anything changed. + function _update(next, keys) { + var st = root._state + var changed = false + for (var i = 0; i < keys.length; i++) { + var key = keys[i] + var prev = st.byKey.get(key) + var row = next[i] + if (prev === row) + continue + st.byKey.set(key, row) + if (prev !== undefined && root.sameRow(prev, row)) + continue + var rev = ++st.rev + st.revs.set(key, rev) + root.setProperty(i, "rowRev", rev) + changed = true + } + return changed + } + + /// Keys (of `items`) on one longest strictly increasing run of + /// `positions` — O(n log n) patience sorting. + function _longestIncreasing(positions, items) { + var n = positions.length + var tails = [] + var tailIdx = [] + var prevIdx = new Array(n) + for (var i = 0; i < n; i++) { + var v = positions[i] + var lo = 0 + var hi = tails.length + while (lo < hi) { + var mid = (lo + hi) >> 1 + if (tails[mid] < v) + lo = mid + 1 + else + hi = mid + } + tails[lo] = v + tailIdx[lo] = i + prevIdx[i] = lo > 0 ? tailIdx[lo - 1] : -1 + } + var out = new Set() + var k = tails.length > 0 ? tailIdx[tails.length - 1] : -1 + while (k >= 0) { + out.add(items[k]) + k = prevIdx[k] + } + return out + } +} diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index f43ccf9c5..2122fd7e4 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -19,6 +19,7 @@ // detail, queue) is this control. import QtQuick +import QtQuick.Window import com.blitzfc.qbz import "../theme" @@ -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 } diff --git a/crates/qbz-qt/qml/controls/QbzLoadMore.qml b/crates/qbz-qt/qml/controls/QbzLoadMore.qml index 8a4329d75..733c1b2e8 100644 --- a/crates/qbz-qt/qml/controls/QbzLoadMore.qml +++ b/crates/qbz-qt/qml/controls/QbzLoadMore.qml @@ -181,7 +181,7 @@ Column { width: pillText.implicitWidth + 36 height: 32 radius: theme.radiusSm - color: pillArea.containsMouse ? theme.surfaceElevated : theme.surfaceCard + color: pillArea.containsMouse ? theme.surfaceElevated : (theme.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard) border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml b/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml index 4af9028ad..506f52168 100644 --- a/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml +++ b/crates/qbz-qt/qml/controls/QbzMultiSelectBar.qml @@ -47,7 +47,7 @@ Rectangle { height: 44 radius: 8 - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) Row { anchors.fill: parent diff --git a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml index 20b53ec69..9c676d5fc 100644 --- a/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml +++ b/crates/qbz-qt/qml/controls/QbzPrimaryButton.qml @@ -83,7 +83,10 @@ Rectangle { ? Qt.darker(theme.danger, 1.15) : theme.danger) : ((btnArea.containsMouse && root.btnEnabled) ? theme.accentHover : theme.accent) - Behavior on color { NumberAnimation { duration: 150 } } + // ColorAnimation, NOT NumberAnimation: a NumberAnimation on a colour + // interpolates it as a number and lands on BLACK, which is how every + // accent button in the modals went dark under the pointer (2026-09-14). + Behavior on color { ColorAnimation { duration: 150 } } Text { id: lbl diff --git a/crates/qbz-qt/qml/controls/QbzRowAdd.qml b/crates/qbz-qt/qml/controls/QbzRowAdd.qml new file mode 100644 index 000000000..71d36158f --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowAdd.qml @@ -0,0 +1,28 @@ +// QbzRowAdd — a row joining a QbzKeyedModel view fades in (a card also grows +// from a touch smaller) once its neighbours made room (2026-09-14). Assign +// it to the view's `add`, with `enabled: .animate`; a scope reset +// never runs it. + +import QtQuick + +Transition { + id: root + property int duration: 200 + /// 1.0 for list rows; cards read better growing from slightly smaller. + property real grow: 1.0 + + NumberAnimation { + property: "opacity" + from: 0.0 + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + from: root.grow + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml b/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml new file mode 100644 index 000000000..4cc694d6c --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowDisplaced.qml @@ -0,0 +1,36 @@ +// QbzRowDisplaced — the neighbours of an inserted, removed or moved row slide +// to their new place (QbzKeyedModel, 2026-09-14). Assign it to a ListView or +// GridView's `displaced` and `move`, with `enabled: .animate`. +// +// A row displaced while it was still fading in or out would otherwise keep +// that partial opacity or scale for good (Qt's ViewTransition notes), so the +// slide also settles both back to 1. +// +// Not a continuous animation: it runs once per structural change, for the +// rows actually on screen, and leaves nothing ticking (the repaint pulse +// rule in CLAUDE.md is about animations that keep presenting frames). + +import QtQuick + +Transition { + id: root + property int duration: 220 + + NumberAnimation { + properties: "x,y" + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "opacity" + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + to: 1.0 + duration: root.duration + easing.type: Easing.OutCubic + } +} diff --git a/crates/qbz-qt/qml/controls/QbzRowRemove.qml b/crates/qbz-qt/qml/controls/QbzRowRemove.qml new file mode 100644 index 000000000..582bf3949 --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzRowRemove.qml @@ -0,0 +1,40 @@ +// QbzRowRemove — a row leaving a QbzKeyedModel view fades out, a card also +// shrinks a touch (2026-09-14). Assign it to the view's `remove`, with +// `enabled: .animate`. The view keeps the delegate alive until this +// ends; its content stays because a removed row's roles are not re-notified. +// +// THE LAST STEP PUTS OPACITY AND SCALE BACK. With `reuseItems` the view pools +// the delegate the moment this transition finishes (QQuickItemViewPrivate:: +// viewItemTransitionFinished -> releaseItem -> setVisible(false), all in one +// call), and a pooled delegate is reused as it was left: without the restore +// the next row it renders is invisible. Measured 2026-09-14: 76 blank rows +// after three removals and a scroll. The restore runs before the release, in +// the same call chain, so it never paints. + +import QtQuick + +Transition { + id: root + property int duration: 180 + /// 1.0 for list rows; cards read better with a slight shrink. + property real shrink: 1.0 + + SequentialAnimation { + ParallelAnimation { + NumberAnimation { + property: "opacity" + to: 0.0 + duration: root.duration + easing.type: Easing.OutCubic + } + NumberAnimation { + property: "scale" + to: root.shrink + duration: root.duration + easing.type: Easing.OutCubic + } + } + PropertyAction { property: "opacity"; value: 1.0 } + PropertyAction { property: "scale"; value: 1.0 } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzSearchField.qml b/crates/qbz-qt/qml/controls/QbzSearchField.qml new file mode 100644 index 000000000..45e41440a --- /dev/null +++ b/crates/qbz-qt/qml/controls/QbzSearchField.qml @@ -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() + } + } + } + } +} diff --git a/crates/qbz-qt/qml/controls/QbzSelect.qml b/crates/qbz-qt/qml/controls/QbzSelect.qml index 99b0eb855..ecf73774d 100644 --- a/crates/qbz-qt/qml/controls/QbzSelect.qml +++ b/crates/qbz-qt/qml/controls/QbzSelect.qml @@ -74,8 +74,8 @@ Rectangle { // shows through the control (QbzSelect.slint:110-116). Hover keeps its own // token, which is already translucent. color: selArea.containsMouse && enabled - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill opacity: enabled ? 1.0 : 0.4 activeFocusOnTab: enabled Accessible.role: Accessible.ComboBox diff --git a/crates/qbz-qt/qml/controls/QbzTextArea.qml b/crates/qbz-qt/qml/controls/QbzTextArea.qml index 03ce073ee..169cfdbed 100644 --- a/crates/qbz-qt/qml/controls/QbzTextArea.qml +++ b/crates/qbz-qt/qml/controls/QbzTextArea.qml @@ -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 diff --git a/crates/qbz-qt/qml/controls/QbzToolButton.qml b/crates/qbz-qt/qml/controls/QbzToolButton.qml index e1ca89f4a..b940a7706 100644 --- a/crates/qbz-qt/qml/controls/QbzToolButton.qml +++ b/crates/qbz-qt/qml/controls/QbzToolButton.qml @@ -69,7 +69,7 @@ Rectangle { : 2 * root.padH + row.implicitWidth radius: 6 color: root.filled ? theme.accent - : (toolArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (toolArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) border.width: (root.large && !(root.active && !root.fillActive)) ? 0 : 1 border.color: (root.active && !root.fillActive) ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/controls/RipWizardModal.qml b/crates/qbz-qt/qml/controls/RipWizardModal.qml index 7acbee6a2..2d08f9484 100644 --- a/crates/qbz-qt/qml/controls/RipWizardModal.qml +++ b/crates/qbz-qt/qml/controls/RipWizardModal.qml @@ -75,6 +75,11 @@ Item { album = doc.album || "" albumArtist = doc.albumArtist || "" year = doc.year || "" + // The boxes too (QbzLineEdit.reset()): a disc re-read with the same + // tags changes no draft above. + albumField.reset() + yearField.reset() + albumArtistField.reset() libraryChoice = "none" var copy = [] var src = doc.tracks || [] @@ -254,6 +259,7 @@ Item { font.pixelSize: theme.fontLegal } QbzLineEdit { width: parent.width + id: albumField text: root.album onEdited: function (v) { root.album = v } } @@ -265,6 +271,7 @@ Item { font.pixelSize: theme.fontLegal } QbzLineEdit { width: parent.width + id: yearField text: root.year onEdited: function (v) { root.year = v } } @@ -277,6 +284,7 @@ Item { font.pixelSize: theme.fontLegal } QbzLineEdit { width: parent.width + id: albumArtistField text: root.albumArtist onEdited: function (v) { root.albumArtist = v } } diff --git a/crates/qbz-qt/qml/controls/ScrollMemory.qml b/crates/qbz-qt/qml/controls/ScrollMemory.qml index cb6925528..037218467 100644 --- a/crates/qbz-qt/qml/controls/ScrollMemory.qml +++ b/crates/qbz-qt/qml/controls/ScrollMemory.qml @@ -44,10 +44,12 @@ // every one of those snaps the list back to the top — over a restore that had // already run correctly. // -// (The same reset also throws a plain forward visit to the top if a rail lands -// while you are reading. The re-assert below only stops it from eating the -// restore; fixing that properly means giving those rails a model of their own, -// which is an AlbumView change and not this file's business.) +// (The album page no longer resets: since 2026-09-13 its tape is swapped on a +// QbzArrayModel, which reaches the ListView as remove+insert changes and keeps +// the viewport — see that control for the Qt 6.11 focus theft that forced the +// move. Those swaps drift `originY` instead, which is what `relativeToOrigin` +// is for. The re-assert below stays for every page that still assigns +// `model` directly.) // // So the offset is held in `_wanted` and re-applied on every contentHeight // change, every count change and every contentY move we did not make, until diff --git a/crates/qbz-qt/qml/controls/SettingsButton.qml b/crates/qbz-qt/qml/controls/SettingsButton.qml index 6aba6e94a..0cd9e1496 100644 --- a/crates/qbz-qt/qml/controls/SettingsButton.qml +++ b/crates/qbz-qt/qml/controls/SettingsButton.qml @@ -36,7 +36,7 @@ Rectangle { border.width: root.activeFocus ? 2 : 1 border.color: root.activeFocus ? theme.accent : (danger ? theme.danger : theme.borderSubtle) - color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: enabled && btnArea.containsMouse ? theme.elevatedHoverFill : theme.elevatedFill opacity: enabled ? 1.0 : 0.4 activeFocusOnTab: enabled Accessible.role: Accessible.Button diff --git a/crates/qbz-qt/qml/controls/TrackReplacementModal.qml b/crates/qbz-qt/qml/controls/TrackReplacementModal.qml index 3125dbf75..0ea7a2d55 100644 --- a/crates/qbz-qt/qml/controls/TrackReplacementModal.qml +++ b/crates/qbz-qt/qml/controls/TrackReplacementModal.qml @@ -411,6 +411,18 @@ Item { tierOverride: cand.modelData.qualityTier || "" label: cand.modelData.qualityDetail || "" } + // The mark alone says "CD" or "Hi-Res"; the + // exact tier is the decision, so it is spelled + // out beside it. (The badge's own hover tooltip + // never shows on this row: the row's MouseArea + // takes the hover.) + Text { + anchors.verticalCenter: parent.verticalCenter + visible: (cand.modelData.qualityDetail || "") !== "" + text: cand.modelData.qualityDetail || "" + color: theme.textSecondary + font.pixelSize: theme.fontLegal + } Text { anchors.verticalCenter: parent.verticalCenter text: root.releaseMode ? "" : (cand.modelData.duration || "") diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml b/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml index 863163a22..45bc0a017 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalAlbum.qml @@ -53,14 +53,28 @@ Rectangle { function toggleSelected(id, mods) { selected = selection.next(selected, id, visibleTracks, mods === undefined ? Qt.NoModifier : mods) } readonly property string navigationStateJson: JSON.stringify({trackQuery: trackQuery}) function restoreNavigationState(state) { trackQuery = state.trackQuery || "" } - onDocChanged: exitMultiSelectMode() + // An in-place album switch starts at the top; the first document of a + // mount does not touch the viewport (ScrollMemory may be restoring it). + property string loadedAlbumId: "" + onDocChanged: { + exitMultiSelectMode() + var id = root.album ? String(root.album.id || "") : "" + if (id === root.loadedAlbumId) + return + if (root.loadedAlbumId !== "") + rowsModel.scrollToTop() + root.loadedAlbumId = id + } ListView { id: list anchors.fill: parent anchors.margins: 12 clip: true - model: root.visibleTracks + // Swapped on a QbzArrayModel, never on `model:` — a fresh array there + // makes Qt 6.11 focus delegate 0 and the header's search box loses the + // keyboard on every keystroke (see the control's header). + model: QbzArrayModel { id: rowsModel; view: list; rows: root.visibleTracks } cacheBuffer: height reuseItems: true boundsBehavior: Flickable.StopAtBounds @@ -168,6 +182,6 @@ Rectangle { ] onPicked: function(a) { if (root.selectedCount > 0) root.bulkAction(a) } } - ScrollMemory { target: list; scope: "localalbum" } + ScrollMemory { target: list; scope: "localalbum"; relativeToOrigin: true } QbzScrollBar { target: list; anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom } } diff --git a/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml b/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml index f059dd714..8437157cb 100644 --- a/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml +++ b/crates/qbz-qt/qml/kiosk/KioskLocalLibrary.qml @@ -890,7 +890,7 @@ Rectangle { border.width: 1 border.color: chip.active ? theme.accent : theme.borderSubtle color: chip.active ? theme.accent - : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + : chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: chipLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml b/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml index 19db08f59..37eb41fe8 100644 --- a/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml +++ b/crates/qbz-qt/qml/kiosk/KioskPlaylist.qml @@ -84,6 +84,10 @@ Rectangle { var id = doc.id || "" if (id === loadedPlaylistId) return + // An in-place playlist switch starts at the top; the first document + // of a mount leaves the viewport to ScrollMemory. + if (loadedPlaylistId !== "") + rowsModel.scrollToTop() loadedPlaylistId = id setMultiSelect(false) } @@ -396,7 +400,10 @@ Rectangle { cacheBuffer: height reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: root.tracks + // Swapped on a QbzArrayModel, never on `model:` — a fresh array there + // makes Qt 6.11 focus delegate 0, and the header's search box lost the + // keyboard whenever a filtered document landed (see the control). + model: QbzArrayModel { id: rowsModel; view: trackList; rows: root.tracks } header: Column { width: trackList.width spacing: 12 @@ -586,6 +593,6 @@ Rectangle { ] onPicked:function(a){if(root.selectedCount>0)root.bulkAction(a)} } - ScrollMemory { target:trackList; scope:"playlist" } + ScrollMemory { target:trackList; scope:"playlist"; relativeToOrigin:true } QbzScrollBar { target:trackList; anchors.right:parent.right; anchors.top:parent.top; anchors.bottom:parent.bottom } } diff --git a/crates/qbz-qt/qml/rows/TrackRow.qml b/crates/qbz-qt/qml/rows/TrackRow.qml index 36d82da3f..4b699b2d0 100644 --- a/crates/qbz-qt/qml/rows/TrackRow.qml +++ b/crates/qbz-qt/qml/rows/TrackRow.qml @@ -990,13 +990,28 @@ Rectangle { // Main artist first, then the featured performers, one comma // separated list. Local / media-server rows carry no // `featured` and stay as they are. With links enabled and - // featured names present, every name is its own StyledText - // link: the main artist opens its page as before; a featured + // featured names present, every name is its own clickable + // SPAN: the main artist opens its page as before; a featured // name opens its page when the album credits gave it an id - // and the search for that name otherwise. StyledText elides - // like plain text (probed: truncated stays true on one line). + // and the search for that name otherwise. Spans, not `` + // anchors: StyledText underlines every anchor unconditionally + // (qquickstyledtext.cpp, setFontUnderline(true)) and nothing + // else in the app underlines a link, so the hit test is done + // by measuring the prefix up to each name with the same font + // (TextMetrics below). StyledText still elides like plain + // text (probed: truncated stays true on one line). readonly property var featured: root.showFeaturedArtists ? (root.item.featured || []) : [] readonly property bool linkMode: root.artistLink && featured.length > 0 + /// The names as displayed, in order: {name, href} — href "" + /// = the main artist with no page to open. + readonly property var spans: { + var out = [{ "name": String(root.item.artist || ""), "href": root.item.artistId ? "main" : "" }] + for (var i = 0; i < featured.length; i++) + out.push({ "name": String(featured[i].name || ""), "href": "f:" + i }) + return out + } + /// The span under the pointer (-1 = none). + property int hotSpan: -1 function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">") } @@ -1005,51 +1020,84 @@ Rectangle { return ("0" + Math.round(v * 255).toString(16)).slice(-2) }).join("") } - function link(href, name) { - var hot = artistLine.hoveredLink === href - return '' + esc(name) + '' + function span(i) { + var s = artistLine.spans[i] + var hot = artistLine.hotSpan === i && s.href !== "" + return '' + esc(s.name) + '' } textFormat: linkMode ? Text.StyledText : Text.PlainText text: { var names = featured.map(function (f) { return f.name }) if (!linkMode) return (root.item.artist || "") + (names.length ? ", " + names.join(", ") : "") - var parts = [root.item.artistId ? link("main", root.item.artist) : esc(root.item.artist)] - for (var i = 0; i < featured.length; i++) - parts.push(link("f:" + i, featured[i].name)) + var parts = [] + for (var i = 0; i < spans.length; i++) + parts.push(span(i)) return parts.join(", ") } - linkColor: theme.textMuted color: root.artistLink && root.item.artistId && artistLinkArea.containsMouse ? theme.textPrimary : theme.textMuted font.pixelSize: 12 elide: Text.ElideRight - onLinkActivated: function (href) { - if (href === "main") { + /// Which span sits at `x` (-1 = a separator or past the text): + /// the prefix up to each name's end is measured with this + /// Text's own font, so the edges match what is drawn. + function spanAt(x) { + if (!linkMode || x < 0) + return -1 + var prefix = "" + for (var i = 0; i < spans.length; i++) { + spanMetrics.text = prefix + var from = spanMetrics.advanceWidth + prefix += spans[i].name + spanMetrics.text = prefix + if (x >= from && x < spanMetrics.advanceWidth) + return i + prefix += ", " + } + return -1 + } + function activate(i) { + var s = artistLine.spans[i] + if (!s || s.href === "") + return + if (s.href === "main") { if (root.routeGoToExternally) root.goToRequested("artist") else QbzArtist.openArtist(root.item.artistId) return } - var f = featured[parseInt(href.slice(2))] + var f = featured[parseInt(s.href.slice(2))] if (!f) return if (f.id) QbzArtist.openArtist(f.id) else QbzSearch.searchSubmit(f.name) } - HoverHandler { - enabled: artistLine.linkMode - cursorShape: artistLine.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor + TextMetrics { + id: spanMetrics + font: artistLine.font } MouseArea { id: artistLinkArea anchors.fill: parent - enabled: root.artistLink && !!root.item.artistId && !artistLine.linkMode + enabled: root.artistLink && (artistLine.linkMode || !!root.item.artistId) hoverEnabled: true - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + cursorShape: (artistLine.linkMode + ? (artistLine.hotSpan >= 0 && artistLine.spans[artistLine.hotSpan].href !== "") + : enabled) ? Qt.PointingHandCursor : Qt.ArrowCursor + onPositionChanged: function (mouse) { + if (artistLine.linkMode) + artistLine.hotSpan = artistLine.spanAt(mouse.x) + } + onExited: artistLine.hotSpan = -1 // Same routing as the menu's "Go to artist" — Slint drives // both through one handler, so gating only the menu would // leave the LINK navigating to the track's own artist. - onClicked: { + onClicked: function (mouse) { + if (artistLine.linkMode) { + var i = artistLine.spanAt(mouse.x) + if (i >= 0) + artistLine.activate(i) + return + } if (root.routeGoToExternally) root.goToRequested("artist") else QbzArtist.openArtist(root.item.artistId) } @@ -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 diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index dd21a2f6b..90869ca31 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -148,6 +148,27 @@ Column { onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) } } } + // The picture behind the two Wallpaper modes: the desktop's, or one of + // the user's own (wallpaper_qt.rs). + SettingRow { kioskHost: root.kioskHost; + visible: (root.doc.appBackgroundIndex || 0) >= 3 + label: QbzSession.tr("Background image", QbzSession.trRev) + description: (root.doc.appBackgroundImage || "") !== "" + ? root.doc.appBackgroundImage + : QbzSession.tr("The system wallpaper. Choose an image of your own instead.", QbzSession.trRev) + Row { + spacing: 8 + SettingsButton { kioskHost: root.kioskHost; + text: QbzSession.tr("Choose image…", QbzSession.trRev) + onClicked: QbzBridge.settingsPickBackgroundImage() + } + SettingsButton { kioskHost: root.kioskHost; + visible: (root.doc.appBackgroundImage || "") !== "" + text: QbzSession.tr("System wallpaper", QbzSession.trRev) + onClicked: QbzBridge.settingsClearBackgroundImage() + } + } + } // Auto-theme rows (the "auto" theme only). SettingRow { kioskHost: root.kioskHost; visible: QbzShell.themeSlug === "auto" diff --git a/crates/qbz-qt/qml/settings/DacWizardModal.qml b/crates/qbz-qt/qml/settings/DacWizardModal.qml index a14e4a56a..0af445724 100644 --- a/crates/qbz-qt/qml/settings/DacWizardModal.qml +++ b/crates/qbz-qt/qml/settings/DacWizardModal.qml @@ -754,7 +754,7 @@ Item { width: parent.width height: 40 radius: theme.radiusSm - color: cfgHead.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cfgHead.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: activeFocus ? theme.accent : theme.borderSubtle activeFocusOnTab: visible && enabled @@ -978,7 +978,7 @@ Item { event.accepted = true } } - color: prevTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: prevTa.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent width: 16 @@ -1016,7 +1016,7 @@ Item { event.accepted = true } } - color: nextTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: nextTa.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent width: 16 diff --git a/crates/qbz-qt/qml/settings/LibFolderEditModal.qml b/crates/qbz-qt/qml/settings/LibFolderEditModal.qml index 20bf749b1..eb3f63a16 100644 --- a/crates/qbz-qt/qml/settings/LibFolderEditModal.qml +++ b/crates/qbz-qt/qml/settings/LibFolderEditModal.qml @@ -64,7 +64,7 @@ Item { root.draftIsNetwork = root.st.isNetwork === true root.draftOverride = root.st.userOverrideNetwork === true root.draftFsIndex = root.st.fsTypeIndex || 0 - aliasField.text = root.draftAlias + aliasField.reset(root.draftAlias) closeButton.forceActiveFocus() } diff --git a/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml b/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml index 428d3c458..55bc2bb69 100644 --- a/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml +++ b/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml @@ -269,7 +269,7 @@ Item { width: resetRow.width + 24 height: root.kioskHost ? 44 : 34 radius: theme.radiusSm - color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: resetArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: activeFocus ? theme.accent : theme.borderSubtle activeFocusOnTab: root.opened diff --git a/crates/qbz-qt/qml/settings/MigrationSetupModal.qml b/crates/qbz-qt/qml/settings/MigrationSetupModal.qml index e8ea5e624..08ebe89a6 100644 --- a/crates/qbz-qt/qml/settings/MigrationSetupModal.qml +++ b/crates/qbz-qt/qml/settings/MigrationSetupModal.qml @@ -221,7 +221,7 @@ Item { width: body.width height: 92 radius: theme.radiusMd - color: selected ? theme.surfaceHover : theme.surfaceElevated + color: selected ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: selected ? 2 : 1 border.color: selected ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/shell/AboutModal.qml b/crates/qbz-qt/qml/shell/AboutModal.qml index 15b356b7c..be7948bbf 100644 --- a/crates/qbz-qt/qml/shell/AboutModal.qml +++ b/crates/qbz-qt/qml/shell/AboutModal.qml @@ -104,7 +104,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: lbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: lbArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: lbRow @@ -162,7 +162,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: hcArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: hcArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: hcRow @@ -224,7 +224,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: (sc.clickable && scArea.containsMouse) ? theme.surfaceHover : theme.surfaceElevated + color: (sc.clickable && scArea.containsMouse) ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: scText diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index e2e5a84e5..9f9806163 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/CastPicker.qml b/crates/qbz-qt/qml/shell/CastPicker.qml index 294758e66..b8fb41894 100644 --- a/crates/qbz-qt/qml/shell/CastPicker.qml +++ b/crates/qbz-qt/qml/shell/CastPicker.qml @@ -528,7 +528,7 @@ Popup { height: 34 radius: theme.radiusSm color: selected ? theme.accent - : (tabArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (tabArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) Row { anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/shell/ContentRouter.qml b/crates/qbz-qt/qml/shell/ContentRouter.qml index b29ce1a74..000532a9d 100644 --- a/crates/qbz-qt/qml/shell/ContentRouter.qml +++ b/crates/qbz-qt/qml/shell/ContentRouter.qml @@ -44,6 +44,46 @@ Item { } } + // THE PAGE'S TAB, LIVE (2026-09-13). Any mounted view with an `activeTab` + // string that does not report a richer state of its own (Local Library + // does, and restores it itself) reports its tab on every change: nav_qt + // stamps it on the history entry, so Back/Forward land on the tab the + // user left, and page_restore_qt keeps it in the per-profile page + // document, so "Where you left off" reopens on it. Never during the + // two-phase route commit, when the item on screen is still the outgoing + // view while `currentView` already names the next one. + Connections { + target: viewLoader.item + ignoreUnknownSignals: true + function onActiveTabChanged() { root._reportTab() } + } + function _reportTab() { + var item = viewLoader.item + if (!item || root._armed || root.kiosk) + return + if (typeof item.activeTab !== "string" || typeof item.navigationStateJson === "string") + return + QbzShell.reportNavState(QbzShell.currentView, JSON.stringify({ "activeTab": item.activeTab })) + } + // The other direction: a Back/Forward destination whose entry carries a + // tab (armed by nav_qt::step before `currentView` is published) mounts + // on it. Views that restore their own state consume the arming + // themselves and are left alone. + function _restoreTab() { + var item = viewLoader.item + if (!item || root.kiosk || QbzShell.restoreStateScope === "") + return + if (QbzShell.restoreStateScope !== QbzShell.currentView) + return + if (typeof item.activeTab !== "string" || typeof item.restoreNavigationState === "function") + return + var saved = null + try { saved = JSON.parse(QbzShell.stateRestore) } catch (e) { saved = null } + QbzShell.restoreStateScope = "" + if (saved && typeof saved.activeTab === "string" && saved.activeTab !== "") + item.activeTab = saved.activeTab + } + // The mounted view. The replacement for AppShell's four `viewLoader.item` // references (multi-select: the two Ctrl+A / Escape routers and the two // duck-typed reporters) — a QML `id` is scoped to its own document, so @@ -595,6 +635,7 @@ Item { && typeof viewLoader.item.activeTab === "string" root._itemTabRequest = viewLoader.item !== null && typeof viewLoader.item.tabNavigationRequest !== "undefined" + root._restoreTab() } } diff --git a/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml b/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml index 5ca3aec19..c4ab5f74d 100644 --- a/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml +++ b/crates/qbz-qt/qml/shell/CustomizeShortcutsModal.qml @@ -452,7 +452,7 @@ Item { radius: theme.radiusSm color: entryRow.recording ? theme.bgHover - : (capArea.containsMouse ? theme.surfaceHover + : (capArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) border.width: (entryRow.recording || entryRow.entry.modified) ? 2 : 1 // Colour priority (trap 6): danger on @@ -499,7 +499,7 @@ Item { height: 30 anchors.verticalCenter: parent.verticalCenter radius: theme.radiusSm - color: resetArea.containsMouse ? theme.surfaceHover + color: resetArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle diff --git a/crates/qbz-qt/qml/shell/HeaderBar.qml b/crates/qbz-qt/qml/shell/HeaderBar.qml index a3ff4331d..7f9c43ec5 100644 --- a/crates/qbz-qt/qml/shell/HeaderBar.qml +++ b/crates/qbz-qt/qml/shell/HeaderBar.qml @@ -63,6 +63,9 @@ Rectangle { // HeaderBar.slint's with-alpha(app-background-surface-alpha)). color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard readonly property bool ambientOn: theme.ambientOn + /// The fill of an ACTIVE nav item / open button: elevated, at half alpha + /// under the ambient field like every other chrome control. + readonly property color activeFill: root.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated /// Raised by the app menu's "Report an issue" row; AppShell owns the modal. @@ -312,7 +315,7 @@ Rectangle { height: 30 width: purchaseRow.implicitWidth radius: theme.radiusSm - color: purchaseTab.isActive ? theme.surfaceElevated + color: purchaseTab.isActive ? root.activeFill : purchaseArea.containsMouse ? theme.surfaceHover : "transparent" Row { @@ -363,7 +366,7 @@ Rectangle { width: tabRow.implicitWidth radius: theme.radiusSm opacity: isEnabled ? 1.0 : 0.5 - color: isActive ? theme.surfaceElevated + color: isActive ? root.activeFill : (tabArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent" Row { @@ -439,7 +442,7 @@ Rectangle { height: 30 radius: theme.radiusSm opacity: isEnabled ? 1.0 : 0.5 - color: isActive ? theme.surfaceElevated + color: isActive ? root.activeFill : (cnbArea.containsMouse && isEnabled) ? theme.surfaceHover : "transparent" // Baked glyph, or the section's own raw image when it carries one // (My QBZ branding) — see shell/NavSectionGlyph.qml. @@ -640,7 +643,7 @@ Rectangle { height: 30 radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter - color: plPopup.opened ? theme.surfaceElevated + color: plPopup.opened ? root.activeFill : plBtnArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: "list-music" @@ -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 diff --git a/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml b/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml index b15d2c7db..b03e9249a 100644 --- a/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml +++ b/crates/qbz-qt/qml/shell/KeyboardShortcutsModal.qml @@ -272,7 +272,7 @@ Item { width: customizeLabel.implicitWidth + 40 height: 40 radius: theme.radiusSm - color: customizeArea.containsMouse ? theme.surfaceHover + color: customizeArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: customizeArea.containsMouse ? theme.accent diff --git a/crates/qbz-qt/qml/shell/LogViewerModal.qml b/crates/qbz-qt/qml/shell/LogViewerModal.qml index 28a13924a..d3bdc8f61 100644 --- a/crates/qbz-qt/qml/shell/LogViewerModal.qml +++ b/crates/qbz-qt/qml/shell/LogViewerModal.qml @@ -66,6 +66,12 @@ Item { onVisibleChanged: { if (visible) { root.advancedOpen = false + // A modal's fields open clean (2026-09-14): the search box and the + // filter it drives both outlived the close. + if (searchInput.text !== "") { + searchInput.text = "" + QbzShell.logSetSearch("") + } keyScope.forceActiveFocus() } } diff --git a/crates/qbz-qt/qml/shell/OrbitBanner.qml b/crates/qbz-qt/qml/shell/OrbitBanner.qml index 7325e1aef..a1ed412dc 100644 --- a/crates/qbz-qt/qml/shell/OrbitBanner.qml +++ b/crates/qbz-qt/qml/shell/OrbitBanner.qml @@ -12,7 +12,7 @@ Rectangle { property string hostName: "" visible: remoteActive height: remoteActive ? Math.max(38, label.implicitHeight + 16) : 0 - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) border.width: 1 border.color: theme.accent Accessible.role: Accessible.StaticText diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 01fc58619..4f478b35b 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -657,7 +657,7 @@ Rectangle { favorite: root.npFavorite ephemeral: root.npEphemeral tooltip: root.tooltip - onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -680,7 +680,7 @@ Rectangle { playCircle: true favorite: root.npFavorite tooltip: root.tooltip - onAddRequested: function (anchorItem) { root.addAnchor = anchorItem; addMenu.openBelowRight(anchorItem) } + onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } } @@ -908,25 +908,8 @@ Rectangle { // "Add to…" flyout behind the transport "+" (TransportControls.slint's // add-menu), on the shared CardMenu surface. Same seven entries, same // order, same icons. - // The "+" button that opened the add-menu: the Copy submenu reopens on it. - property Item addAnchor: null + // The Copy submenu of the add-menu writes through this carrier. QbzClipboard { id: npClipboard } - CardMenu { - id: npCopyMenu - menuWidth: 232 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { - var parts = [QbzPlayer.npTitle || ""] - if (a === "copy-full") { - if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) - if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) - } - npClipboard.copy(parts.join(" - ")) - } - } CardMenu { id: addMenu @@ -992,13 +975,16 @@ Rectangle { "action": "buy" }) } - // Copy (2026-09-13): a submenu — the track name, or + // Copy (2026-09-13): a hover-opened submenu — the track name, or // "Track - Album - Artist". m.push({ "label": QbzSession.tr("Copy", QbzSession.trRev), "icon": "copy", "action": "copy", - "submenu": true + "submenu": [ + { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, + { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } + ] }) return m.concat(root.abEntries()) } @@ -1017,8 +1003,13 @@ Rectangle { QbzLibrary.libraryToggleFavorite("album", QbzPlayer.npAlbumId) } else if (a === "buy") { if (id !== "") QbzAlbum.buyTrack(id) - } else if (a === "copy") { - if (root.addAnchor) npCopyMenu.openBelowRight(root.addAnchor) + } else if (a === "copy-title" || a === "copy-full") { + var parts = [QbzPlayer.npTitle || ""] + if (a === "copy-full") { + if ((QbzPlayer.npAlbum || "") !== "") parts.push(QbzPlayer.npAlbum) + if ((QbzPlayer.npArtist || "") !== "") parts.push(QbzPlayer.npArtist) + } + npClipboard.copy(parts.join(" - ")) } else if (a === "ab-mark") { QbzPlayer.abMark() } else if (a === "ab-clear") { diff --git a/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml b/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml index dbe44886d..877d6b61a 100644 --- a/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml +++ b/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml @@ -126,7 +126,7 @@ Popup { width: parent.width height: 92 radius: theme.radiusSm - color: optionArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: optionArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: optionArea.containsMouse ? theme.accent : theme.borderSubtle diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index eb9dd76b6..f39c997d9 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -38,6 +38,24 @@ Rectangle { readonly property var currentRow: doc.current || null readonly property var upcoming: doc.upcoming || [] readonly property var historyRows: doc.history || [] + // Keyed models for both lists (2026-09-14): a track leaving Up Next (it + // started playing, it was removed) disappears and the rows below slide up; + // a track joining slides the list open; nothing is rebuilt. The rows are + // Repeaters in a Column, so the motion is the Column's own add / move + // transitions — a positioner has no remove transition. Identity is the + // track (source + id, repeats by occurrence); another page or search + // replaces the list at once. + QbzKeyedModel { + id: upcomingModel + rows: root.upcoming + keyOf: function (row) { return (row.source || "") + ":" + row.id } + scope: JSON.stringify([root.doc.searchQuery || "", root.doc.page || 0]) + } + QbzKeyedModel { + id: historyModel + rows: root.historyRows + keyOf: function (row) { return (row.source || "") + ":" + row.id } + } readonly property var settingsDoc: { try { return JSON.parse(QbzBridge.settingsJson) } catch (e) { return ({}) } @@ -138,7 +156,8 @@ Rectangle { // arithmetic model to keep in sync. Top half of row k -> slot k, bottom // half -> slot k+1, past the last row -> N (append). <=40 rows per page. function slotFromPointer(winY) { - var n = root.upcoming.length + // The Repeater's own count: its items are what the pointer is over. + var n = upcomingRepeater.count for (var i = 0; i < n; i++) { var d = upcomingRepeater.itemAt(i) if (d && winY < d.rowCenterY()) @@ -223,7 +242,13 @@ Rectangle { root.coverMap = Object.assign({}, m) } } - Component.onCompleted: dispatchCovers() + Component.onCompleted: { + // The lists' Columns, laid out synchronously around a reset so a new + // page or search appears at once (QbzKeyedModel). + upcomingModel.views = [upNextSection] + historyModel.views = [historyBody] + dispatchCovers() + } onDocChanged: dispatchCovers() onQueueTrackArtworkChanged: dispatchCovers() // Collect the covers of the document CURRENTLY on the property and ask the @@ -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) diff --git a/crates/qbz-qt/qml/shell/ReportIssueModal.qml b/crates/qbz-qt/qml/shell/ReportIssueModal.qml index 728d2d4ee..5a1424a9d 100644 --- a/crates/qbz-qt/qml/shell/ReportIssueModal.qml +++ b/crates/qbz-qt/qml/shell/ReportIssueModal.qml @@ -138,7 +138,7 @@ Item { radius: theme.radiusSm border.width: 1 border.color: theme.borderSubtle - color: goArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: goArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: goLabel anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index 364dc467e..06571b6ea 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -903,12 +903,10 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: { root.myqbzSearchOpen = !root.myqbzSearchOpen - if (!root.myqbzSearchOpen) { - myqbzSearchEdit.text = "" - root.myqbzSearch = "" - } else { - myqbzSearchEdit.forceActiveFocus() - } + if (!root.myqbzSearchOpen) + myqbzSearchEdit.clear() + else + myqbzSearchEdit.focusField() } } } @@ -999,8 +997,11 @@ Rectangle { onClicked: QbzShell.navigateTo("collections") } } - // Inline search field (filters the tree client-side). - Rectangle { + // Inline search field (filters the tree client-side). The + // shared search box: Escape empties it AND folds it away, + // the cross empties it and keeps the caret. + QbzSearchField { + id: myqbzSearchEdit visible: root.myqbzSearchOpen anchors.left: myqbzBrandIcon.right anchors.leftMargin: 8 @@ -1009,28 +1010,12 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter height: 22 radius: 4 - color: theme.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() } } } diff --git a/crates/qbz-qt/qml/shell/WallpaperField.qml b/crates/qbz-qt/qml/shell/WallpaperField.qml new file mode 100644 index 000000000..2497d00d4 --- /dev/null +++ b/crates/qbz-qt/qml/shell/WallpaperField.qml @@ -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 + } +} diff --git a/crates/qbz-qt/qml/theme/QbzTheme.qml b/crates/qbz-qt/qml/theme/QbzTheme.qml index a672df4aa..27ddb1378 100644 --- a/crates/qbz-qt/qml/theme/QbzTheme.qml +++ b/crates/qbz-qt/qml/theme/QbzTheme.qml @@ -59,6 +59,18 @@ QtObject { readonly property color surfaceCard: _c("surfaceCard") readonly property color surfaceElevated: _c("surfaceElevated") readonly property color surfaceHover: _c("surfaceHover") + /// Hover fill for a control whose REST fill is surfaceElevated: a lift of + /// that surface. NOT `surfaceHover` — that is a 6% white wash meant for + /// rows that rest transparent, and over an elevated button it lands + /// DARKER than the rest fill (the modal buttons that "went black on + /// hover", 2026-09-14). Derived, so every palette gets it for free. + readonly property color surfaceElevatedHover: Qt.lighter(surfaceElevated, isDark ? 1.35 : 0.94) + /// The same lift for the ambient half-alpha fill, a little more opaque so + /// the hover still reads as a lift over the field. + readonly property color surfaceElevatedHoverA50: Qt.rgba(surfaceElevatedHover.r, surfaceElevatedHover.g, surfaceElevatedHover.b, 0.62) + /// The ambient-aware pair an elevated control paints: rest and hover. + readonly property color elevatedFill: ambientOn ? surfaceElevatedA50 : surfaceElevated + readonly property color elevatedHoverFill: ambientOn ? surfaceElevatedHoverA50 : surfaceElevatedHover readonly property color bgHover: _c("bgHover") readonly property color textPrimary: _c("textPrimary") // Weak-ramp hardening under ambient on LIGHT themes (owner, 2026-08-31): @@ -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 ----------- // diff --git a/crates/qbz-qt/qml/views/AlbumView.qml b/crates/qbz-qt/qml/views/AlbumView.qml index df98929ec..397279ec6 100644 --- a/crates/qbz-qt/qml/views/AlbumView.qml +++ b/crates/qbz-qt/qml/views/AlbumView.qml @@ -59,14 +59,15 @@ Rectangle { // The view's album + url-keyed cover map (artwork pipeline). // - // Applied imperatively so a SAME-ALBUM deferred rail publish can preserve - // the ListView offset. Binding `model` to a freshly parsed JS array makes - // QQuickItemView reset contentY whenever Similar albums / Suggestions / More - // from this artist lands — the forced jump the owner caught while reading. + // Applied imperatively so the page can tell a SAME-ALBUM deferred rail + // publish (Similar albums / Suggestions / More from this artist landing + // while the user reads: the viewport stays put) from a NEW album (the + // track filter resets and the page starts at the top). The tape itself is + // swapped on `cellsModel`, a QbzArrayModel: reassigning the ListView's + // `model` would reset contentY on every publish and, on Qt 6.11, focus a + // delegate — see that control's header for both. property var album: ({}) property string documentAlbumId: "" - property int albumDocumentEpoch: 0 - property real albumRestoreY: 0 function parseAlbumDocument() { try { @@ -82,27 +83,18 @@ Rectangle { var nextId = nextHeader.id !== undefined && nextHeader.id !== null ? String(nextHeader.id) : "" var sameAlbum = nextId !== "" && nextId === root.documentAlbumId - var savedY = sameAlbum ? pageFlick.contentY : 0 - root.albumDocumentEpoch += 1 - var epoch = root.albumDocumentEpoch + if (!sameAlbum && root.documentAlbumId !== "") { + // A different release replacing the one on screen (the empty + // document a load publishes first counts): its track list starts + // unfiltered — the header's search box mirrors `trackQuery` — and + // at the top. The first document of a mount leaves the viewport + // alone: ScrollMemory may be restoring a Back navigation. + root.trackQuery = "" + cellsModel.scrollToTop() + } root.album = next if (nextId !== "") root.documentAlbumId = nextId - if (!sameAlbum || savedY <= pageFlick.originY) - return - root.albumRestoreY = savedY - // The model reset and its content-height polish happen after the - // document assignment. One deferred restore coalesces a publish burst - // and clamps if the replacement document is genuinely shorter. - Qt.callLater(function () { - if (epoch !== root.albumDocumentEpoch - || nextId !== root.documentAlbumId) - return - pageFlick.forceLayout() - var minY = pageFlick.originY - var maxY = minY + Math.max(0, pageFlick.contentHeight - pageFlick.height) - pageFlick.contentY = Math.max(minY, Math.min(root.albumRestoreY, maxY)) - }) } readonly property var albumHeader: album.header || ({}) @@ -850,7 +842,17 @@ Rectangle { anchors.fill: parent clip: true boundsBehavior: Flickable.StopAtBounds - model: root.listCells + // The tape is swapped on this model, never on `model:` itself: a + // fresh array assigned to the ListView after completion makes Qt 6.11 + // force currentIndex 0 and focus delegate 0, which robbed the header's + // track filter of the keyboard on every keystroke (2026-09-13). The + // wrapper also keeps the viewport across a same-album rail publish; + // applyAlbumDocument() asks for the top when the album changes. + model: QbzArrayModel { + id: cellsModel + view: pageFlick + rows: root.listCells + } reuseItems: true currentIndex: -1 // The track tape is cheap enough that a fast wheel can consume one @@ -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 diff --git a/crates/qbz-qt/qml/views/ArtistReleasesView.qml b/crates/qbz-qt/qml/views/ArtistReleasesView.qml index d7add9f45..016dfe0a2 100644 --- a/crates/qbz-qt/qml/views/ArtistReleasesView.qml +++ b/crates/qbz-qt/qml/views/ArtistReleasesView.qml @@ -257,7 +257,7 @@ Rectangle { width: retryLabel.implicitWidth + 32 height: 34 radius: 6 - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: retryLabel diff --git a/crates/qbz-qt/qml/views/ArtistView.qml b/crates/qbz-qt/qml/views/ArtistView.qml index c9ef633f0..19171aba8 100644 --- a/crates/qbz-qt/qml/views/ArtistView.qml +++ b/crates/qbz-qt/qml/views/ArtistView.qml @@ -1695,7 +1695,7 @@ Rectangle { enabled: !popRow.selectMode hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: function (mouse) { popRow._menuAnchor = moreArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popMenu.openAtCursor(moreArea, mouse.x, mouse.y) } } } } @@ -1779,7 +1779,10 @@ Rectangle { m.push({ "label": t("Track info", r), "icon": "info", "action": "track-info" }) if (!popRow.pulledDead) m.push({ "label": t("Buy on Qobuz", r), "icon": "shopping-bag", "action": "buy" }) - m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": true }) + m.push({ "label": t("Copy", r), "icon": "copy", "action": "copy", "submenu": [ + { "label": t("Track name", r), "icon": "copy", "action": "copy-title" }, + { "label": t("Track - Album - Artist", r), "icon": "clipboard", "action": "copy-full" } + ] }) return m } onPicked: function (a) { @@ -1814,27 +1817,14 @@ Rectangle { else if (a === "go-artist") QbzArtist.openArtist(popRow.row.artistId) else if (a === "track-info") root.openTrackInfo(id) else if (a === "buy") QbzAlbum.buyTrack(id) - else if (a === "copy") popCopyMenu.openAtCursor(popRow._menuAnchor, popRow._menuX, popRow._menuY) - } - } - // Copy submenu (2026-09-13), reopened where the row menu was. - property Item _menuAnchor: null - property real _menuX: 0 - property real _menuY: 0 - CardMenu { - id: popCopyMenu - menuWidth: 224 - entries: [ - { "label": QbzSession.tr("Track name", QbzSession.trRev), "icon": "copy", "action": "copy-title" }, - { "label": QbzSession.tr("Track - Album - Artist", QbzSession.trRev), "icon": "clipboard", "action": "copy-full" } - ] - onPicked: function (a) { - var parts = [popRow.row.title || ""] - if (a === "copy-full") { - if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) - if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + else if (a === "copy-title" || a === "copy-full") { + var parts = [popRow.row.title || ""] + if (a === "copy-full") { + if ((popRow.row.album || "") !== "") parts.push(popRow.row.album) + if ((popRow.row.artist || "") !== "") parts.push(popRow.row.artist) + } + artistClipboard.copy(parts.join(" - ")) } - artistClipboard.copy(parts.join(" - ")) } } @@ -1860,7 +1850,7 @@ Rectangle { id: popRcArea anchors.fill: parent acceptedButtons: Qt.RightButton - onClicked: function (mouse) { popRow._menuAnchor = popRcArea; popRow._menuX = mouse.x; popRow._menuY = mouse.y; popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } + onClicked: function (mouse) { popMenu.openAtCursor(popRcArea, mouse.x, mouse.y) } } } diff --git a/crates/qbz-qt/qml/views/BlacklistManagerView.qml b/crates/qbz-qt/qml/views/BlacklistManagerView.qml index 946e5b762..90db7a37f 100644 --- a/crates/qbz-qt/qml/views/BlacklistManagerView.qml +++ b/crates/qbz-qt/qml/views/BlacklistManagerView.qml @@ -282,7 +282,7 @@ Rectangle { width: toggleRow.implicitWidth + 28 height: root.kioskHost ? 44 : 34 radius: theme.radiusSm - color: toggleArea.containsMouse ? theme.surfaceHover + color: toggleArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: toggleRow diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index ee807854d..6b2a62881 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -1796,7 +1796,7 @@ Rectangle { height: 32 radius: 6 color: retryArea.containsMouse - ? theme.surfaceHover : theme.surfaceElevated + ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 6bff0cffa..bbb65fb0b 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -323,7 +323,10 @@ Rectangle { root.artistsGroup = p.artistsGroup root.artistsView = p.artistsView } - Component.onCompleted: root.applyPrefs() + Component.onCompleted: { + root.applyPrefs() + feedRows.views = [grid, list, playlistsList, albumsList.listView] + } Connections { target: QbzLibrary function onLibraryPrefsJsonChanged() { root.applyPrefs() } @@ -562,6 +565,59 @@ Rectangle { onVisibleRowsChanged: Qt.callLater(root.reportSurfaceState) onVisibleChanged: Qt.callLater(root.reportSurfaceState) + // THE MODEL every body binds to (2026-09-14). `visibleRows` stays the + // logical truth for everything that reasons about rows (selection, play + // context, the A-Z strip); the views see it through QbzKeyedModel, which + // turns a new derive into removes / inserts / moves instead of a model + // swap. That is the whole fix for "an album leaving the Library rebuilds + // the grid at the top": the heart settles, Rust republishes the feed + // (`publish_library_document`), the derive runs, and only the leaving + // card fades out while its neighbours slide into the gap — scroll offset, + // delegates and covers untouched. + // + // A new tab, sort, search or filter set is a different page, not a change + // to this one: `scope` replaces the rows at once and starts at the top, + // exactly as the swap did. `favoriteFilterRev` is deliberately NOT in the + // scope — a heart removed under "Favorites only" is precisely the row + // that should animate out. + // + // Index-coupled readers (the artwork window reports) read + // `feedRows.published`, the array the model reflects index for index; the + // reconcile is deferred one turn, and `onReconciled` re-reports because a + // removal above the window shifts every index. + /// The page identity `feedRows` resets on — for bodies that derive their + /// own keyed rows from the feed (the Artists sidepanel rail). + readonly property string rowsScope: feedRows.scope + QbzKeyedModel { + id: feedRows + rows: root.visibleRows + // The All feed shows a purchased favourite twice (a favourites row and + // a purchases row), so there the group is part of the identity; the + // tabs keep one representative per entity, whose group may change. + keyOf: function (row) { + if (row.kind === "group-header") + return row.id + return (row._membershipKey || (row.kind + ":" + row.id)) + + (root.activeTab === "all" ? "|" + (row.group || "") : "") + } + scope: JSON.stringify([root.activeTab, root.activeSort, root.search, + root.tabSearch, root.showPurchases, root.showFavorites, + root.showFollowing, root.showLocal, root.hiresOnly, root.genreNames, + root.tracksGroup, root.albumsGroup, root.artistsGroup, + root.artistsView, root.playlistsSubTab]) + // Positional cache from parseFeed: one row leaving renumbers it for + // every row after it, which is not a change to those rows. + ignoredKeys: ["_feedOrder"] + // `views` is handed over in Component.onCompleted below: all four + // bodies are declared after this model. + onReconciled: { + root.gridWindowReport() + root.listWindowReport() + albumsList.report() + playlistsList.report() + } + } + /// A-Z bucket for a title: its first letter uppercased, "#" for anything /// that is not a letter (a leading digit, "*", punctuation). Matches the /// jump strip's own buckets, so a letter in the strip always has a @@ -1093,13 +1149,14 @@ Rectangle { id: windowDebounce interval: 180 onTriggered: root.reportWindow( - pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast, pendingVisibleFirst) + pendingRows ? pendingRows : feedRows.published, pendingFirst, pendingLast, pendingVisibleFirst) property int pendingFirst: 0 property int pendingLast: 0 property int pendingVisibleFirst: 0 property var pendingRows: null } - /// `rows` is optional — omit it and the band is read off `visibleRows`. + /// `rows` is optional — omit it and the band is read off the array the + /// views show (`feedRows.published`). function queueWindowReport(first, last, rows, visibleFirst) { windowDebounce.pendingFirst = first windowDebounce.pendingLast = last @@ -1250,6 +1307,11 @@ Rectangle { readonly property int barInset: (root.activeTab === "tracks" && root.tracksMultiSelect) ? 52 : 0 + // A reload with a page already on screen (a bulk removal, a language + // switch) keeps that page until the new document lands, so the rows + // reconcile instead of the page blinking through the skeleton. + readonly property bool hasPage: root.feed.length > 0 + // Loading skeleton — a deliberate ADDITION: the Slint mounts a // bare centred 36px LoadingSpinner here (FavoritesView.slint: // 956/1012), which says "busy" but not "this is the shape of @@ -1269,6 +1331,7 @@ Rectangle { anchors.rightMargin: 32 anchors.topMargin: 16 visible: QbzLibrary.libraryLoading && QbzLibrary.libraryError === "" + && !content.hasPage // Which shape the tab is about to render. readonly property bool listShape: (root.activeTab === "all" && root.viewMode === "list") @@ -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 ================================= diff --git a/crates/qbz-qt/qml/views/LocalAlbumView.qml b/crates/qbz-qt/qml/views/LocalAlbumView.qml index f90f6a38e..7959f1dc7 100644 --- a/crates/qbz-qt/qml/views/LocalAlbumView.qml +++ b/crates/qbz-qt/qml/views/LocalAlbumView.qml @@ -588,7 +588,7 @@ Rectangle { border.width: 1 border.color: root.multiSelect ? theme.accent : theme.borderSubtle color: (root.multiSelect || selectArea.containsMouse) - ? theme.surfaceHover + ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "square-check-big" diff --git a/crates/qbz-qt/qml/views/PlayHistoryView.qml b/crates/qbz-qt/qml/views/PlayHistoryView.qml index 4a52bbff8..0e6ffdaf2 100644 --- a/crates/qbz-qt/qml/views/PlayHistoryView.qml +++ b/crates/qbz-qt/qml/views/PlayHistoryView.qml @@ -103,7 +103,7 @@ Rectangle { radius: 6 border.width: 1 border.color: theme.borderSubtle - color: theme.surfaceElevated + color: (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) Row { anchors.fill: parent diff --git a/crates/qbz-qt/qml/views/PlaylistView.qml b/crates/qbz-qt/qml/views/PlaylistView.qml index eb384fa40..c936e4602 100644 --- a/crates/qbz-qt/qml/views/PlaylistView.qml +++ b/crates/qbz-qt/qml/views/PlaylistView.qml @@ -75,6 +75,8 @@ Rectangle { // never reach Rust, everything else goes down as a JSON id array through // QbzPlayer.bulkTracksAction (bulk_tracks_qt.rs). property bool multiSelect: false + /// The rows a bulk "Remove from playlist" is waiting to confirm. + property var pendingRemoveIds: [] property var selected: ({}) readonly property int selectedCount: Object.keys(root.selected).length readonly property bool multiSelectOn: root.multiSelect @@ -108,6 +110,13 @@ Rectangle { return } if (action === "clear") { root.selected = ({}); sel.anchorId = ""; return } + if (action === "remove") { + var picked = root.selectedIdsInOrder() + if (picked.length === 0) return + root.pendingRemoveIds = picked + removeConfirm.open() + return + } var ids = root.selectedIdsInOrder() if (ids.length === 0) return QbzPlayer.bulkTracksAction(JSON.stringify(ids), action, "playlist", String(doc.id || "")) @@ -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 = ({}) + } + } } diff --git a/crates/qbz-qt/qml/views/PurchaseAlbumView.qml b/crates/qbz-qt/qml/views/PurchaseAlbumView.qml index 433f87219..45c5cc7c7 100644 --- a/crates/qbz-qt/qml/views/PurchaseAlbumView.qml +++ b/crates/qbz-qt/qml/views/PurchaseAlbumView.qml @@ -400,7 +400,7 @@ Rectangle { height: 32 radius: 6 anchors.horizontalCenter: parent.horizontalCenter - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/PurchasesView.qml b/crates/qbz-qt/qml/views/PurchasesView.qml index c6d71c45b..3b37f1226 100644 --- a/crates/qbz-qt/qml/views/PurchasesView.qml +++ b/crates/qbz-qt/qml/views/PurchasesView.qml @@ -355,7 +355,7 @@ Rectangle { required property var modelData width: Math.max(96, kioskTabLabel.implicitWidth + 24) height: 64 - color: root.tab === modelData.id ? theme.surfaceElevated : "transparent" + color: root.tab === modelData.id ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : "transparent" Text { id: kioskTabLabel; anchors.centerIn: parent; text: parent.modelData.label; color: theme.textPrimary; font.pixelSize: 18 } MouseArea { anchors.fill: parent; onClicked: QbzPurchases.setTab(parent.modelData.id) } } @@ -446,7 +446,7 @@ Rectangle { height: 32 radius: 6 anchors.horizontalCenter: parent.horizontalCenter - color: retryArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: retryArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated border.width: 1 border.color: theme.borderSubtle Text { diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml index b7c8d2f71..248b900ac 100644 --- a/crates/qbz-qt/qml/views/QueueView.qml +++ b/crates/qbz-qt/qml/views/QueueView.qml @@ -40,6 +40,39 @@ Rectangle { && root.doc.currentIndex < root.rows.length readonly property int actionRailWidth: kioskHost ? 72 : 52 + // The list's model (2026-09-14): queue changes reach the ListView as + // removes / inserts / moves instead of a new array, so removing a track + // fades that row out while the rest slide up, "play next" slides the + // queue open, a history row trimmed above the window leaves the rows on + // screen where they are, and nothing is rebuilt from the top. + // + // Identity is the track (source + id); a repeat of the same track is its + // next occurrence. Phase, section and phaseIndex are data, not identity: + // when a track ends, the chronological order does not change, only which + // row is current, so those rows update in place and nothing moves. A + // search is a different list (the scope), replaced at once. + QbzKeyedModel { + id: rowsModel + rows: root.rows + keyOf: function (row) { return (row.source || "") + ":" + row.id } + scope: root.doc.searchQuery || "" + onReconciled: { + // The follow and the covers read view indices: run them once the + // view holds the new rows. + root.scheduleVisibleCovers() + if (root.hasPlayingRow && root.followPlaying) + root.followPlayingRow() + } + } + // A committed drop lands the dragged row where the pointer left it; only + // its neighbours slide. The chevrons keep the move animation (a swap). + property bool dropSettling: false + Timer { + id: dropSettle + interval: 600 + onTriggered: root.dropSettling = false + } + // Timing for the chronological projection currently on screen. History is // fully elapsed, the current row contributes the player's live position, // and upcoming rows are fully remaining. A filtered listen list therefore @@ -128,6 +161,7 @@ Rectangle { } Component.onCompleted: { + rowsModel.views = [queueList] QbzQueue.queueExtendedOpened() root.scheduleVisibleCovers() } @@ -139,8 +173,6 @@ Rectangle { root.scheduleVisibleCovers() if (!root.hasPlayingRow) root.cancelPlayingFollow() - else if (root.followPlaying) - Qt.callLater(function () { root.followPlayingRow() }) } onSearchActiveChanged: if (root.searchActive) root.cancelPlayingFollow() @@ -170,20 +202,22 @@ Rectangle { } function requestVisibleCovers() { - if (root.rows.length === 0 || queueList.height <= 0) + // View indices index the rows the model holds, not a newer `rows`. + var shown = rowsModel.published + if (shown.length === 0 || queueList.height <= 0) return var first = queueList.indexAt(4, queueList.contentY + 2) if (first < 0) first = 0 var last = queueList.indexAt(4, queueList.contentY + queueList.height - 2) if (last < first) - last = Math.min(root.rows.length - 1, first + 16) + last = Math.min(shown.length - 1, first + 16) first = Math.max(0, first - 3) - last = Math.min(root.rows.length - 1, last + 3) + last = Math.min(shown.length - 1, last + 3) var urls = [] var wanted = ({}) for (var i = first; i <= last; i++) { - var url = root.artUrl(root.rows[i]) + var url = root.artUrl(shown[i]) if (url !== "" && !root.coverMap[url] && wanted[url] !== true) { wanted[url] = true urls.push(url) @@ -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 { diff --git a/crates/qbz-qt/qml/views/SearchView.qml b/crates/qbz-qt/qml/views/SearchView.qml index 937a32722..c3a2ab90d 100644 --- a/crates/qbz-qt/qml/views/SearchView.qml +++ b/crates/qbz-qt/qml/views/SearchView.qml @@ -689,7 +689,7 @@ Rectangle { height: 24 radius: 12 anchors.verticalCenter: parent.verticalCenter - color: clrArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: clrArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "x"; width: 13; height: 13; anchors.centerIn: parent; tintName: clrArea.containsMouse ? "textPrimary" : "muted" } MouseArea { id: clrArea diff --git a/crates/qbz-qt/qml/views/library/FeedGridCell.qml b/crates/qbz-qt/qml/views/library/FeedGridCell.qml index c43933838..4ddc67d19 100644 --- a/crates/qbz-qt/qml/views/library/FeedGridCell.qml +++ b/crates/qbz-qt/qml/views/library/FeedGridCell.qml @@ -63,7 +63,13 @@ Item { cell.scheduleMutableRestore() } GridView.onPooled: cell.releaseLoadedCard() - GridView.onReused: cell.scheduleMutableRestore() + GridView.onReused: { + // A row transition cut short (QbzKeyedModel in LibraryView) can pool + // the cell half faded or shrunk; the pool hands it back as it was. + cell.opacity = 1 + cell.scale = 1 + cell.scheduleMutableRestore() + } width: 200 height: 246 diff --git a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml index 8bfbb86de..590b26ffb 100644 --- a/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml +++ b/crates/qbz-qt/qml/views/library/LibraryAlbumsList.qml @@ -26,9 +26,11 @@ Item { /// The LibraryView root (artMap, the selection map, the window reporter). property var view: null - /// The derived album rows for this tab — `view.visibleRows`, passed in so - /// this file never re-derives and both surfaces see the same array. - property var rows: [] + /// The Library's keyed model (views/LibraryView.qml `feedRows`), shared by + /// every body so a row leaving animates instead of rebuilding the list. + property var rowsModel: null + /// For the model's synchronous reset layout. + readonly property alias listView: list QbzTheme { id: theme } @@ -39,6 +41,8 @@ Item { function positionAt(index) { list.positionViewAtIndex(index, ListView.Beginning) } + /// Re-report the artwork window (the host calls it after a reconcile). + function report() { list.report() } Column { anchors.fill: parent @@ -83,7 +87,10 @@ Item { anchors.topMargin: 4 anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom + // Height-gated like the host's grid: a hidden ListView with a + // model keeps refilling its viewport, and this one shares the + // Library's model with every other body. + height: root.visible ? parent.height - header.height - 4 : 0 // No strip inset here: the A-Z strip lives in LibraryView and // the HOST already widened this component's right margin for // it. Insetting again would double-count it. @@ -92,7 +99,11 @@ Item { cacheBuffer: 64 * 8 reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: root.rows + model: root.rowsModel + add: QbzRowAdd { enabled: root.rowsModel !== null && root.rowsModel.animate } + remove: QbzRowRemove { enabled: root.rowsModel !== null && root.rowsModel.animate } + move: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate } + displaced: QbzRowDisplaced { enabled: root.rowsModel !== null && root.rowsModel.animate } // Windowed artwork, same contract as the grid: never read // `list.model` back off the view (views/LibraryView.qml @@ -101,18 +112,19 @@ Item { // Gated on visibility like the host's own bodies // (LibraryView.gridWindowReport / listWindowReport): `artMap` // is ONE map and a report PRUNES outside its band, while this - // body keeps firing hidden — `rows` flips to [] on every tab - // switch (onModelChanged) and `anchors.fill` turns any window - // resize into onHeightChanged. Ungated it evicted whichever + // body keeps firing hidden (`anchors.fill` turns any window + // resize into onHeightChanged). Ungated it evicted whichever // body is actually on screen, the Artists sidepanel rail above - // all, whose band is NOT a `visibleRows` band. + // all, whose band is NOT a feed band. Offsets count from + // `originY`, which rows leaving above the window move. function report() { - if (!list.visible) return + if (!list.visible || root.rowsModel === null) return var stride = root.rowH + root.rowGap - var first = Math.max(0, Math.floor(list.contentY / stride) - 4) - var last = Math.ceil((list.contentY + list.height) / stride) + 4 + var scrollY = list.contentY - list.originY + var first = Math.max(0, Math.floor(scrollY / stride) - 4) + var last = Math.ceil((scrollY + list.height) / stride) + 4 root.view.queueWindowReport(first, - Math.min(root.rows.length - 1, last)) + Math.min(root.rowsModel.published.length - 1, last)) } onContentYChanged: list.report() onModelChanged: list.report() @@ -121,8 +133,11 @@ Item { Component.onCompleted: list.report() delegate: AlbumListRow { - required property var modelData + required property string rowKey + required property int rowRev required property int index + readonly property var modelData: root.rowsModel.row(rowKey, rowRev) + ListView.onReused: { opacity = 1; scale = 1 } width: list.width item: modelData rowIndex: index diff --git a/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml b/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml index fb15914f4..bae2ab9c2 100644 --- a/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml +++ b/crates/qbz-qt/qml/views/library/LibraryArtistsPanel.qml @@ -95,7 +95,25 @@ Item { root._rebuilding = false root.report() } - Component.onCompleted: { root.rebuild(); root.reportSoon() } + // The rail's own keyed model (2026-09-14): an artist unfollowed from the + // pane fades out of the rail while the rows below slide up, instead of + // the rail being rebuilt from the top. Same page identity as the host's + // bodies, so a search or a sort still replaces it at once. + QbzKeyedModel { + id: railModel + rows: root.entries + keyOf: function (entry) { + return entry.t === 0 ? "letter:" + entry.label : "artist:" + entry.item.id + } + scope: root.view ? root.view.rowsScope : "" + onReconciled: root.report() + } + + Component.onCompleted: { + railModel.views = [railList] + root.rebuild() + root.reportSoon() + } Connections { target: root.view function onVisibleRowsChanged() { root.rebuild() } @@ -212,7 +230,11 @@ Item { cacheBuffer: 56 * 8 reuseItems: true boundsBehavior: Flickable.StopAtBounds - model: root.entries + model: railModel + add: QbzRowAdd { enabled: railModel.animate } + remove: QbzRowRemove { enabled: railModel.animate } + move: QbzRowDisplaced { enabled: railModel.animate } + displaced: QbzRowDisplaced { enabled: railModel.animate } // The trigger set views/local/LocalArtistsTab.qml:161-165 already // uses for the same shape of rail: scroll, model swap, viewport @@ -225,7 +247,10 @@ Item { onVisibleChanged: if (visible) root.reportSoon() delegate: Loader { - required property var modelData + required property string rowKey + required property int rowRev + readonly property var modelData: railModel.row(rowKey, rowRev) + ListView.onReused: { opacity = 1; scale = 1 } width: railList.width height: modelData.t === 0 ? root.headerH : root.rowH sourceComponent: modelData.t === 0 ? letterComp : artistComp diff --git a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml index 619ef3d43..55f0fdafe 100644 --- a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml +++ b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml @@ -82,7 +82,7 @@ Item { width: 30 height: 30 radius: 6 - color: active ? theme.surfaceElevated + color: active ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : ttArea.containsMouse ? theme.surfaceHover : "transparent" QbzIcon { name: parent.name @@ -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 diff --git a/crates/qbz-qt/qml/views/local/FilterChip.qml b/crates/qbz-qt/qml/views/local/FilterChip.qml index 96fd49596..12b26e1b8 100644 --- a/crates/qbz-qt/qml/views/local/FilterChip.qml +++ b/crates/qbz-qt/qml/views/local/FilterChip.qml @@ -22,7 +22,7 @@ Rectangle { border.width: 1 border.color: active ? theme.accent : theme.borderSubtle color: active ? theme.accent - : chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + : chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Text { id: lbl diff --git a/crates/qbz-qt/qml/views/local/FolderSubcard.qml b/crates/qbz-qt/qml/views/local/FolderSubcard.qml index 97717ed5a..6e6b18e09 100644 --- a/crates/qbz-qt/qml/views/local/FolderSubcard.qml +++ b/crates/qbz-qt/qml/views/local/FolderSubcard.qml @@ -26,7 +26,7 @@ Rectangle { radius: 6 border.width: 1 border.color: cardArea.containsMouse ? theme.accent : theme.borderSubtle - color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Column { anchors.fill: parent diff --git a/crates/qbz-qt/qml/views/local/LocalChrome.qml b/crates/qbz-qt/qml/views/local/LocalChrome.qml index a946703aa..10ac507e3 100644 --- a/crates/qbz-qt/qml/views/local/LocalChrome.qml +++ b/crates/qbz-qt/qml/views/local/LocalChrome.qml @@ -120,8 +120,8 @@ Item { readonly property bool busy: QbzLocal.localDiscOpening color: (!openBtn.busy && (openArea.containsMouse || openMenu.opened)) - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill Row { id: openRow diff --git a/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml b/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml index 50d48655d..7c1aa5460 100644 --- a/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml +++ b/crates/qbz-qt/qml/views/local/LocalEphemeralPane.qml @@ -314,8 +314,8 @@ Item { radius: 6 anchors.verticalCenter: parent.verticalCenter color: ripArea.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill Row { id: ripRow anchors.centerIn: parent diff --git a/crates/qbz-qt/qml/views/local/LocalFilterButton.qml b/crates/qbz-qt/qml/views/local/LocalFilterButton.qml index 304901d74..04dcb64d3 100644 --- a/crates/qbz-qt/qml/views/local/LocalFilterButton.qml +++ b/crates/qbz-qt/qml/views/local/LocalFilterButton.qml @@ -25,7 +25,7 @@ Item { border.width: 1 border.color: root.view && root.view.filterCount > 0 ? theme.accent : theme.borderSubtle - color: area.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: area.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "list-filter" diff --git a/crates/qbz-qt/qml/views/local/LocalGenresTab.qml b/crates/qbz-qt/qml/views/local/LocalGenresTab.qml index 0ce85ac5a..a86b604fb 100644 --- a/crates/qbz-qt/qml/views/local/LocalGenresTab.qml +++ b/crates/qbz-qt/qml/views/local/LocalGenresTab.qml @@ -198,9 +198,8 @@ Item { height: 28 radius: 6 color: chipMouse.containsMouse - ? theme.surfaceHover - : (theme.ambientOn ? theme.surfaceElevatedA50 - : theme.surfaceElevated) + ? theme.elevatedHoverFill + : theme.elevatedFill border.width: 1 border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle @@ -312,7 +311,7 @@ Item { radius: 5 color: theme.ambientOn ? theme.surfaceElevatedA50 - : (caretArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated) + : (caretArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated) border.width: 1 border.color: theme.ambientOn ? theme.frostBorder : theme.borderSubtle QbzIcon { diff --git a/crates/qbz-qt/qml/views/local/LocalIconSelect.qml b/crates/qbz-qt/qml/views/local/LocalIconSelect.qml index 1042b0e16..5560c0b20 100644 --- a/crates/qbz-qt/qml/views/local/LocalIconSelect.qml +++ b/crates/qbz-qt/qml/views/local/LocalIconSelect.qml @@ -40,7 +40,7 @@ Item { radius: 6 border.width: 1 border.color: root.marked ? theme.accent : theme.borderSubtle - color: btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: btnArea.containsMouse ? theme.elevatedHoverFill : theme.elevatedFill QbzIcon { name: root.iconName width: 15 diff --git a/crates/qbz-qt/qml/views/local/TreeRow.qml b/crates/qbz-qt/qml/views/local/TreeRow.qml index 6fff7fa07..18e94645d 100644 --- a/crates/qbz-qt/qml/views/local/TreeRow.qml +++ b/crates/qbz-qt/qml/views/local/TreeRow.qml @@ -35,7 +35,7 @@ Rectangle { height: 26 radius: 6 - color: selected ? theme.surfaceElevated + color: selected ? (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) : rowArea.containsMouse ? theme.surfaceHover : "transparent" // Row body — declared FIRST so the chevron / checkbox win their clicks. diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml index c79aeb4b5..4b08e225d 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzCard.qml @@ -47,7 +47,7 @@ Rectangle { color: cardArea.containsMouse ? theme.surfaceHover : (root.compact ? "transparent" : theme.surfaceCard) border.width: root.compact ? 0 : 1 - border.color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated // `label · meta`, but never a bare " · ": a default/half-built `item` (a // standalone instance, or a pre-publish frame) has neither half and would diff --git a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml index 77524835f..68af97b9f 100644 --- a/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml +++ b/crates/qbz-qt/qml/views/myqbz/MyQbzGridView.qml @@ -524,7 +524,7 @@ Rectangle { width: 28 height: 28 radius: 14 - color: newArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: newArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent name: "plus" diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml index 635df4196..7b6e7eb31 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderCard.qml @@ -35,7 +35,7 @@ Rectangle { width: 160 height: 150 radius: 10 - color: cardArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: cardArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 // padding 16 / spacing 8 / centred column. diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml index 502fb8f13..2763a27bd 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderChip.qml @@ -40,7 +40,7 @@ Rectangle { height: 52 radius: 8 - color: chipArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: chipArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated opacity: (root.folder.isHidden === true) ? 0.6 : 1.0 Row { diff --git a/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml b/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml index 2f982d745..788c0fab1 100644 --- a/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml +++ b/crates/qbz-qt/qml/views/playlistmanager/PmFolderMenu.qml @@ -83,6 +83,8 @@ QbzContextMenu { menuRoot.playlistId = String(pid || "") menuRoot.currentFolderId = String(fid || "") menuRoot.folderQuery = "" + // The box too: QbzLineEdit re-seeds only while unfocused. + folderSearchField.reset("") menuRoot.openBelowRight(anchorItem) } @@ -103,6 +105,7 @@ QbzContextMenu { height: visible ? (menuRoot.kioskHost ? 44 : 32) : 0 QbzLineEdit { + id: folderSearchField kioskHost: menuRoot.kioskHost anchors.verticalCenter: parent.verticalCenter // The plain arm is a FIXED width: 240 — a modal/menu must set it. diff --git a/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml b/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml index 27b72bd57..a6e7febfc 100644 --- a/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml +++ b/crates/qbz-qt/qml/views/purchases/PurchasesToolbar.qml @@ -96,7 +96,7 @@ Item { width: tb.kioskHost ? Math.max(64, tbRow.width) : tbRow.width height: tb.kioskHost ? 64 : 30 radius: 6 - color: tbArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: tbArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated Row { id: tbRow height: parent.height @@ -396,7 +396,7 @@ Item { width: root.kioskHost ? 64 : 30 height: root.kioskHost ? 64 : 30 radius: 6 - color: filterArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: filterArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: "list-filter" width: 16 @@ -579,7 +579,7 @@ Item { height: root.kioskHost ? 64 : 30 radius: 6 anchors.verticalCenter: parent.verticalCenter - color: viewArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + color: viewArea.containsMouse ? theme.surfaceElevatedHover : theme.surfaceElevated QbzIcon { name: root.viewMode === "grid" ? "list" : "layout-grid" width: 16 diff --git a/crates/qbz-qt/src/album_info_qt.rs b/crates/qbz-qt/src/album_info_qt.rs index 5f4c2dce6..14541880a 100644 --- a/crates/qbz-qt/src/album_info_qt.rs +++ b/crates/qbz-qt/src/album_info_qt.rs @@ -121,6 +121,8 @@ fn map_track(index: usize, t: &Track, album_artist: &str) -> InfoTrack { let performers: Vec = parse_performers(t.performers.as_deref().unwrap_or_default()) .into_iter() + // Catalog fillers ("Not Documented") are not people (2026-09-13). + .filter(|p| !qbz_text_utils::names::is_placeholder_name(&p.name)) .map(|p| PerformerRow { roles: roles_suffix(&p.roles), primary_role: p diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index 3190eaebb..14bba645f 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -485,10 +485,14 @@ fn credit_role(roles: Option<&Vec>) -> String { /// album.rs `build_credits` (releaseArtistsMapper parity, minus the /// "VARIOUS"-composer drop it also applies). fn build_credits(album: &Album) -> Vec<(String, String, String)> { + use qbz_text_utils::names::is_placeholder_name; + // Catalog fillers ("Not Documented") are not credits — dropped here so + // the header never prints them beside the real artist (2026-09-13). let mut credits: Vec<(String, String, String)> = match album.artists.as_ref().filter(|v| !v.is_empty()) { Some(list) => list .iter() + .filter(|a| !is_placeholder_name(&a.name)) .map(|a| { ( a.name.clone(), @@ -497,15 +501,22 @@ fn build_credits(album: &Album) -> Vec<(String, String, String)> { ) }) .collect(), - None => vec![( - album.artist.name.clone(), - album.artist.id.to_string(), - String::new(), - )], + None => Vec::new(), }; + if credits.is_empty() && !is_placeholder_name(&album.artist.name) { + credits.push(( + album.artist.name.clone(), + album.artist.id.to_string(), + String::new(), + )); + } // Album-level composer appended last, unless it's the localized // "Various Composers" placeholder (releaseArtistsMapper's VARIOUS drop). - if let Some(composer) = album.composer.as_ref().filter(|c| !c.name.is_empty()) { + if let Some(composer) = album + .composer + .as_ref() + .filter(|c| !c.name.is_empty() && !is_placeholder_name(&c.name)) + { if !composer.name.to_uppercase().contains("VARIOUS") { credits.push(( composer.name.clone(), @@ -542,6 +553,7 @@ pub(crate) fn featured_list( }; qbz_qobuz::performers::featured_artists(performers, artist, title) .into_iter() + .filter(|name| !qbz_text_utils::names::is_placeholder_name(name)) .map(|name| { let id = credits .iter() @@ -636,8 +648,22 @@ pub async fn load_album( other => other.to_string(), })?; - let artist = album.artist.name.clone(); - let artist_id = album.artist.id.to_string(); + // The header's artist: never a catalog filler — fall back to the first + // real name in `artists`, else nothing (the credits line still shows + // whatever real names exist). + let (artist, artist_id) = if qbz_text_utils::names::is_placeholder_name(&album.artist.name) { + album + .artists + .as_ref() + .and_then(|list| { + list.iter() + .find(|a| !qbz_text_utils::names::is_placeholder_name(&a.name)) + }) + .map(|a| (a.name.clone(), a.id.to_string())) + .unwrap_or_default() + } else { + (album.artist.name.clone(), album.artist.id.to_string()) + }; let credits = build_credits(&album); let date_display = format_release_date( album @@ -1168,7 +1194,9 @@ fn album_to_card(album: &Album) -> AlbumCardData { } fn card_artist(album: &Album) -> (String, String) { - if !album.artist.name.is_empty() { + if !album.artist.name.is_empty() + && !qbz_text_utils::names::is_placeholder_name(&album.artist.name) + { return (album.artist.name.clone(), album.artist.id.to_string()); } if let Some(list) = album.artists.as_ref() { diff --git a/crates/qbz-qt/src/artist_releases_qt.rs b/crates/qbz-qt/src/artist_releases_qt.rs index 1a751f072..927afc2c2 100644 --- a/crates/qbz-qt/src/artist_releases_qt.rs +++ b/crates/qbz-qt/src/artist_releases_qt.rs @@ -276,7 +276,14 @@ pub fn open(artist_id: String, artist_name: String, release_type: String) { // The route is a TWO-FILE contract (nav_qt.rs:9-16): this records the id, // AppShell.qml's Loader ternary mounts it. `QbzShell.navigateTo` is NOT // usable — it carries no payload and clears LAST_DETAIL (main.rs:1607). - crate::nav_qt::record("artistreleases"); + crate::nav_qt::record_with( + "artistreleases", + serde_json::json!({ + "artistId": &artist_id, + "artistName": &artist_name, + "releaseType": &release_type + }), + ); publish(); fetch(generation, artist_id, release_type, 0, true); } diff --git a/crates/qbz-qt/src/award_qt.rs b/crates/qbz-qt/src/award_qt.rs index b74892395..04b0afb86 100644 --- a/crates/qbz-qt/src/award_qt.rs +++ b/crates/qbz-qt/src/award_qt.rs @@ -484,7 +484,7 @@ pub fn open_award(award_id: String, fallback_name: String) { if id.is_empty() { return; } - crate::nav_qt::record("award"); + crate::nav_qt::record_with("award", serde_json::json!({ "id": &id, "name": &fallback_name })); let generation = with_state(|s| { s.generation = s.generation.wrapping_add(1); s.id = id.clone(); @@ -721,7 +721,7 @@ pub fn open_albums() { if id.is_empty() { return; } - crate::nav_qt::record("awardalbums"); + crate::nav_qt::record_with("awardalbums", serde_json::json!({ "id": &id, "name": &name })); let generation = with_state(|s| { s.generation = s.generation.wrapping_add(1); s.all_albums.clear(); diff --git a/crates/qbz-qt/src/bridge.rs b/crates/qbz-qt/src/bridge.rs index 0fd46c632..f1294a38f 100644 --- a/crates/qbz-qt/src/bridge.rs +++ b/crates/qbz-qt/src/bridge.rs @@ -84,6 +84,10 @@ pub mod qbz_bridge { /// Select rows: the picked OPTION INDEX within the row's list. #[qinvokable] fn settings_select(self: Pin<&mut QbzBridge>, key: QString, index: i32); + #[qinvokable] + fn settings_pick_background_image(self: Pin<&mut QbzBridge>); + #[qinvokable] + fn settings_clear_background_image(self: Pin<&mut QbzBridge>); /// Slider rows (initial buffer size). #[qinvokable] fn settings_slider(self: Pin<&mut QbzBridge>, key: QString, value: i32); @@ -169,6 +173,8 @@ pub mod qbz_bridge { /// invokable this replaced could not express them. #[qinvokable] fn playlist_remove_track(self: Pin<&mut QbzBridge>, row_id: QString); + #[qinvokable] + fn playlist_remove_tracks(self: Pin<&mut QbzBridge>, ids_json: QString); /// Drag-reorder drop: visible row `from` -> insertion slot `slot`. #[qinvokable] fn playlist_reorder(self: Pin<&mut QbzBridge>, from: i32, slot: i32); @@ -281,6 +287,14 @@ impl qbz_bridge::QbzBridge { crate::settings_bool(key.to_string(), value); } + pub fn settings_pick_background_image(self: Pin<&mut Self>) { + crate::settings_qt::pick_background_image(); + } + + pub fn settings_clear_background_image(self: Pin<&mut Self>) { + crate::settings_qt::clear_background_image(); + } + pub fn settings_select(self: Pin<&mut Self>, key: QString, index: i32) { crate::settings_select(key.to_string(), index); } @@ -293,11 +307,13 @@ impl qbz_bridge::QbzBridge { crate::settings_string(key.to_string(), value.to_string()); } - /// Purely bridge-local state — no crate handler, nothing to persist. The - /// Slint global is not persisted either: Settings always opens on Audio, - /// the section only has to survive a Loader unmount WITHIN a session. + /// Bridge-local state with one reader outside: the section only has to + /// survive a Loader unmount WITHIN a session (Settings opens on Audio), + /// and it rides to page_restore_qt so "Where you left off" reopens + /// Settings on the same section. pub fn settings_set_section(mut self: Pin<&mut Self>, index: i32) { if index == 11 && !crate::orbit_qt::enabled() { return; } + crate::page_restore_qt::note_args("settings", serde_json::json!({ "section": index })); self.as_mut().set_settings_section(index); } @@ -380,6 +396,10 @@ impl qbz_bridge::QbzBridge { crate::playlist_remove_track(row_id.to_string()); } + pub fn playlist_remove_tracks(self: Pin<&mut Self>, ids_json: QString) { + crate::playlist_remove_tracks(ids_json.to_string()); + } + pub fn playlist_reorder(self: Pin<&mut Self>, from: i32, slot: i32) { crate::playlist_reorder(from, slot); } diff --git a/crates/qbz-qt/src/browse_qt.rs b/crates/qbz-qt/src/browse_qt.rs index 741b2d97c..a883ec441 100644 --- a/crates/qbz-qt/src/browse_qt.rs +++ b/crates/qbz-qt/src/browse_qt.rs @@ -151,7 +151,10 @@ pub fn open_discover_browse(endpoint: String, title: String) { log::warn!("[qbz-qt] open_discover_browse with an empty endpoint; ignoring"); return; } - crate::nav_qt::record("discoverbrowse"); + crate::nav_qt::record_with( + "discoverbrowse", + serde_json::json!({ "endpoint": &endpoint, "title": &title }), + ); let generation = { let Ok(mut s) = DISCOVER.lock() else { return; diff --git a/crates/qbz-qt/src/foryou_qt.rs b/crates/qbz-qt/src/foryou_qt.rs index f8f29f697..2d0726568 100644 --- a/crates/qbz-qt/src/foryou_qt.rs +++ b/crates/qbz-qt/src/foryou_qt.rs @@ -710,7 +710,7 @@ pub(crate) fn open_mix(kind: String) { return; } }; - crate::nav_qt::record("mix"); + crate::nav_qt::record_with("mix", serde_json::json!({ "kind": &kind })); let (title, subtitle) = mix_meta(&kind); let generation = with_mix(|s| { s.generation = s.generation.wrapping_add(1); diff --git a/crates/qbz-qt/src/label_qt.rs b/crates/qbz-qt/src/label_qt.rs index 9bf345f5f..8f164f96b 100644 --- a/crates/qbz-qt/src/label_qt.rs +++ b/crates/qbz-qt/src/label_qt.rs @@ -231,7 +231,7 @@ pub fn open_label(label_id: String) { log::warn!("[qbz-qt] open_label: not a Qobuz label id: {label_id}"); return; }; - crate::nav_qt::record("label"); + crate::nav_qt::record_with("label", serde_json::json!({ "id": id.to_string() })); let generation = { let Ok(mut s) = LABEL.lock() else { return; @@ -770,7 +770,7 @@ pub fn open_releases() { s.query.clear(); (s.releases_generation, id) }; - crate::nav_qt::record("labelreleases"); + crate::nav_qt::record_with("labelreleases", serde_json::json!({ "id": id.to_string() })); publish_releases(); fetch_releases(generation, id); } diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs index fb6fdc81c..22f9ac1ab 100644 --- a/crates/qbz-qt/src/local_playlist_qt.rs +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -711,30 +711,30 @@ pub fn set_open_mixed_snapshot( pub fn local_picker_ref_for_row(id: &str) -> Option { let queue = CURRENT_QUEUE.lock().ok()?; let q = queue.iter().find(|q| q.id.to_string() == id)?; + local_picker_ref_for_queue_track(q) +} + +/// The picker ref for a QUEUE row, source-aware like +/// `local_picker_ref_for_track`: Plex and the media servers ride the key +/// their row keeps in the hint slot (the rating key / the server item id — +/// `local_playback::local_queue_track`, `media_servers_qt::cached_to_local_track`), +/// an offline copy its library row id (ALSO in the hint: its queue id is the +/// catalog id when the copy has one, which is what `local_row_input` needs to +/// re-derive the right input kind), a local file its row id. `None` = a +/// local row this port cannot file (a media row that lost its key). The +/// queue's "save as playlist" and per-row "Add to playlist" build their local +/// refs here — never by hand (the module header's rule). +pub fn local_picker_ref_for_queue_track(q: &QueueTrack) -> Option { match q.source.as_deref() { Some("plex") => q .source_item_id_hint .as_ref() .map(|key| format!("plex:{key}")), - // Remote rows carry the server item id in the hint slot, same as - // Plex carries the rating key (media_servers_qt::cached_to_local_track). Some(source @ ("jellyfin" | "subsonic")) => q .source_item_id_hint .as_ref() .map(|item| format!("{source}:{item}")), Some("local") => Some(q.id.to_string()), - // An OFFLINE COPY row (`local_playback::local_queue_track` tags these - // "qobuz_download"). It reaches this function because `row_to_display` - // publishes every non-Plex `RowItem::Local` with `source: "local"`, so - // TrackRow.qml offers the entry — returning `None` here would render it - // and no-op, which is the defect class this round is closing. - // - // Its `q.id` is NOT a library row id: `local_queue_track` sets the - // queue id to `qobuz_track_id` when the row has one. The library row id - // is carried in `source_item_id_hint` for exactly this reason, and it - // is what `local_row_input` needs to re-derive the right input kind - // (`Qobuz(qid)` when the copy has a catalog id, the file path when it - // does not). Some("qobuz_download") => q.source_item_id_hint.clone(), _ => None, } @@ -876,11 +876,9 @@ pub(crate) fn row_to_display(item: &RowItem) -> (PlaylistTrackRow, Option (PlaylistTrackRow, Option = { + let Ok(map) = ROW_POSITIONS.lock() else { + return; + }; + row_ids + .iter() + .filter_map(|id| map.get(id).copied()) + .collect() + }; + positions.sort_unstable_by(|a, b| b.cmp(a)); + positions.dedup(); + if positions.is_empty() { + log::warn!( + "[qbz-qt] local playlist bulk remove: no known rows among {} id(s)", + row_ids.len() + ); + return; + } + let pid = playlist_id.clone(); + let _ = tokio::task::spawn_blocking(move || { + with_db(true, |db| { + Ok(db.with_connection(|conn| -> rusqlite::Result<()> { + for position in positions { + repo::remove_track(conn, &pid, position)?; + } + Ok(()) + })) + }) + }) + .await; + load(runtime, &playlist_id).await; + crate::reload_sidebar_including_local(); +} + /// Reorder the open detail: move the row at `from` to `to` (repo positions), /// then reload. /// diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 67219117d..a27649f15 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -290,6 +290,10 @@ mod disc_meta_qt; mod label_qt; mod local_media_info_qt; mod nav_qt; +mod page_restore_qt; +mod wallpaper_qt; +#[cfg(target_os = "linux")] +mod wallpaper_wayland_qt; mod now_playing; mod offline_fwd; mod output_labels; @@ -822,6 +826,11 @@ fn on_session_entered() { let ordinary_entry = nav_qt::shell_entry_view(); let logged_off = offline_fwd::engine().status().offline_session; let local_album = local_restore_qt::take_startup_album(); + // The exact page (page_restore_qt: a detail, a root's tab, a Settings + // section, a search). Consumed here even when another door wins, so a + // re-entry in the same process never replays it. `ordinary_entry` already + // names its entry view — the shell mounted that view at construction. + let page = page_restore_qt::take_startup_page(); let entry_view = if local_album.is_some() { "localalbum".to_string() } else if logged_off { @@ -831,6 +840,8 @@ fn on_session_entered() { }; nav_qt::record(&entry_view); hydrate_view(&entry_view); + // The desktop wallpaper, when a background mode paints it (a no-op otherwise). + wallpaper_qt::refresh(); // Logged-off startup is the ONLY automatic redirect. An authenticated // account keeps the established startup/restore flow even when physical // connectivity is down. The selected tab is the first user-ordered Local @@ -841,6 +852,15 @@ fn on_session_entered() { } else if logged_off { let landing = settings_qt::local_landing_tab(kiosk_profile_qt::active()); navigate_to_tab("local", &landing); + } else if let Some(page) = page { + // Re-run the page's opener on the view the shell already mounted. A + // page that cannot come back (a catalog page while offline, a hidden + // Purchases surface, stale arguments) steps back onto the root that + // was seeded beneath it. + if !page_restore_qt::restore(&page) { + nav_qt::back(); + hydrate_view(&nav_qt::current_view()); + } } now_playing::publish_current(); // Phase 3: fetch Discover > Home (online sessions only — the offline @@ -1431,7 +1451,7 @@ pub(crate) fn open_album(album_id: String) { if offline_fwd::engine().status().is_offline() { return; } - nav_qt::record("album"); + nav_qt::record_with("album", serde_json::json!({ "id": &album_id })); *LAST_DETAIL.lock().unwrap() = ("album".to_string(), album_id.clone()); let runtime = app(); album_bridge::ui(move |mut b| { @@ -1465,7 +1485,7 @@ pub(crate) fn open_artist(artist_id: String) { if offline_fwd::engine().status().is_offline() { return; } - nav_qt::record("artist"); + nav_qt::record_with("artist", serde_json::json!({ "id": &artist_id })); *LAST_DETAIL.lock().unwrap() = ("artist".to_string(), artist_id.clone()); // Warm the Library feed in the background: the page's "In library" tab is // built off it, and on a cold session (or right after an account switch — @@ -1903,7 +1923,7 @@ pub(crate) fn open_playlist(playlist_id: String) { // QBZ as a player without Qobuz — refusing them while offline would gate // local files behind a network the user does not have. if local_playlist_qt::is_local_id(&playlist_id) { - nav_qt::record("playlist"); + nav_qt::record_with("playlist", serde_json::json!({ "id": &playlist_id })); ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); let runtime = app(); spawn(async move { @@ -1917,7 +1937,7 @@ pub(crate) fn open_playlist(playlist_id: String) { log::warn!("[qbz-qt] open_playlist: invalid id {playlist_id}"); return; }; - nav_qt::record("playlist"); + nav_qt::record_with("playlist", serde_json::json!({ "id": &playlist_id })); // Clear the previous playlist before the fetch — same stale-render as the // album and artist views had. ui(|mut b| b.as_mut().set_playlist_json(QString::from("{}"))); @@ -2156,6 +2176,37 @@ pub(crate) fn playlist_remove_track(row_id: String) { }); } +/// Multi-select "Remove from playlist": the three arms of +/// `playlist_remove_track` over every selected row — the local repo rows in +/// one pass, the sidecar rows one by one (each is its own table write), the +/// Qobuz memberships in ONE API call. +pub(crate) fn playlist_remove_tracks(ids_json: String) { + let ids: Vec = serde_json::from_str(&ids_json).unwrap_or_default(); + if ids.is_empty() { + return; + } + let runtime = app(); + spawn(async move { + if local_playlist_qt::local_detail_open() { + local_playlist_qt::remove_rows(&runtime, &ids).await; + return; + } + let mut catalog: Vec = Vec::new(); + for row_id in &ids { + if playlist_qt::is_mixed() && playlist_qt::remove_sidecar_row(&runtime, row_id).await { + continue; + } + match row_id.parse::() { + Ok(id) => catalog.push(id), + Err(_) => log::warn!("[qbz-qt] playlist bulk remove: non-numeric row id {row_id}"), + } + } + if !catalog.is_empty() { + playlist_qt::remove_tracks(&runtime, &catalog).await; + } + }); +} + /// Drag-reorder drop: move the visible row `from` to insertion slot `slot`. /// /// Routes like the chevrons below: a LOCAL playlist writes the repo `position` diff --git a/crates/qbz-qt/src/myqbz_detail_qt.rs b/crates/qbz-qt/src/myqbz_detail_qt.rs index 2cc7ff804..fef26fc17 100644 --- a/crates/qbz-qt/src/myqbz_detail_qt.rs +++ b/crates/qbz-qt/src/myqbz_detail_qt.rs @@ -2325,7 +2325,7 @@ pub(crate) async fn load(runtime: &Arc>, id: String) /// Grid card click: push the route, then load. pub(crate) fn open(id: String) { - crate::nav_qt::record("mixtapedetail"); + crate::nav_qt::record_with("mixtapedetail", serde_json::json!({ "id": &id })); let runtime = crate::app(); crate::spawn(async move { load(&runtime, id).await; diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index f55796e79..448c3a640 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -32,16 +32,18 @@ //! only the `contextual` branch navigates; `weak`/`none` publish a document //! that a global overlay renders (`musician_qt.rs`). //! -//! `purchase-album` is in that same context-carrying class (its album id lives -//! in the purchases controller, not in the id), and `purchases` is left out of -//! the restore set for a DIFFERENT reason: it is opt-in and ships hidden -//! (`show_purchases`, default false), so restoring onto it could open the app -//! on a surface whose own entry point is switched off. Neither is in -//! `VIEW_TO_PREF` below, so `startup_view()` can never resolve to either — and -//! neither is offered by the Startup page dropdown, whose own list -//! (`settings_qt::STARTUP_PAGE_VALUES`) would have to grow a matching entry -//! first. The Slint build DOES restore `purchases` (`qbz/src/main.rs:637`); -//! bringing that across is an additive change that needs both halves. +//! `purchase-album` and `purchases` are not in `VIEW_TO_PREF` below either: +//! the album id lives in the purchases controller, and the surface is opt-in +//! (`show_purchases`, default false). Since 2026-09-13 page_restore_qt brings +//! both back — the controller notes the album id, and the restore checks the +//! opt-in first, so a hidden surface never reopens the app. +//! +//! WHERE YOU LEFT OFF, EXACTLY (2026-09-13): every route push goes through +//! `record` / `record_with` (the latter carries the opener's arguments, kept +//! on the entry), and page_restore_qt keeps the current page, its arguments +//! and its tab in a per-profile document. `startup_view()` answers with that +//! page's entry view, the history is seeded with the page's root, and the +//! session entry re-runs the opener. //! //! `nowplaying` is KIOSK-ONLY (2026-08-02 kiosk-port contract §3): it is the //! NavRail's fifth tile and the route the kiosk's full-screen player lives on. @@ -76,6 +78,10 @@ struct Entry { /// scroll scope, Rust stores and returns it without interpreting it. state_scope: String, state_json: String, + /// The opener's arguments (`record_with`), Null for a plain route push: + /// what page_restore_qt keeps for a restart, re-noted when a step lands + /// back on the entry. + args: serde_json::Value, } struct NavHistory { @@ -125,6 +131,9 @@ pub fn set_live_state(scope: &str, json: &str) { live.0.push_str(scope); live.1.clear(); live.1.push_str(json); + drop(live); + // The page's tab rides along to the page document (page_restore_qt). + crate::page_restore_qt::note_state(scope, json); } fn live_state() -> (String, String) { @@ -154,11 +163,12 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { // restored view with a history rooted at Home would offer a Back to a // page the user never opened this session. entries: vec![Entry { - view: startup_view(), + view: startup_root(), scope: String::new(), scroll: 0.0, state_scope: String::new(), state_json: String::new(), + args: serde_json::Value::Null, }], index: 0, }); @@ -192,11 +202,12 @@ fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { /// owner's own prefs today read `last_view = "local-library"`, written by Slint. /// Unknown values in either direction resolve to Home rather than guessing. /// -/// This root-only fallback does not carry detail IDs. Local albums now have -/// a separate per-profile context in local_restore_qt, restored at session -/// entry with a missing-entity fallback. Other details, Search and Settings are -/// excluded too — transient and config respectively, and reopening the app -/// inside either is a surprise, not "where you left off". +/// This root-only value carries no detail ids and never will: it is the +/// SHARED file. The exact page — the album, the artist, the playlist, the +/// search, the Settings section, and the tab a root view was on — lives in +/// page_restore_qt's per-profile document since 2026-09-13 (local albums in +/// local_restore_qt's), restored at session entry with this root seeded +/// beneath it and used as the fallback when the page cannot come back. const VIEW_TO_PREF: &[(&str, &str)] = &[ ("home", "home"), ("library", "favorites"), @@ -291,6 +302,12 @@ pub fn crash_level() -> u8 { /// on Home every time. That is the "renders, persists, drives nothing" defect /// class this port has a name for. pub fn startup_view() -> String { + // The exact page, when page_restore_qt has one to reopen (it applies the + // same "remember" and crash-chain gates, plus the launcher-link and kiosk + // ones): its entry view is what the shell mounts. + if let Some(view) = crate::page_restore_qt::startup_view() { + return view; + } if crate::settings_qt::pref_str("startup_page", "home") != "remember" { return "home".to_string(); } @@ -306,6 +323,12 @@ pub fn startup_view() -> String { resolved.to_string() } +/// The history seed: the root beneath a restored page (so Back leads there), +/// else the startup view itself. +fn startup_root() -> String { + crate::page_restore_qt::startup_root().unwrap_or_else(startup_view) +} + /// The view the SHELL should mount on at this session entry. /// /// `init_shell_for_user` is MULTI-ENTRY (login, session restore, "Start @@ -325,7 +348,14 @@ pub fn shell_entry_view() -> String { } pub fn record(view: &str) { - record_entry(view, false); + record_entry(view, false, serde_json::Value::Null); +} + +/// `record` for an opener that can rebuild its page from arguments: they +/// travel with the entry and into page_restore_qt's document. `args` is a +/// JSON object of plain values (an id, a name, a kind). +pub fn record_with(view: &str, args: serde_json::Value) { + record_entry(view, false, args); } /// A user-selected Local Library tab is a distinct history destination even @@ -356,12 +386,12 @@ fn record_view_tab(view: &str, tab: &str, state: &str) { return; } set_live_state(view, state); - record_entry(view, true); + record_entry(view, true, serde_json::Value::Null); next["activeTab"] = serde_json::json!(tab); set_live_state(view, &next.to_string()); } -fn record_entry(view: &str, force: bool) { +fn record_entry(view: &str, force: bool, args: serde_json::Value) { // Persist BEFORE the history mutation so the write reflects the view the // user actually reached, and only for the safe set — a detail view leaves // the stored value on the last safe root, which is exactly what the @@ -369,6 +399,8 @@ fn record_entry(view: &str, force: bool) { if let Some(pref) = pref_for_view(view) { crate::settings_qt::save_pref("last_view", serde_json::json!(pref)); } + // The exact page, for "Where you left off" (page_restore_qt). + crate::page_restore_qt::note_page(view, &args); let (can_back, can_forward, current, pushed) = with_history(|h| { let mut pushed = false; if force || h.entries[h.index].view != view { @@ -390,6 +422,7 @@ fn record_entry(view: &str, force: bool) { scroll: 0.0, state_scope: String::new(), state_json: String::new(), + args, }); h.index += 1; pushed = true; @@ -445,7 +478,7 @@ pub fn forward() { /// move the cursor, and hand the destination's saved position to the shell so /// its scroll container can pick it up when it lays out. fn step(delta: isize) { - let (can_back, can_forward, current, scope, scroll, state_scope, state_json) = + let (can_back, can_forward, current, scope, scroll, state_scope, state_json, args) = with_history(|h| { let next = h.index as isize + delta; if next >= 0 && (next as usize) < h.entries.len() { @@ -468,12 +501,16 @@ fn step(delta: isize) { e.scroll, e.state_scope.clone(), e.state_json.clone(), + e.args.clone(), ) }); // The destination is now the live page: seed the live pair with what it is // about to restore to, so leaving it again before it has reported anything // does not stamp the OUTGOING page's offset onto it. set_live_scroll(&scope, scroll); + // The destination is the page again (its arguments travel on the entry); + // note it BEFORE the state, so the tab lands on the right page. + crate::page_restore_qt::note_page(¤t, &args); set_live_state(&state_scope, &state_json); // Arm only for a position worth restoring. A page that was at the top // needs no restore, and arming for 0 would leave a scope standing until diff --git a/crates/qbz-qt/src/page_restore_qt.rs b/crates/qbz-qt/src/page_restore_qt.rs new file mode 100644 index 000000000..9a27505c6 --- /dev/null +++ b/crates/qbz-qt/src/page_restore_qt.rs @@ -0,0 +1,650 @@ +//! Where the user left off — THE PAGE, not just its section (2026-09-13). +//! +//! `nav_qt`'s `last_view` pref remembers the last ROOT view, in the vocabulary +//! the Slint build shares through `ui_prefs.json`, so "Startup page = Where +//! you left off" could only ever reopen on Home, Library, Local Library, +//! Mixtapes or Collections: a session closed on an album came back on +//! Library > Releases. The rule now is the page the user was on — with its +//! arguments (the album, the artist, the playlist, the search query, the +//! Settings section…) and the tab it was showing. +//! +//! Two layers, both written as the user moves: +//! - `last_view` (`nav_qt::record_entry`) keeps its meaning and its shared +//! vocabulary: the last root, which is also the history seeded UNDER a +//! restored page (so Back has somewhere to go) and the fallback when the +//! page cannot come back. +//! - this module's `last_page_qt.json`, per profile beside +//! `local_navigation_qt.json`: `{"page": {root, view, args, state}}`. +//! `view` and `args` come from `nav_qt::record_with` — every restorable +//! opener records its own arguments — or from `note_args` for the pages +//! whose arguments live outside their route push (the Settings section, +//! the search query, the purchased album); `state` is the tab the mounted +//! view reported through `QbzShell.reportNavState`. +//! +//! The restore is ONE-SHOT per process and honours the gates the local album +//! restore honours: the pref must say "remember", a crash chain bypasses it +//! (nav_qt's ladder), an explicit launcher link outranks it, and the kiosk +//! keeps its own navigation model. The shell mounts the page's view at +//! construction (no Home flash), the history is seeded with the page's root, +//! and `restore()` re-runs the opener at session entry. A page that needs the +//! catalog is skipped while the session is offline, Purchases stays behind its +//! opt-in, and a page that cannot come back lands on the root instead. +//! +//! Local albums stay with `local_restore_qt`, which owns their filter context: +//! this module never stores `localalbum`. Leaving the app on a page that only +//! its opener could rebuild (`scene`, `musician`, `discobuilder`, +//! `metadataeditor`) keeps the previous restorable page in the document, so +//! the restart lands one step back rather than on Home. + +use serde_json::{json, Map, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +const FILE: &str = "last_page_qt.json"; +const MAX_ARGS_BYTES: usize = 8 * 1024; +const MAX_STATE_BYTES: usize = 64 * 1024; + +/// Routes whose page can be rebuilt from what is stored. Everything else in +/// nav_qt's id list is left out on purpose (see the module header). +const RESTORABLE: &[&str] = &[ + "home", + "library", + "local", + "mixtapes", + "collections", + "album", + "artist", + "playlist", + "label", + "labelreleases", + "mixtapedetail", + "award", + "awardalbums", + "mix", + "artistreleases", + "discoverbrowse", + "playlistbrowse", + "recentalbums", + "mostplayedalbums", + "purchases", + "purchase-album", + "settings", + "search", + "queue-view", + "blacklist", + "playlistmanager", + "offlinemanager", + "libraryfolders", +]; + +/// Pages that cannot show anything without the catalog: skipped while the +/// session is offline (a Qobuz playlist joins them at restore time; a local +/// one does not). +const NEEDS_CATALOG: &[&str] = &[ + "album", + "artist", + "label", + "labelreleases", + "award", + "awardalbums", + "mix", + "artistreleases", + "discoverbrowse", + "playlistbrowse", + "purchases", + "purchase-album", + "search", +]; + +/// The roots nav_qt persists as `last_view` (its `VIEW_TO_PREF` set). +const ROOTS: &[&str] = &["home", "library", "local", "mixtapes", "collections"]; + +/// The persisted page. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Page { + /// The last root view: the history seed and the fallback. + #[serde(default)] + pub root: String, + /// The route id the user was on. + #[serde(default)] + pub view: String, + /// The opener's arguments. + #[serde(default)] + pub args: Map, + /// The tab the mounted view reported, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state: Option>, +} + +impl Page { + fn valid(&self) -> bool { + RESTORABLE.contains(&self.view.as_str()) + && serde_json::to_string(&self.args).is_ok_and(|s| s.len() <= MAX_ARGS_BYTES) + && self + .state + .as_ref() + .is_none_or(|s| serde_json::to_string(s).is_ok_and(|t| t.len() <= MAX_STATE_BYTES)) + } + + /// The view the shell records at session entry. The two listings that + /// read their parent's state come back THROUGH that parent — the entry + /// records it, `restore` opens the parent and then the listing, and the + /// history reads root -> parent -> listing, the way it was reached. + pub fn entry_view(&self) -> &str { + match self.view.as_str() { + "labelreleases" => "label", + "awardalbums" => "award", + other => other, + } + } + + fn arg(&self, key: &str) -> String { + self.args + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + } + + fn arg_i64(&self, key: &str) -> i64 { + self.args.get(key).and_then(Value::as_i64).unwrap_or(0) + } + + fn tab(&self) -> Option { + self.state + .as_ref() + .and_then(|s| s.get("activeTab")) + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .map(str::to_string) + } +} + +// --------------------------------------------------------------------------- +// The live document (pure — the globals below wrap it) +// --------------------------------------------------------------------------- + +/// What the process currently believes the page is, plus arguments noted +/// for a route that has not been recorded yet (`note_args` before the QML +/// side pushes the route — the purchased album). +#[derive(Default)] +struct Live { + page: Option, + pending: Option<(String, Map)>, +} + +impl Live { + /// A route was recorded (`nav_qt::record_entry` / `step`). Returns the + /// page to persist when something changed. + fn note_page(&mut self, view: &str, args: &Value) -> Option { + let page = self.page.get_or_insert_with(Page::default); + let before = page.clone(); + if ROOTS.contains(&view) { + page.root = view.to_string(); + } + if RESTORABLE.contains(&view) { + let pending = self + .pending + .take() + .filter(|(pending_view, _)| pending_view == view) + .map(|(_, args)| args); + let explicit = match args { + Value::Object(map) if !map.is_empty() => Some(map.clone()), + _ => None, + }; + page.view = view.to_string(); + page.args = explicit.or(pending).unwrap_or_default(); + page.state = None; + } + (*page != before).then(|| page.clone()) + } + + /// Arguments for a route: applied in place when it is the current page, + /// otherwise kept for its next record. + fn note_args(&mut self, view: &str, args: Map) -> Option { + if serde_json::to_string(&args).is_ok_and(|s| s.len() > MAX_ARGS_BYTES) { + return None; + } + if let Some(page) = self.page.as_mut() { + if page.view == view { + if page.args == args { + return None; + } + page.args = args; + return Some(page.clone()); + } + } + self.pending = Some((view.to_string(), args)); + None + } + + /// The mounted view reported its state: only the tab is restart + /// material (filters and selections are history material, and Local + /// Library persists its own richer set). + fn note_state(&mut self, scope: &str, state: &str) -> Option { + if state.len() > MAX_STATE_BYTES { + return None; + } + let page = self.page.as_mut()?; + if page.view != scope { + return None; + } + let tab = serde_json::from_str::(state) + .ok() + .and_then(|v| v.get("activeTab").cloned()) + .filter(|t| t.as_str().is_some_and(|s| !s.is_empty())); + let next = tab.map(|t| Map::from_iter([("activeTab".to_string(), t)])); + if page.state == next { + return None; + } + page.state = next; + Some(page.clone()) + } +} + +static LIVE: Mutex = Mutex::new(Live { + page: None, + pending: None, +}); +static WRITE: Mutex<()> = Mutex::new(()); +static STARTUP: OnceLock> = OnceLock::new(); +static TAKEN: AtomicBool = AtomicBool::new(false); + +fn path() -> Option { + use qbz_app::user_data::UserDataPaths; + // nav_qt's tests push routes through the real `record`: they must never + // write into the developer's profile. + if cfg!(test) { + return None; + } + // Same identity as local_restore_qt, including the guest profile. + let user = UserDataPaths::load_last_user_id().unwrap_or(0); + Some(UserDataPaths::data_dir_for(user).ok()?.join(FILE)) +} + +fn save_at(path: &Path, page: &Page) { + let _guard = WRITE.lock().unwrap_or_else(|e| e.into_inner()); + let Some(mut doc) = crate::settings_qt::read_json_object(path) else { + return; + }; + let next = json!(page); + if doc.get("page") != Some(&next) { + doc.insert("page".into(), next); + crate::settings_qt::write_json_object_atomic(path, &doc); + } +} + +fn load_at(path: &Path) -> Option { + let doc = crate::settings_qt::read_json_object(path)?; + let page: Page = serde_json::from_value(doc.get("page")?.clone()).ok()?; + page.valid().then_some(page) +} + +fn persist(page: Option) { + if let (Some(page), Some(path)) = (page, path()) { + save_at(&path, &page); + } +} + +fn live(f: impl FnOnce(&mut Live) -> R) -> R { + f(&mut LIVE.lock().unwrap_or_else(|e| e.into_inner())) +} + +// --------------------------------------------------------------------------- +// Writers (nav_qt, the bridges) +// --------------------------------------------------------------------------- + +/// A route reached the top of the history (record) or the cursor moved onto +/// it (back/forward). `args` is the opener's argument object, or Null. +pub fn note_page(view: &str, args: &Value) { + if crate::kiosk_profile_qt::active() { + return; + } + persist(live(|l| l.note_page(view, args))); +} + +/// Arguments for a route that does not carry them on its route push. +pub fn note_args(view: &str, args: Value) { + if crate::kiosk_profile_qt::active() { + return; + } + let Value::Object(args) = args else { return }; + persist(live(|l| l.note_args(view, args))); +} + +/// The mounted view's live state (`nav_qt::set_live_state`). +pub fn note_state(scope: &str, state: &str) { + if crate::kiosk_profile_qt::active() { + return; + } + persist(live(|l| l.note_state(scope, state))); +} + +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +fn startup_page_at( + path: &Path, + remember: bool, + crash_level: u8, + link: bool, + kiosk: bool, +) -> Option { + if !remember || crash_level >= 2 || link || kiosk { + return None; + } + load_at(path) +} + +/// The page this process opens on, decided ONCE (the shell bridge asks at +/// construction, the history seed and the session entry ask later, and all +/// three must agree). `None` = the ordinary `last_view` startup. +fn startup_page() -> Option<&'static Page> { + STARTUP + .get_or_init(|| { + let path = path()?; + let page = startup_page_at( + &path, + crate::settings_qt::pref_str("startup_page", "home") == "remember", + crate::nav_qt::crash_level(), + crate::deep_link_qt::has_pending(), + crate::kiosk_profile_qt::active(), + )?; + log::info!( + "[qbz-qt] startup: remember -> page {:?} args {} tab {:?} (root {:?})", + page.view, + Value::Object(page.args.clone()), + page.tab(), + page.root + ); + Some(page) + }) + .as_ref() +} + +/// The view the shell mounts at construction and records at session entry. +pub fn startup_view() -> Option { + startup_page().map(|p| p.entry_view().to_string()) +} + +/// The root seeded beneath the restored page, so Back leads somewhere. +pub fn startup_root() -> Option { + startup_page() + .map(|p| p.root.clone()) + .filter(|root| ROOTS.contains(&root.as_str())) +} + +/// Only the first session entry of this process restores; a launcher link +/// that arrived after the shell was built still outranks the page. +pub fn take_startup_page() -> Option { + if TAKEN.swap(true, Ordering::AcqRel) { + return None; + } + if crate::deep_link_qt::has_pending() || crate::kiosk_profile_qt::active() { + return None; + } + startup_page().cloned() +} + +/// Re-run the page's opener at session entry. The entry view is already +/// recorded and hydrated by the caller; a detail's opener records the same +/// route again (a no-op push) and loads its document. `false` = the page +/// cannot come back and the caller lands on the root instead. +pub fn restore(page: &Page) -> bool { + let status = crate::offline_fwd::engine().status(); + let offline = status.is_offline(); + let id = page.arg("id"); + let needs_catalog = NEEDS_CATALOG.contains(&page.view.as_str()) + || (page.view == "playlist" && !crate::local_playlist_qt::is_local_id(&id)); + if offline && needs_catalog { + log::info!( + "[qbz-qt] startup: page {:?} needs the catalog and the session is offline; landing on the root", + page.view + ); + return false; + } + let with_id = |open: &dyn Fn(String)| -> bool { + if id.is_empty() { + return false; + } + open(id.clone()); + true + }; + match page.view.as_str() { + "home" | "library" => { + if let Some(tab) = page.tab() { + crate::navigate_to_tab(&page.view, &tab); + } + true + } + // Local Library restores its own tab (local_restore_qt); the MyQBZ + // grids were hydrated with the entry. + "local" | "mixtapes" | "collections" => true, + "album" => with_id(&crate::open_album), + "artist" => with_id(&crate::open_artist), + "playlist" => with_id(&crate::open_playlist), + "label" => with_id(&crate::label_qt::open_label), + "labelreleases" => { + if !with_id(&crate::label_qt::open_label) { + return false; + } + crate::label_qt::open_releases(); + true + } + "mixtapedetail" => with_id(&crate::myqbz_detail_qt::open), + "award" => { + let name = page.arg("name"); + with_id(&|id| crate::award_qt::open_award(id, name.clone())) + } + "awardalbums" => { + let name = page.arg("name"); + if !with_id(&|id| crate::award_qt::open_award(id, name.clone())) { + return false; + } + crate::award_qt::open_albums(); + true + } + "mix" => { + let kind = page.arg("kind"); + if kind.is_empty() { + return false; + } + crate::foryou_qt::open_mix(kind); + true + } + "artistreleases" => { + let artist_id = page.arg("artistId"); + if artist_id.is_empty() { + return false; + } + crate::artist_releases_qt::open( + artist_id, + page.arg("artistName"), + page.arg("releaseType"), + ); + true + } + "discoverbrowse" => { + let endpoint = page.arg("endpoint"); + if endpoint.is_empty() { + return false; + } + crate::browse_qt::open_discover_browse(endpoint, page.arg("title")); + true + } + "playlistbrowse" => { + crate::browse_qt::open_playlist_browse(); + true + } + "recentalbums" => { + crate::browse_qt::open_recent_albums(); + true + } + "mostplayedalbums" => { + crate::browse_qt::open_most_played_albums(); + true + } + // Opt-in surface: never reopen the app on a page whose own entry + // point is switched off. The list loads itself on mount. + "purchases" => crate::settings_qt::pref_bool("show_purchases", false), + "purchase-album" => { + if !crate::settings_qt::pref_bool("show_purchases", false) { + return false; + } + with_id(&crate::purchases_qt::open_album) + } + "settings" => { + // Same guard as the bridge's `settings_set_section`. + let section = page.arg_i64("section").clamp(0, 64) as i32; + let section = if section == 11 && !crate::orbit_qt::enabled() { + 0 + } else { + section + }; + crate::ui(move |mut b| b.as_mut().set_settings_section(section)); + true + } + "search" => { + let query = page.arg("query"); + if query.chars().count() < 2 { + return false; + } + let tab = page.arg_i64("tab").clamp(0, 16) as i32; + let runtime = crate::app(); + crate::spawn(async move { + crate::search_qt::submit(&runtime, &query, Some(tab)).await; + }); + true + } + // Mounted by the entry; reads live bridges or refreshes itself. + "queue-view" | "libraryfolders" => true, + "blacklist" => { + crate::blacklist_qt::open_manager(); + true + } + "playlistmanager" => { + crate::playlist_manager_qt::navigate(); + true + } + "offlinemanager" => { + crate::offline_manager_qt::open(); + true + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(view: &str) -> Page { + Page { + root: "library".into(), + view: view.into(), + args: Map::new(), + state: None, + } + } + + #[test] + fn a_detail_keeps_its_arguments_and_a_root_becomes_the_fallback() { + let mut live = Live::default(); + assert!(live.note_page("library", &Value::Null).is_some()); + let album = live + .note_page("album", &json!({"id": "123"})) + .expect("the page changed"); + assert_eq!(album.view, "album"); + assert_eq!(album.root, "library"); + assert_eq!(album.args.get("id").and_then(Value::as_str), Some("123")); + assert_eq!(album.entry_view(), "album"); + // The same push again changes nothing. + assert!(live.note_page("album", &json!({"id": "123"})).is_none()); + // A page only its opener could rebuild keeps the album on record. + assert!(live.note_page("scene", &Value::Null).is_none()); + assert_eq!(live.page.as_ref().unwrap().view, "album"); + // Back onto Home: root and view move together. + let home = live.note_page("home", &Value::Null).unwrap(); + assert_eq!((home.root.as_str(), home.view.as_str()), ("home", "home")); + assert!(home.args.is_empty()); + } + + #[test] + fn arguments_noted_before_the_route_push_ride_that_push() { + let mut live = Live::default(); + live.note_page("purchases", &Value::Null); + assert!(live + .note_args( + "purchase-album", + json!({"id": "9"}).as_object().unwrap().clone() + ) + .is_none()); + let detail = live.note_page("purchase-album", &Value::Null).unwrap(); + assert_eq!(detail.args.get("id").and_then(Value::as_str), Some("9")); + // On the current page they apply in place; unchanged ones are quiet. + live.note_page("settings", &Value::Null); + let settings = live + .note_args( + "settings", + json!({"section": 4}).as_object().unwrap().clone(), + ) + .unwrap(); + assert_eq!(settings.arg_i64("section"), 4); + assert!(live + .note_args( + "settings", + json!({"section": 4}).as_object().unwrap().clone() + ) + .is_none()); + } + + #[test] + fn only_the_tab_of_the_current_page_is_kept_from_a_state_report() { + let mut live = Live::default(); + live.note_page("library", &Value::Null); + let tabbed = live + .note_state("library", r#"{"activeTab":"albums","filter":"x"}"#) + .unwrap(); + assert_eq!(tabbed.tab().as_deref(), Some("albums")); + assert!(tabbed.state.as_ref().unwrap().get("filter").is_none()); + assert!(live + .note_state("home", r#"{"activeTab":"forYou"}"#) + .is_none()); + assert!(live + .note_state("library", "not json") + .is_some_and(|p| p.state.is_none())); + // A new page starts without a tab. + let album = live.note_page("album", &json!({"id": "1"})).unwrap(); + assert!(album.state.is_none()); + } + + #[test] + fn the_document_round_trips_and_the_gates_bypass_it_without_deleting_it() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("users/1/last_page_qt.json"); + let mut saved = page("labelreleases"); + saved.args.insert("id".into(), json!("77")); + save_at(&file, &saved); + assert_eq!( + startup_page_at(&file, true, 1, false, false), + Some(saved.clone()) + ); + assert_eq!(saved.entry_view(), "label"); + for (remember, crash, link, kiosk) in [ + (false, 1, false, false), + (true, 2, false, false), + (true, 1, true, false), + (true, 1, false, true), + ] { + assert!(startup_page_at(&file, remember, crash, link, kiosk).is_none()); + } + assert_eq!(startup_page_at(&file, true, 1, false, false), Some(saved)); + // An unrestorable or malformed page is ignored, never crashed on. + for bad in [json!({"view": "scene"}), json!({"view": 7}), json!("album")] { + std::fs::write(&file, json!({"page": bad}).to_string()).unwrap(); + assert!(startup_page_at(&file, true, 1, false, false).is_none()); + } + } +} diff --git a/crates/qbz-qt/src/playlist_picker_qt.rs b/crates/qbz-qt/src/playlist_picker_qt.rs index 6212e0040..9db4e6777 100644 --- a/crates/qbz-qt/src/playlist_picker_qt.rs +++ b/crates/qbz-qt/src/playlist_picker_qt.rs @@ -76,6 +76,11 @@ pub enum Payload { /// source-aware at insert time to a local path / an offline-copy Qobuz id) /// or `"plex:"` Plex rows. LocalRefs(Vec), + /// Both at once — the QUEUE saved as a playlist while it holds catalog + /// and local rows (queue_qt::save_as_playlist). Routed as the two + /// halves would be on their own; the confusion the enum exists to make + /// unrepresentable stays so, each half in its own typed slot. + Mixed { qobuz: Vec, local: Vec }, } impl Default for Payload { @@ -89,6 +94,7 @@ impl Payload { match self { Self::Qobuz(v) => v.len(), Self::LocalRefs(v) => v.len(), + Self::Mixed { qobuz, local } => qobuz.len() + local.len(), } } @@ -360,6 +366,18 @@ pub fn open_for_local_refs(runtime: &Runtime, refs: Vec) { open_payload(runtime, Payload::LocalRefs(refs)); } +/// Open the picker with catalog ids AND local refs — the queue saved as a +/// playlist. Either half may be empty, in which case this is the matching +/// single-kind open. +pub fn open_for_mixed(runtime: &Runtime, ids: Vec, refs: Vec) { + let qobuz: Vec = ids.iter().filter_map(|s| s.parse::().ok()).collect(); + match (qobuz.is_empty(), refs.is_empty()) { + (true, _) => open_payload(runtime, Payload::LocalRefs(refs)), + (false, true) => open_payload(runtime, Payload::Qobuz(qobuz)), + (false, false) => open_payload(runtime, Payload::Mixed { qobuz, local: refs }), + } +} + fn open_payload(runtime: &Runtime, payload: Payload) { if payload.is_empty() { return; @@ -426,6 +444,14 @@ async fn refresh_containment(epoch: u64) { Payload::LocalRefs(strings) => { crate::local_playlist_qt::membership_refs_blocking(strings) } + Payload::Mixed { qobuz, local } => { + let mut refs: Vec = qobuz + .iter() + .map(|&id| PlaylistTrackRef::Qobuz(id)) + .collect(); + refs.extend(crate::local_playlist_qt::membership_refs_blocking(local)); + refs + } }; // Only the Qobuz snapshot can be incomplete; the sidecar and local // tables are locally authoritative (QBZ is their only writer). @@ -648,6 +674,11 @@ pub fn pick(playlist_id: &str) { Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) } + // Cases 1 + 2 at once: the queue's mixed payload. + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&target, &local) + } }) .await .unwrap_or(0); @@ -663,6 +694,26 @@ pub fn pick(playlist_id: &str) { }; match payload { + // Cases 3 + 4 at once — the queue's MIXED payload: the local refs + // ride the sidecar right away, and the catalog ids take the Qobuz arm + // below (its duplicate check included) through one more pass of this + // function with only them carried. + Payload::Mixed { qobuz, local } => { + let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); + let sidecar_runtime = runtime.clone(); + crate::spawn(async move { + let written = write_sidecar_refs(pid, qobuz_count, local).await; + log::info!( + "[qbz-qt] playlist picker: {written} local row(s) filed on the sidecar of {pid}" + ); + crate::playlist_qt::refresh_after_membership_change(&sidecar_runtime, pid).await; + }); + { + let mut st = STATE.lock().unwrap(); + st.payload = Payload::Qobuz(qobuz); + } + pick(playlist_id); + } // Case 3 — local refs onto a QOBUZ playlist. They attach through the // library.db sidecar tables (the same tables the merged detail // renders), never through the API: a library row id posted to Qobuz @@ -820,6 +871,10 @@ pub fn create_and_add(name: &str) { Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &ids) } + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&new_id, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&new_id, &local) + } } }) .await @@ -887,6 +942,20 @@ pub fn create_and_add(name: &str) { toast_added(written, &name); crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; } + // The queue's mixed payload: local refs on the sidecar, + // catalog ids through the API, one toast for the ids. + Payload::Mixed { qobuz, local } => { + if !local.is_empty() { + let written = write_sidecar_refs(pid, 0, local).await; + log::info!( + "[qbz-qt] playlist picker: {written} local row(s) filed on the new playlist {pid}" + ); + } + if !qobuz.is_empty() { + add_and_toast(&runtime, pid, qobuz, name).await; + } + crate::playlist_qt::refresh_after_membership_change(&runtime, pid).await; + } _ => {} } close(); @@ -926,6 +995,10 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p Payload::Qobuz(ids) => { crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &ids) } + Payload::Mixed { qobuz, local } => { + crate::local_playlist_qt::add_qobuz_tracks_blocking(&target, &qobuz) + + crate::local_playlist_qt::add_local_refs_blocking(&target, &local) + } } }) .await @@ -935,13 +1008,25 @@ pub async fn add_dropped_payload_to_target(runtime: &Runtime, target_id: &str, p refresh_local_target(runtime, &target).await; } Some(crate::local_playlist_qt::PlaylistRef::Qobuz(pid)) => { - let Payload::LocalRefs(refs) = payload else { - return; + let (ids, refs) = match payload { + Payload::LocalRefs(refs) => (Vec::new(), refs), + Payload::Mixed { qobuz, local } => (qobuz, local), + Payload::Qobuz(_) => return, }; + let had_ids = !ids.is_empty(); let qobuz_count = crate::sidebar_qt::playlist_track_count(pid).unwrap_or(0); - let written = write_sidecar_refs(pid, qobuz_count, refs).await; - set_last_used(&pid.to_string(), written); - toast_added(written, ""); + let written = if refs.is_empty() { + 0 + } else { + write_sidecar_refs(pid, qobuz_count, refs).await + }; + if had_ids { + add_and_toast(runtime, pid, ids, String::new()).await; + } + set_last_used(&pid.to_string(), written.max(1)); + if written > 0 || !had_ids { + toast_added(written, ""); + } crate::playlist_qt::refresh_after_membership_change(runtime, pid).await; } None => {} diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index e28286a9c..393b842d8 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -458,7 +458,10 @@ mod sidecar_removal_tests { #[test] fn local_sidecar_is_the_numeric_row_id() { - assert!(matches!(sidecar_removal("local", "4321", None), Some(SidecarRemoval::Local(4321)))); + assert!(matches!( + sidecar_removal("local", "4321", None), + Some(SidecarRemoval::Local(4321)) + )); assert!(sidecar_removal("local", "local:nope", None).is_none()); } @@ -878,7 +881,12 @@ pub(crate) fn map_track(track: &Track) -> PlaylistTrackRow { .unwrap_or_default(); PlaylistTrackRow { position: 0, - featured: crate::album_qt::featured_list(track.performers.as_deref(), &artist, &track.title, &[]), + featured: crate::album_qt::featured_list( + track.performers.as_deref(), + &artist, + &track.title, + &[], + ), // Heart state at build time, from the favourite-id cache — the same // O(1) read `album_qt` / `artist_qt` / `label_qt` rows use. It was // never stamped here, so `TrackRow.qml` saw `undefined` on every @@ -1866,6 +1874,26 @@ pub async fn rename_by_id( .await .map_err(|e| format!("rename playlist {pid} failed: {e}"))?; + // The editor seeds a Qobuz playlist from the manager's WARM cache before + // it fetches (`playlist_manager_qt::cached_playlist_seed`), and the + // reload `after_write` orders lands later — or never, offline. Patch the + // cache now, so an editor reopened right after Save never shows the + // pre-save name or description (the dirty-form report of 2026-09-14). + { + let cache_name = name.clone(); + let cache_desc = description.map(|d| d.trim().to_string()); + if crate::playlist_manager_qt::patch_cache(|data| { + if let Some(p) = data.playlists.iter_mut().find(|p| p.id == pid) { + p.name = cache_name; + if let Some(desc) = cache_desc { + p.description = Some(desc); + } + } + }) { + crate::playlist_manager_qt::publish_document(); + } + } + let target = pid.to_string(); let patched = with_doc(|d| { if d.id != target { @@ -2067,6 +2095,69 @@ pub async fn remove_track(runtime: &Arc>, catalog_id: } } +/// `remove_track` for a SELECTION: every matching membership leaves the +/// document at once and the API is asked ONCE (`playlist/deleteTracks` takes +/// the list). Same optimistic removal, same reconcile-by-reload on failure. +pub async fn remove_tracks(runtime: &Arc>, catalog_ids: &[u64]) { + let Some((pid, memberships)) = with_doc(|d| { + if !d.is_owner { + return None; + } + let pid = d.id.parse::().ok()?; + let memberships: Vec = d + .tracks + .iter() + .filter(|t| { + t.id.parse::() + .ok() + .is_some_and(|id| catalog_ids.contains(&id)) + }) + .map(|t| t.playlist_track_id) + .collect(); + Some((pid, memberships)) + }) + .flatten() else { + return; + }; + if memberships.is_empty() { + return; + } + with_doc(|d| { + d.tracks + .retain(|t| !memberships.contains(&t.playlist_track_id)); + d.track_count = d.tracks.len() as i32; + d.total_duration = total_duration_label(&d.tracks); + let doc = d.clone(); + publish(&doc); + }); + match runtime + .core() + .remove_tracks_from_playlist(pid, &memberships) + .await + { + Ok(()) => { + let removed: Vec = catalog_ids.to_vec(); + let _ = tokio::task::spawn_blocking(move || { + crate::library_db_qt::with_db(true, |db| { + Ok(db.with_connection(|conn| { + qbz_library::qobuz_playlist_snapshot::apply_removed_tracks( + conn, pid, &removed, + ) + })) + }) + }) + .await; + } + Err(e) => { + log::error!( + "[qbz-qt] remove {} membership(s) from playlist {pid} failed: {e}", + memberships.len() + ); + let _ = load(runtime, pid).await; + } + } +} + /// The open document's row ids, IN THE ORDER THE VIEW RENDERS THEM. /// /// The Qt twin of the reference's `playlist::full_item_ids()` diff --git a/crates/qbz-qt/src/purchases_qt.rs b/crates/qbz-qt/src/purchases_qt.rs index a6a7becab..0f905e3ba 100644 --- a/crates/qbz-qt/src/purchases_qt.rs +++ b/crates/qbz-qt/src/purchases_qt.rs @@ -2366,6 +2366,9 @@ fn begin_open_album(album_id: String, verified_entitlement: Option>) { /// (or a synthetic ephemeral id), and the picker's Qobuz arm would add an /// unrelated catalog track under that number. /// -/// The local-mode ARM now exists (`playlist_picker_qt::open_for_local_refs`), -/// so the remaining blocker is narrower than the old note claimed: what this -/// module lacks is a source-aware row -> ref RESOLVER. A `QueueTrack`'s -/// `source_item_id_hint` is overloaded (`local_playback` documents it as the -/// album id on the non-mixtape enqueue paths), so it is not a safe Plex rating -/// key outside the local queue builder's own rows. PARITY-DEBT, recorded -/// rather than silently mixing the id spaces. +/// Every row the picker can file rides along, by SOURCE: a catalog row its +/// id, a local row (file, Plex, Jellyfin/Subsonic, offline copy) the ref +/// `local_playlist_qt::local_picker_ref_for_queue_track` builds, an +/// ephemeral row nothing. A queue of only local rows opens the picker in +/// local mode (its list is the local playlists, which is the whole list +/// offline); a queue of both kinds carries a MIXED payload the picker +/// routes as the two halves would be on their own. Until 2026-09-14 the +/// local rows were skipped, so an all-local queue opened nothing at all. pub async fn save_as_playlist(runtime: &Arc>) { let state = runtime.core().get_queue_state_full().await; + let (ids, refs, skipped) = + picker_payload_for(state.current_track.iter().chain(state.upcoming.iter())); + if skipped > 0 { + log::info!( + "[qbz-qt] queue save-as-playlist: skipped {skipped} row(s) with nothing to file \ + (ephemeral, or a local row without its key)" + ); + } + if ids.is_empty() && refs.is_empty() { + log::warn!("[qbz-qt] queue save-as-playlist: nothing to file"); + return; + } + crate::playlist_picker_qt::open_for_mixed(runtime, ids, refs); +} + +/// The picker payload for queue rows: catalog ids and local refs, each +/// de-duplicated, plus the count of rows that could not be filed. +fn picker_payload_for<'a>( + rows: impl Iterator, +) -> (Vec, Vec, usize) { let mut ids: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let mut refs: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + let mut seen_refs: HashSet = HashSet::new(); let mut skipped = 0usize; - for track in state.current_track.iter().chain(state.upcoming.iter()) { - if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { + for track in rows { + if crate::local_ephemeral::is_ephemeral_id(track.id as i64) { skipped += 1; continue; } - if seen.insert(track.id) { + if track.is_local { + match crate::local_playlist_qt::local_picker_ref_for_queue_track(track) { + Some(r) => { + if seen_refs.insert(r.clone()) { + refs.push(r); + } + } + None => skipped += 1, + } + continue; + } + if seen_ids.insert(track.id) { ids.push(track.id.to_string()); } } - if skipped > 0 { - log::info!( - "[qbz-qt] queue save-as-playlist: skipped {skipped} local/ephemeral row(s) — \ - no local-mode picker in this port" - ); - } - if ids.is_empty() { - return; - } - crate::playlist_picker_qt::open_for_ids(runtime, ids); + (ids, refs, skipped) } /// Open the picker seeded with ONE upcoming row (page-local `page_index`) — @@ -1541,17 +1566,22 @@ pub async fn add_to_playlist(runtime: &Arc>, page_ind log::warn!("[qbz-qt] queue: add_to_playlist {page_index} -> no upcoming track"); return; }; - if track.is_local || crate::local_ephemeral::is_ephemeral_id(track.id as i64) { - // Defensive: QueuePanel.qml drops the entry for `isLocal` and - // `isEphemeral` rows, so this is only reachable from a stale page - // index. Not "no local-mode picker" (that arm exists) — no - // source-aware row -> ref resolver here; see `save_as_playlist`. + if crate::local_ephemeral::is_ephemeral_id(track.id as i64) { log::warn!( - "[qbz-qt] queue: add_to_playlist {page_index} is a local/ephemeral row — \ - no source-aware ref resolver on the queue" + "[qbz-qt] queue: add_to_playlist {page_index} is an ephemeral row — nothing to file" ); return; } + if track.is_local { + // By source, the way the local surfaces do it (2026-09-14). + match crate::local_playlist_qt::local_picker_ref_for_queue_track(track) { + Some(r) => crate::playlist_picker_qt::open_for_local_refs(runtime, vec![r]), + None => log::warn!( + "[qbz-qt] queue: add_to_playlist {page_index} is a local row without a picker key" + ), + } + return; + } crate::playlist_picker_qt::open_for_ids(runtime, vec![track.id.to_string()]); } @@ -1624,6 +1654,49 @@ mod tests { } } + #[test] + fn save_as_playlist_files_every_source_it_can() { + let mut local = track(41, "file"); + local.is_local = true; + local.source = Some("local".into()); + let mut plex = track(4200, "plex"); + plex.is_local = true; + plex.source = Some("plex".into()); + plex.source_item_id_hint = Some("12345".into()); + let mut jelly = track(77, "jf"); + jelly.is_local = true; + jelly.source = Some("jellyfin".into()); + jelly.source_item_id_hint = Some("abc".into()); + let mut copy = track(900, "copy"); + copy.is_local = true; + copy.source = Some("qobuz_download".into()); + copy.source_item_id_hint = Some("58".into()); + let mut keyless = track(78, "lost"); + keyless.is_local = true; + keyless.source = Some("subsonic".into()); + let rows = [ + track(555, "q"), + local, + plex, + jelly, + copy, + keyless, + track(555, "q again"), + ]; + let (ids, refs, skipped) = picker_payload_for(rows.iter()); + assert_eq!(ids, vec!["555".to_string()]); + assert_eq!( + refs, + vec![ + "41".to_string(), + "plex:12345".to_string(), + "jellyfin:abc".to_string(), + "58".to_string() + ] + ); + assert_eq!(skipped, 1); + } + /// §4.4 JSON shape: {index, tracks:[{id,title,artist,artUrl}]} over the /// FULL flat queue, history OLDEST-first. #[test] diff --git a/crates/qbz-qt/src/search_qt.rs b/crates/qbz-qt/src/search_qt.rs index b3197ac55..1dab7e0de 100644 --- a/crates/qbz-qt/src/search_qt.rs +++ b/crates/qbz-qt/src/search_qt.rs @@ -2417,6 +2417,10 @@ async fn submit_page( *page = PageState::default(); page.doc.query = q.clone(); page.doc.tab = tab.unwrap_or(0); + crate::page_restore_qt::note_args( + "search", + serde_json::json!({ "query": &q, "tab": page.doc.tab }), + ); page.doc.loading = true; page.doc.local_revision = version.to_string(); publish_page(&page.doc); @@ -2744,6 +2748,10 @@ pub fn tab_changed(tab: i32) { let mut guard = PAGE.lock().unwrap(); if let Some(page) = guard.as_mut() { page.doc.tab = tab; + crate::page_restore_qt::note_args( + "search", + serde_json::json!({ "query": &page.doc.query, "tab": tab }), + ); let doc = page.doc.clone(); drop(guard); publish_page(&doc); diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 484c173b4..0c8058454 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -778,6 +778,34 @@ pub fn ambient_bar_alpha() -> f32 { /// Same one-document rule as `toggle_system_title_bar`: the current key is /// read inside the write closure, so a torn read can no longer make the app /// commit "ambient" over a user who had just turned it off (or the reverse). +/// Settings > Appearance > Background image > "Choose image…": the user's +/// own picture behind the wallpaper modes, outranking the desktop's. +pub fn pick_background_image() { + crate::spawn(async { + let Some(file) = rfd::AsyncFileDialog::new() + .set_title(qbz_i18n::t("Background image")) + .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp", "avif"]) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().to_string(); + save_pref("app_background_image", serde_json::json!(path)); + publish_snapshot().await; + crate::wallpaper_qt::refresh(); + }); +} + +/// "System wallpaper": drop the user's own picture. +pub fn clear_background_image() { + save_pref("app_background_image", serde_json::json!("")); + crate::spawn(async { + publish_snapshot().await; + crate::wallpaper_qt::refresh(); + }); +} + pub fn toggle_ambient_background() -> i32 { edit_prefs(|doc| { let current = doc @@ -1643,8 +1671,10 @@ const QCONNECT_CONFLICT_POLICY_LABELS: &[&str] = &[ "Continue local playback and replace the Qobuz Connect queue", ]; // Appearance option tables (AppearanceSettings.slint / ui_prefs.rs). -const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"]; -const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"]; +// 3 and 4 paint the desktop wallpaper (wallpaper_qt.rs): behind the window, +// or through the Blurred-art atmosphere pass. +const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art", "Wallpaper", "Wallpaper, blurred"]; +const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred", "wallpaper", "wallpaper-blurred"]; const LANGUAGE_LABELS: &[&str] = &[ "Auto", "English", @@ -2155,6 +2185,9 @@ pub struct SettingsDoc { pub app_background_modes: Vec, #[serde(rename = "appBackgroundIndex")] pub app_background_index: i32, + /// The user's own background image ("" = the desktop wallpaper). + #[serde(rename = "appBackgroundImage")] + pub app_background_image: String, #[serde(rename = "autoThemeSources")] pub auto_theme_sources: Vec, #[serde(rename = "autoThemeSourceIndex")] @@ -2714,6 +2747,7 @@ pub async fn publish_snapshot() { .iter() .position(|v| *v == pref_str("app_background", "off")) .unwrap_or(0) as i32, + app_background_image: pref_str("app_background_image", ""), auto_theme_sources: AUTO_THEME_SOURCE_LABELS .iter() .map(|l| qbz_i18n::t(l)) @@ -4225,6 +4259,8 @@ pub async fn settings_select(runtime: &Arc>, key: &st // ImmersiveAtmosphere for 2, exactly like AppShell.slint:213-231). let ambient = index as i32; crate::shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(ambient)); + // The wallpaper modes need their image (a no-op for the others). + crate::wallpaper_qt::refresh(); } "language" => { let Some(lang) = LANGUAGE_VALUES.get(index) else { diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index da00fb2bc..f03202b3f 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -296,6 +296,16 @@ pub mod qbz_shell { /// May the app-wide dynamic background be offered? /// Same tier, same reason (`main.rs:8563`); gates the whole picker row. #[qproperty(bool, app_background_available)] + /// The desktop wallpaper (or the user's own image) as a file:// URL, + /// and its atmosphere bitmap — the "wallpaper" background modes + /// (wallpaper_qt.rs). Empty until a mode that paints it resolves one. + #[qproperty(QString, wallpaper_url)] + #[qproperty(QString, wallpaper_atmosphere_url)] + /// This process's windows with their absolute geometry, as a Plasma + /// Wayland compositor reports them (`[{"x","y","w","h"}, …]`); + /// "[]" anywhere else. WallpaperField.qml picks the ApplicationWindow + /// out of it by size (wallpaper_wayland_qt.rs). + #[qproperty(QString, wallpaper_windows_json)] /// Is the kiosk touch shell the one currently mounted? /// /// The Qt counterpart of `ShellState.kiosk-profile` @@ -533,6 +543,13 @@ pub mod qbz_shell { /// Header history buttons. #[qinvokable] fn navigate_back(self: Pin<&mut QbzShell>); + /// Re-resolve the desktop wallpaper (window activation, a mode switch). + #[qinvokable] + fn refresh_wallpaper(self: Pin<&mut QbzShell>); + /// Start following this window's place on a Plasma Wayland desktop + /// (the Wallpaper mode on Wayland). Idempotent; a no-op elsewhere. + #[qinvokable] + fn track_window_position(self: Pin<&mut QbzShell>); #[qinvokable] fn navigate_forward(self: Pin<&mut QbzShell>); @@ -896,6 +913,9 @@ pub struct QbzShellRust { gpu_tier: bool, shader_scenes_available: bool, app_background_available: bool, + wallpaper_url: QString, + wallpaper_atmosphere_url: QString, + wallpaper_windows_json: QString, kiosk_profile: bool, kiosk_fullscreen_boot: bool, ambient_mode: i32, @@ -1011,6 +1031,9 @@ impl Default for QbzShellRust { gpu_tier: crate::renderer_qt::gpu_tier(), shader_scenes_available: crate::renderer_qt::gpu_tier(), app_background_available: crate::renderer_qt::gpu_tier(), + wallpaper_url: QString::default(), + wallpaper_atmosphere_url: QString::default(), + wallpaper_windows_json: QString::from("[]"), kiosk_profile: crate::kiosk_profile_qt::active(), kiosk_fullscreen_boot: crate::kiosk_profile_qt::active() && crate::kiosk_profile_qt::fullscreen_at_boot(), @@ -1433,6 +1456,14 @@ impl qbz_shell::QbzShell { crate::nav_qt::back(); } + pub fn refresh_wallpaper(self: Pin<&mut Self>) { + crate::wallpaper_qt::refresh(); + } + + pub fn track_window_position(self: Pin<&mut Self>) { + crate::wallpaper_qt::track_window_position(); + } + pub fn navigate_forward(self: Pin<&mut Self>) { crate::nav_qt::forward(); } diff --git a/crates/qbz-qt/src/track_info_qt.rs b/crates/qbz-qt/src/track_info_qt.rs index 27dae48c8..8007fb610 100644 --- a/crates/qbz-qt/src/track_info_qt.rs +++ b/crates/qbz-qt/src/track_info_qt.rs @@ -65,8 +65,13 @@ fn map(track: Track) -> TrackInfoDoc { .map(|(role, names)| CreditRow { role: format_role_label(&role).to_uppercase(), role_raw: role, - names, + // Catalog fillers ("Not Documented") are not people (2026-09-13). + names: names + .into_iter() + .filter(|name| !qbz_text_utils::names::is_placeholder_name(name)) + .collect(), }) + .filter(|row| !row.names.is_empty()) .collect(); TrackInfoDoc { diff --git a/crates/qbz-qt/src/wallpaper_qt.rs b/crates/qbz-qt/src/wallpaper_qt.rs new file mode 100644 index 000000000..864760139 --- /dev/null +++ b/crates/qbz-qt/src/wallpaper_qt.rs @@ -0,0 +1,425 @@ +//! The desktop wallpaper as the app-wide background (2026-09-14). +//! +//! Two `app_background` modes ride on it: "wallpaper" (3) makes the window +//! read as translucent to the desktop — ALWAYS the part of the wallpaper +//! that lies under the window, wherever it is moved, lightly blurred under +//! the half-alpha chrome (shell/WallpaperField.qml). The window's place on +//! the screen comes from Qt where the platform tells it (X11, macOS, +//! Windows) and from the compositor on KDE Plasma Wayland +//! (wallpaper_wayland_qt.rs, `org_kde_plasma_window_management`); where +//! neither can say, a centred crop stands in. "wallpaper-blurred" (4) is +//! Blurred art with the wallpaper in the cover's place: the same +//! ImmersiveAtmosphere, the same drift while the transport plays +//! (atmosphere_qt::for_cover_blocking builds its bitmap). +//! +//! The image is the system wallpaper, resolved per desktop — KDE Plasma's +//! `plasma-org.kde.plasma.desktop-appletsrc` (the containment on screen 0 +//! wins; a wallpaper PACKAGE resolves to its largest `contents/images/*` +//! bitmap, a slideshow folder to its first image), GNOME through `gsettings` +//! (the dark variant under a dark colour scheme), Windows' current +//! `TranscodedWallpaper`, macOS through System Events — or an image the user +//! chose (`app_background_image`), which outranks the desktop. `refresh()` +//! is cheap to call often: the resolved path and its mtime are memoised, and +//! nothing is republished while they hold. + +use cxx_qt_lib::QString; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; + +pub const MODE_WALLPAPER: i32 = 3; + +static LAST: Mutex)>> = Mutex::new(None); + +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp", "avif", "jxl"]; + +/// Does this `app_background` mode paint the wallpaper? +pub fn mode_uses_wallpaper(mode: i32) -> bool { + mode >= MODE_WALLPAPER +} + +/// Start following the window's place on a Plasma Wayland desktop +/// (wallpaper_wayland_qt.rs). Idempotent; nothing to start elsewhere. +pub fn track_window_position() { + #[cfg(target_os = "linux")] + crate::wallpaper_wayland_qt::start(); +} + +/// Resolve the wallpaper (the user's own image first) and publish its URL and +/// its atmosphere bitmap on the shell bridge. No-op while the mode does not +/// paint it, and quiet while the same file (same mtime) is already published. +pub fn refresh() { + if !mode_uses_wallpaper(crate::settings_qt::app_background_mode()) { + return; + } + crate::spawn(async { + let resolved = tokio::task::spawn_blocking(|| { + let custom = crate::settings_qt::pref_str("app_background_image", ""); + let path = if custom.trim().is_empty() { + resolve_system() + } else { + Some(PathBuf::from(custom.trim())) + }; + let path = path.filter(|p| p.is_file())?; + let mtime = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()); + { + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if last + .as_ref() + .is_some_and(|(p, t)| *p == path && *t == mtime) + { + return None; + } + *last = Some((path.clone(), mtime)); + } + let atmosphere = crate::atmosphere_qt::for_cover_blocking(&path.to_string_lossy()) + .unwrap_or_default(); + Some((file_url(&path), atmosphere)) + }) + .await + .ok() + .flatten(); + if let Some((url, atmosphere)) = resolved { + log::info!("[qbz-qt] wallpaper: {url}"); + crate::shell_bridge::ui(move |mut b| { + b.as_mut().set_wallpaper_url(QString::from(url.as_str())); + b.as_mut() + .set_wallpaper_atmosphere_url(QString::from(atmosphere.as_str())); + }); + } + }); +} + +/// The desktop's current wallpaper, if this desktop tells us. +pub fn resolve_system() -> Option { + #[cfg(target_os = "windows")] + return windows_wallpaper(); + #[cfg(target_os = "macos")] + return macos_wallpaper(); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + return kde_wallpaper().or_else(gnome_wallpaper); +} + +fn file_url(path: &Path) -> String { + let text = path.to_string_lossy(); + if text.starts_with('/') { + format!("file://{text}") + } else { + // Windows: `C:\a\b.png` -> `file:///C:/a/b.png`. + format!("file:///{}", text.replace('\\', "/")) + } +} + +/// `%20` and friends back to bytes (the desktops store wallpaper paths as +/// URLs). Anything malformed passes through untouched. +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = &s[i + 1..i + 3]; + if let Ok(v) = u8::from_str_radix(hex, 16) { + out.push(v); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// A wallpaper setting's value (`file:///…` or a bare path) to a path. +fn path_from_setting(value: &str) -> PathBuf { + let v = value.trim().trim_matches('\'').trim_matches('"'); + let v = v.strip_prefix("file://").unwrap_or(v); + PathBuf::from(percent_decode(v)) +} + +fn is_image(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) +} + +/// A file stays a file; a directory is either a KDE wallpaper PACKAGE +/// (`contents/images/.`, the largest wins) or a slideshow folder +/// (its first image, sorted). +fn resolve_image_source(path: PathBuf) -> Option { + if path.is_file() { + return Some(path); + } + if !path.is_dir() { + return None; + } + let package = path.join("contents").join("images"); + let dir = if package.is_dir() { package } else { path }; + let mut files: Vec = std::fs::read_dir(&dir) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && is_image(p)) + .collect(); + if files.is_empty() { + return None; + } + files.sort(); + let pixels = |p: &Path| -> u64 { + p.file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| { + let (w, h) = s.split_once('x')?; + Some(w.parse::().ok()? * h.parse::().ok()?) + }) + .unwrap_or(0) + }; + // The FIRST of equals wins (a slideshow folder has no sizes in its names), + // which `max_by_key` would not give: it returns the last maximum. + let mut best = files[0].clone(); + let mut best_pixels = pixels(&best); + for file in &files[1..] { + let px = pixels(file); + if px > best_pixels { + best = file.clone(); + best_pixels = px; + } + } + Some(best) +} + +fn section_ids(section: &str) -> Vec<&str> { + section + .trim_matches(|c| c == '[' || c == ']') + .split("][") + .collect() +} + +/// The image Plasma paints on screen 0 (else the lowest screen, else any): +/// `[Containments][N][Wallpaper][org.kde.image][General] Image=`, for the +/// containments whose wallpaper plugin is the image one. +fn kde_image_from_config(text: &str) -> Option { + let mut section = String::new(); + let mut screen_of: HashMap = HashMap::new(); + let mut plugin_of: HashMap = HashMap::new(); + let mut image_of: HashMap = HashMap::new(); + for raw in text.lines() { + let line = raw.trim(); + if line.starts_with('[') { + section = line.to_string(); + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let ids = section_ids(§ion); + if ids.len() == 2 && ids[0] == "Containments" { + match key { + "lastScreen" => { + if let Ok(n) = value.trim().parse::() { + screen_of.insert(ids[1].to_string(), n); + } + } + "wallpaperplugin" => { + plugin_of.insert(ids[1].to_string(), value.trim().to_string()); + } + _ => {} + } + } else if ids.len() == 5 + && ids[0] == "Containments" + && ids[2] == "Wallpaper" + && ids[3] == "org.kde.image" + && ids[4] == "General" + && key == "Image" + && !value.trim().is_empty() + { + image_of.insert(ids[1].to_string(), value.trim().to_string()); + } + } + let mut best: Option<(i32, String)> = None; + for (id, image) in image_of { + if plugin_of.get(&id).is_some_and(|p| p != "org.kde.image") { + continue; + } + let screen = screen_of.get(&id).copied().unwrap_or(i32::MAX); + let rank = if screen < 0 { i32::MAX } else { screen }; + if best.as_ref().is_none_or(|(r, _)| rank < *r) { + best = Some((rank, image)); + } + } + best.map(|(_, image)| image) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn kde_wallpaper() -> Option { + let config = dirs::config_dir()?.join("plasma-org.kde.plasma.desktop-appletsrc"); + let text = std::fs::read_to_string(config).ok()?; + let value = kde_image_from_config(&text)?; + resolve_image_source(path_from_setting(&value)) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn gsettings(schema: &str, key: &str) -> Option { + let out = std::process::Command::new("gsettings") + .args(["get", schema, key]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn gnome_wallpaper() -> Option { + let dark = gsettings("org.gnome.desktop.interface", "color-scheme") + .is_some_and(|v| v.contains("prefer-dark")); + let key = if dark { + "picture-uri-dark" + } else { + "picture-uri" + }; + let value = gsettings("org.gnome.desktop.background", key) + .filter(|v| !v.trim_matches('\'').is_empty()) + .or_else(|| gsettings("org.gnome.desktop.background", "picture-uri"))?; + resolve_image_source(path_from_setting(&value)) +} + +#[cfg(target_os = "windows")] +fn windows_wallpaper() -> Option { + let appdata = std::env::var_os("APPDATA")?; + let transcoded = PathBuf::from(appdata) + .join("Microsoft") + .join("Windows") + .join("Themes") + .join("TranscodedWallpaper"); + if transcoded.is_file() { + return Some(transcoded); + } + let out = std::process::Command::new("reg") + .args(["query", r"HKCU\Control Panel\Desktop", "/v", "WallPaper"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let line = text.lines().find(|l| l.contains("WallPaper"))?; + let value = line.split("REG_SZ").nth(1)?.trim(); + resolve_image_source(PathBuf::from(value)) +} + +#[cfg(target_os = "macos")] +fn macos_wallpaper() -> Option { + let out = std::process::Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to get picture of current desktop", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if value.is_empty() { + return None; + } + resolve_image_source(PathBuf::from(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const APPLETSRC: &str = r#" +[Containments][1] +activityId=abc +lastScreen=1 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image + +[Containments][1][Wallpaper][org.kde.image][General] +Image=file:///home/me/Pictures/Second%20Screen.png + +[Containments][2] +activityId=abc +lastScreen=0 +plugin=org.kde.plasma.folder +wallpaperplugin=org.kde.image + +[Containments][2][Wallpaper][org.kde.color][General] +Color=0,0,0 + +[Containments][2][Wallpaper][org.kde.image][General] +Image=file:///usr/share/wallpapers/MilkyWay/ + +[Containments][3] +lastScreen=0 +wallpaperplugin=org.kde.color + +[Containments][3][Wallpaper][org.kde.image][General] +Image=file:///home/me/stale.png +"#; + + #[test] + fn plasma_picks_the_image_containment_on_screen_zero() { + assert_eq!( + kde_image_from_config(APPLETSRC).as_deref(), + Some("file:///usr/share/wallpapers/MilkyWay/") + ); + // Without screen 0 the lowest screen wins; a colour containment never does. + let only_second = APPLETSRC.replace("lastScreen=0", "lastScreen=5"); + assert_eq!( + kde_image_from_config(&only_second).as_deref(), + Some("file:///home/me/Pictures/Second%20Screen.png") + ); + assert!(kde_image_from_config("[Containments][9]\nlastScreen=0\n").is_none()); + } + + #[test] + fn settings_values_become_decoded_paths() { + assert_eq!( + path_from_setting("file:///home/me/Pictures/Second%20Screen.png"), + PathBuf::from("/home/me/Pictures/Second Screen.png") + ); + assert_eq!( + path_from_setting("'file:///usr/share/backgrounds/a.jpg'"), + PathBuf::from("/usr/share/backgrounds/a.jpg") + ); + assert_eq!( + path_from_setting("/plain/path.png"), + PathBuf::from("/plain/path.png") + ); + assert_eq!(percent_decode("100%"), "100%"); + } + + #[test] + fn a_package_resolves_to_its_largest_bitmap_and_a_folder_to_its_first_image() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("MilkyWay"); + let images = package.join("contents").join("images"); + std::fs::create_dir_all(&images).unwrap(); + for name in ["1080x1920.png", "5120x2880.png", "notes.txt"] { + std::fs::write(images.join(name), b"x").unwrap(); + } + assert_eq!( + resolve_image_source(package.clone()), + Some(images.join("5120x2880.png")) + ); + let slides = temp.path().join("slides"); + std::fs::create_dir_all(&slides).unwrap(); + for name in ["b.jpg", "a.png", "readme.md"] { + std::fs::write(slides.join(name), b"x").unwrap(); + } + assert_eq!( + resolve_image_source(slides.clone()), + Some(slides.join("a.png")) + ); + let file = temp.path().join("one.webp"); + std::fs::write(&file, b"x").unwrap(); + assert_eq!(resolve_image_source(file.clone()), Some(file)); + assert!(resolve_image_source(temp.path().join("missing")).is_none()); + } +} diff --git a/crates/qbz-qt/src/wallpaper_wayland_qt.rs b/crates/qbz-qt/src/wallpaper_wayland_qt.rs new file mode 100644 index 000000000..b6f86cc79 --- /dev/null +++ b/crates/qbz-qt/src/wallpaper_wayland_qt.rs @@ -0,0 +1,380 @@ +//! Where OUR window sits on a Plasma Wayland desktop (2026-09-14). +//! +//! The "Wallpaper" background (wallpaper_qt.rs, mode 3) paints the part of +//! the desktop picture that lies UNDER the window, so moving the window slides +//! the picture the way a translucent window would. That needs the window's +//! place on the screen, and Wayland hides it from clients by design: Qt's +//! `QWindow::position()` is a fiction there. KDE Plasma does tell a client +//! where every window is — through `org_kde_plasma_window_management`, the +//! protocol its own task manager and window switchers use — and this module +//! reads it back for the windows of this process. +//! +//! WHY A SEPARATE CONNECTION. Qt owns the process's Wayland display and reads +//! it from its own event loop; sharing that `wl_display` would mean a foreign +//! event queue dispatched from a thread Qt does not know about. A second +//! client connection is ordinary Wayland (any process may hold several), costs +//! one socket, and lives on its own thread that blocks in `dispatch` and only +//! wakes when KWin has something to say. It reports through the shell bridge +//! (`wallpaperWindowsJson`), a JSON array of this pid's windows with their +//! absolute geometry, republished only when that array changes; +//! `shell/WallpaperField.qml` picks the one whose size is the +//! ApplicationWindow's and turns the global origin into a per-screen offset. +//! Nothing else in the app touches this connection. +//! +//! WHO GETS THE PROTOCOL. KWin blacklists `org_kde_plasma_window_management` +//! (`kwin/src/wayland_server.cpp`, `interfacesBlackList`) and hands it only to +//! a client whose executable — `/proc//exe` — is named by a `.desktop` +//! file whose `Exec` (first word, canonical ABSOLUTE path) is that binary and +//! which lists the interface in `X-KDE-Wayland-Interfaces` +//! (`kwin/src/utils/serviceutils.h`). The packaged desktop entries carry both +//! (`packaging/linux/qbz.desktop`: `Exec=/usr/bin/qbz`); a development binary +//! run from the target directory gets the protocol only with a user-level +//! desktop file pointing at it. Installs whose running binary no desktop file +//! can name never see it: Flatpak and Snap (sandboxed), the AppImage (a +//! per-run mount point), Nix (the wrapper script is not the binary that +//! runs). When the global is absent the `bind` +//! fails, one line is logged, and the crop stays centred — the same picture as +//! before this module, never a broken one. Other Wayland compositors have no +//! equivalent protocol and take the same fallback. +//! +//! The tracker starts lazily (`start()`, first use of mode 3 on a Wayland +//! session, from QML which knows the QPA platform) and runs for the life of +//! the process: geometry events arrive only when a window moves or resizes, +//! so an idle tracker costs nothing. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use cxx_qt_lib::QString; +use wayland_client::globals::{registry_queue_init, GlobalListContents}; +use wayland_client::protocol::wl_registry; +use wayland_client::{Connection, Dispatch, QueueHandle}; +use wayland_protocols_plasma::plasma_window_management::client::org_kde_plasma_window::{ + self, OrgKdePlasmaWindow, +}; +use wayland_protocols_plasma::plasma_window_management::client::org_kde_plasma_window_management::{ + self, OrgKdePlasmaWindowManagement, +}; + +/// `window_with_uuid` arrived with v13; `client_geometry` (the surface +/// without decorations, which is what the ApplicationWindow measures) with +/// v18. Everything in between is tolerated: a v13–v17 compositor reports the +/// frame geometry instead, which is the same rectangle for QBZ's frameless +/// window. +const MIN_VERSION: u32 = 13; +const MAX_VERSION: u32 = 18; + +static STARTED: AtomicBool = AtomicBool::new(false); + +/// One window of this process, as the compositor reports it. Absolute +/// (global, logical) coordinates. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct WindowRect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Everything the compositor has said about one mapped window, until its +/// `pid` proves it is not ours (then the proxy is destroyed and the entry +/// dropped). +#[derive(Debug, Default)] +struct Tracked { + pid: Option, + /// Frame geometry (`geometry`), the v13–v17 fallback. + frame: Option<(i32, i32, u32, u32)>, + /// Surface geometry (`client_geometry`, v18) — preferred. + client: Option<(i32, i32, u32, u32)>, +} + +struct State { + me: u32, + windows: HashMap, + dirty: bool, + /// What the bridge holds now: a move that ends where it started, or a + /// geometry event that repeats the frame, republishes nothing. + published: String, +} + +/// Start the tracker once per process. Safe to call on every mode switch: +/// every call after the first returns immediately. Outside a Wayland session +/// there is nothing to track and nothing is started. +pub fn start() { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + return; + } + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + if let Err(e) = std::thread::Builder::new() + .name("qbz-wl-windows".into()) + .spawn(run) + { + log::warn!("[qbz-qt] wallpaper: window tracker thread failed to start: {e}"); + STARTED.store(false, Ordering::SeqCst); + } +} + +fn run() { + let conn = match Connection::connect_to_env() { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-qt] wallpaper: no Wayland connection for the window tracker: {e}"); + return; + } + }; + let (globals, mut queue) = match registry_queue_init::(&conn) { + Ok(v) => v, + Err(e) => { + log::warn!("[qbz-qt] wallpaper: Wayland registry failed: {e}"); + return; + } + }; + let qh = queue.handle(); + let _manager: OrgKdePlasmaWindowManagement = + match globals.bind(&qh, MIN_VERSION..=MAX_VERSION, ()) { + Ok(m) => m, + Err(e) => { + log::info!( + "[qbz-qt] wallpaper: org_kde_plasma_window_management is not offered to this \ + binary ({e}); the crop stays centred (wallpaper_wayland_qt.rs)" + ); + return; + } + }; + let mut state = State { + me: std::process::id(), + windows: HashMap::new(), + dirty: false, + published: "[]".to_string(), + }; + loop { + if let Err(e) = queue.blocking_dispatch(&mut state) { + log::warn!("[qbz-qt] wallpaper: window tracker stopped: {e}"); + publish("[]".to_string()); + return; + } + if state.dirty { + state.dirty = false; + let json = to_json(&state.mine()); + if json != state.published { + state.published = json.clone(); + publish(json); + } + } + } +} + +impl State { + /// This process's windows, in a stable order (position, then size) so + /// two publishes of the same layout serialise identically. + fn mine(&self) -> Vec { + let mut out: Vec = self + .windows + .values() + .filter(|w| w.pid == Some(self.me)) + .filter_map(|w| { + let (x, y, width, height) = w.client.or(w.frame)?; + Some(WindowRect { + x, + y, + width, + height, + }) + }) + .collect(); + out.sort_by(|a, b| { + (a.x, a.y, a.width, a.height).cmp(&(b.x, b.y, b.width, b.height)) + }); + out + } +} + +/// The JSON the shell bridge carries: `[{"x","y","w","h"}, …]`. +pub(crate) fn to_json(rects: &[WindowRect]) -> String { + let items: Vec = rects + .iter() + .map(|r| { + serde_json::json!({ + "x": r.x, + "y": r.y, + "w": r.width, + "h": r.height, + }) + }) + .collect(); + serde_json::Value::Array(items).to_string() +} + +fn publish(json: String) { + crate::shell_bridge::ui(move |mut b| { + b.as_mut() + .set_wallpaper_windows_json(QString::from(json.as_str())); + }); +} + +// The registry: `registry_queue_init` collected the globals; nothing to do +// per event. +impl Dispatch for State { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + manager: &OrgKdePlasmaWindowManagement, + event: org_kde_plasma_window_management::Event, + _data: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + // A mapped window: ask for its object, keyed by the uuid the + // compositor gave it. Every window on the desktop announces itself + // here; the `pid` event sorts ours from the rest. + if let org_kde_plasma_window_management::Event::WindowWithUuid { uuid, .. } = event { + manager.get_window_by_uuid(uuid.clone(), qh, uuid.clone()); + state.windows.insert(uuid, Tracked::default()); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + window: &OrgKdePlasmaWindow, + event: org_kde_plasma_window::Event, + uuid: &String, + _conn: &Connection, + _qh: &QueueHandle, + ) { + use org_kde_plasma_window::Event; + let me = state.me; + let Some(tracked) = state.windows.get_mut(uuid) else { + return; + }; + match event { + Event::PidChanged { pid } => tracked.pid = Some(pid), + Event::Geometry { + x, + y, + width, + height, + } => { + tracked.frame = Some((x, y, width, height)); + state.dirty |= tracked.pid == Some(me); + } + Event::ClientGeometry { + x, + y, + width, + height, + } => { + tracked.client = Some((x, y, width, height)); + state.dirty |= tracked.pid == Some(me); + } + // The compositor has said what it knows about a new window + // (kwin sends `pid_changed` before this, `client_geometry` + // after it). Someone else's: let go of it now, so its later + // moves never reach this thread. + Event::InitialState => { + if tracked.pid != Some(me) { + state.windows.remove(uuid); + window.destroy(); + } else { + state.dirty = true; + } + } + Event::Unmapped => { + let mine = tracked.pid == Some(me); + state.windows.remove(uuid); + window.destroy(); + state.dirty |= mine; + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_carries_each_window_with_short_keys() { + let rects = vec![ + WindowRect { + x: 640, + y: 120, + width: 1720, + height: 980, + }, + WindowRect { + x: -10, + y: 0, + width: 320, + height: 96, + }, + ]; + let json = to_json(&rects); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["x"], 640); + assert_eq!(arr[0]["y"], 120); + assert_eq!(arr[0]["w"], 1720); + assert_eq!(arr[0]["h"], 980); + assert_eq!(arr[1]["x"], -10); + } + + #[test] + fn empty_tracker_publishes_an_empty_array() { + assert_eq!(to_json(&[]), "[]"); + } + + #[test] + fn only_this_process_windows_are_reported_and_client_geometry_wins() { + let mut state = State { + me: 4242, + windows: HashMap::new(), + dirty: false, + published: String::new(), + }; + state.windows.insert( + "a".into(), + Tracked { + pid: Some(4242), + frame: Some((100, 100, 800, 600)), + client: Some((104, 130, 792, 566)), + }, + ); + state.windows.insert( + "b".into(), + Tracked { + pid: Some(7), + frame: Some((0, 0, 500, 500)), + client: None, + }, + ); + state.windows.insert( + "c".into(), + Tracked { + pid: Some(4242), + frame: None, + client: None, + }, + ); + let mine = state.mine(); + assert_eq!(mine.len(), 1); + assert_eq!(mine[0].x, 104); + assert_eq!(mine[0].width, 792); + } +} diff --git a/crates/qbz-text-utils/src/lib.rs b/crates/qbz-text-utils/src/lib.rs index 708d3eb46..9687636d6 100644 --- a/crates/qbz-text-utils/src/lib.rs +++ b/crates/qbz-text-utils/src/lib.rs @@ -1,5 +1,6 @@ //! Frontend-agnostic text/date utilities extracted from the qbz binary. pub mod dates; +pub mod names; pub mod sleep; pub mod strip_html; diff --git a/crates/qbz-text-utils/src/names.rs b/crates/qbz-text-utils/src/names.rs new file mode 100644 index 000000000..a3f5e71b5 --- /dev/null +++ b/crates/qbz-text-utils/src/names.rs @@ -0,0 +1,66 @@ +//! Placeholder names the catalog uses where a real credit is missing. +//! +//! Qobuz ships literal fillers as artist / composer / performer names — +//! "Not Documented" is the one seen on album headers (2026-09-13), and the +//! usual "Unknown" / "N/A" family exists too. They are not people: a UI +//! that shows them prints junk beside the real artist and links to an +//! artist page that does not exist. Every credit list runs through +//! [`is_placeholder_name`] before it reaches a document. + +/// True for a name the catalog uses as "no data": matched case-insensitively +/// on the trimmed text, punctuation-insensitive for the hyphenated forms. +pub fn is_placeholder_name(name: &str) -> bool { + let folded: String = name + .trim() + .chars() + .map(|c| if c == '-' || c == '_' { ' ' } else { c.to_ascii_lowercase() }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + matches!( + folded.as_str(), + "" | "not documented" + | "undocumented" + | "unknown" + | "unknown artist" + | "unknown composer" + | "n/a" + | "na" + | "none" + | "null" + | "no artist" + | "no name" + | "[unknown]" + | "(unknown)" + ) +} + +#[cfg(test)] +mod tests { + use super::is_placeholder_name; + + #[test] + fn the_catalog_fillers_are_placeholders() { + for name in [ + "Not Documented", + "not documented", + "NOT-DOCUMENTED", + " Undocumented ", + "Unknown", + "Unknown Artist", + "N/A", + "", + " ", + ] { + assert!(is_placeholder_name(name), "{name:?}"); + } + } + + #[test] + fn real_names_pass() { + for name in ["Stream Of Passion", "Various Artists", "Roy Orbison", "Non Documenté"] { + assert!(!is_placeholder_name(name), "{name:?}"); + } + } +} diff --git a/flake.nix b/flake.nix index 799a5ae6d..bb9487bfb 100644 --- a/flake.nix +++ b/flake.nix @@ -87,6 +87,10 @@ install -Dm755 qbz "$out/bin/qbz" install -Dm644 qbz.desktop \ "$out/share/applications/com.blitzfc.qbz.desktop" + # KWin matches the Exec path against /proc//exe before it + # offers org_kde_plasma_window_management (Wallpaper background). + substituteInPlace "$out/share/applications/com.blitzfc.qbz.desktop" \ + --replace-warn "Exec=/usr/bin/qbz" "Exec=$out/bin/qbz" if [ -d icons ]; then cp -r icons "$out/share/icons"; fi install -Dm644 LICENSE "$out/share/licenses/qbz/LICENSE" if [ -d licenses ]; then diff --git a/packaging/arch/com.blitzfc.qbz.desktop b/packaging/arch/com.blitzfc.qbz.desktop index 93bf12922..aef3da006 100644 --- a/packaging/arch/com.blitzfc.qbz.desktop +++ b/packaging/arch/com.blitzfc.qbz.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz %u +Exec=/usr/bin/qbz %u Type=Application Icon=com.blitzfc.qbz Categories=AudioVideo;Audio;Music;Player; @@ -10,3 +10,7 @@ StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; MimeType=x-scheme-handler/qobuzapp; +# KWin hands org_kde_plasma_window_management (where this window is, for the +# Wallpaper background on Plasma Wayland) only to a binary named by an +# ABSOLUTE Exec that declares the interface here. +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management diff --git a/packaging/linux/qbz.desktop b/packaging/linux/qbz.desktop index f8ca2203a..58c964902 100644 --- a/packaging/linux/qbz.desktop +++ b/packaging/linux/qbz.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz %u +Exec=/usr/bin/qbz %u Icon=qbz Terminal=false Type=Application @@ -10,3 +10,7 @@ Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; StartupWMClass=com.blitzfc.qbz StartupNotify=true MimeType=x-scheme-handler/qobuzapp; +# KWin hands org_kde_plasma_window_management (where this window is, for the +# Wallpaper background on Plasma Wayland) only to a binary named by an +# ABSOLUTE Exec that declares the interface here. +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management diff --git a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml index 7894d5d98..c653af935 100644 --- a/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml +++ b/scripts/qml-tests/imports/com/blitzfc/qbz/QbzShell.qml @@ -7,4 +7,7 @@ QtObject { property string restoreScope: "" property real scrollRestore: 0 function reportScroll(scope, y) {} - property string themeJson: ""; property int ambientMode: 0 } + property string themeJson: ""; property int ambientMode: 0 + property string wallpaperWindowsJson: "[]" + property int trackRequests: 0 + function trackWindowPosition() { trackRequests++ } } diff --git a/scripts/qml-tests/tst_array_model.qml b/scripts/qml-tests/tst_array_model.qml new file mode 100644 index 000000000..50dde6d25 --- /dev/null +++ b/scripts/qml-tests/tst_array_model.qml @@ -0,0 +1,103 @@ +// QbzArrayModel: swapping the rows array must not rob the view's header of +// the keyboard (Qt 6.11 forces currentIndex 0 on a ListView.model assignment +// and focuses delegate 0), must keep the viewport where it was relative to +// the content origin, and scrollToTop() must land on the origin. +import QtQuick +import QtQml.Models +import Qt.labs.qmlmodels +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 600; height: 400 + property var cells: [] + function makeCells(n) { + var out = [] + for (var i = 0; i < n; i++) + out.push({ "kind": (i % 5 === 0) ? "track" : "gap", "i": i }) + return out + } + + ListView { + id: view + width: 600; height: 400 + clip: true + reuseItems: true + currentIndex: -1 + cacheBuffer: 800 + model: QbzArrayModel { id: rowsModel; view: view; rows: root.cells } + header: Item { + width: 600; height: 300 + TextInput { objectName: "field"; width: 200; height: 30; y: 250; text: "x" } + } + delegate: DelegateChooser { + role: "kind" + DelegateChoice { roleValue: "gap"; delegate: Item { width: 600; height: 10 } } + DelegateChoice { + roleValue: "track" + delegate: Item { + required property var modelData + width: 600; height: 10 + Rectangle { width: 600; height: 50; color: "#333" } + } + } + } + } + + TestCase { + name: "ArrayModel" + when: windowShown + function field() { return view.headerItem.children[0] } + function settle() { wait(0); waitForRendering(view); wait(0) } + + function test_a_swap_keeps_header_focus_and_current_index() { + root.cells = root.makeCells(200) + settle() + compare(view.count, 200) + var input = field() + input.forceActiveFocus() + verify(input.activeFocus) + root.cells = root.makeCells(150) + settle() + compare(view.count, 150) + compare(view.currentIndex, -1) + verify(input.activeFocus, "the header's field keeps the keyboard across a swap") + } + + function test_b_swap_keeps_viewport_relative_to_origin() { + root.cells = root.makeCells(200) + settle() + view.contentY = view.originY + 1000 + settle() + var before = view.contentY - view.originY + root.cells = root.makeCells(300) + settle() + fuzzyCompare(view.contentY - view.originY, before, 0.5) + compare(view.count, 300) + } + + function test_c_scroll_to_top_lands_on_the_origin() { + root.cells = root.makeCells(200) + settle() + view.contentY = view.originY + 900 + settle() + rowsModel.scrollToTop() + root.cells = root.makeCells(120) + settle() + fuzzyCompare(view.contentY, view.originY, 0.5) + compare(view.count, 120) + } + + function test_d_first_rows_do_not_move_the_viewport() { + root.cells = [] + settle() + compare(view.count, 0) + var y = view.contentY + root.cells = root.makeCells(80) + settle() + compare(view.count, 80) + fuzzyCompare(view.contentY, y, 0.5) + } + } +} diff --git a/scripts/qml-tests/tst_card_menu.qml b/scripts/qml-tests/tst_card_menu.qml new file mode 100644 index 000000000..0340542e2 --- /dev/null +++ b/scripts/qml-tests/tst_card_menu.qml @@ -0,0 +1,81 @@ +// CardMenu submenus: hovering the child must keep BOTH menus open (the +// rows' MouseAreas must count as menu hover), moving to a plain row in the +// parent closes the child only, and leaving both closes both. +import QtQuick +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 700; height: 500 + + Item { id: anchorItem; x: 120; y: 80; width: 10; height: 10 } + + CardMenu { + id: menu + entries: [ + { "label": "One", "icon": "", "action": "one" }, + { "label": "Copy", "icon": "", "action": "copy", "submenu": [ + { "label": "Sub A", "icon": "", "action": "sub-a" }, + { "label": "Sub B", "icon": "", "action": "sub-b" } + ] }, + { "label": "Three", "icon": "", "action": "three" } + ] + } + SignalSpy { id: picks; target: menu; signalName: "picked" } + + TestCase { + name: "CardMenuSubmenu" + when: windowShown + // Row i's centre inside a menu (5px padding, 33px rows). + function rowY(m, i) { return m.y + 5 + 33 * i + 16 } + + function test_a_hover_keeps_the_pair_open() { + menu.openAtCursor(anchorItem, 0, 0) + wait(50) + verify(menu.opened) + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + verify(menu.subOpen, "hovering the Copy row opens the child") + var sub = menu._sub + verify(sub.opened) + mouseMove(root, sub.x + 40, rowY(sub, 0)) + wait(600) + verify(menu.opened, "the parent stays open while the child's first row is hovered") + verify(sub.opened, "the child stays open while its first row is hovered") + mouseMove(root, sub.x + 40, rowY(sub, 1)) + wait(600) + verify(menu.opened && sub.opened, "still open on the child's second row") + // Back onto a plain parent row: the child goes away, the parent stays. + mouseMove(root, menu.x + 40, rowY(menu, 0)) + wait(120) + verify(menu.opened) + verify(!menu.subOpen, "a plain row puts the child away") + // Leave both: both close. + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + verify(menu.subOpen) + mouseMove(root, 650, 450) + wait(700) + verify(!menu.opened, "leaving both closes the parent") + } + + function test_b_picking_in_the_child_closes_everything_and_forwards() { + picks.clear() + menu.openAtCursor(anchorItem, 0, 0) + wait(50) + mouseMove(root, menu.x + 40, rowY(menu, 1)) + wait(120) + var sub = menu._sub + verify(sub && sub.opened) + mouseMove(root, sub.x + 40, rowY(sub, 1)) + wait(100) + mouseClick(root, sub.x + 40, rowY(sub, 1)) + wait(100) + compare(picks.count, 1) + compare(picks.signalArguments[0][0], "sub-b") + verify(!menu.opened) + verify(!sub.opened) + } + } +} diff --git a/scripts/qml-tests/tst_keyed_model.qml b/scripts/qml-tests/tst_keyed_model.qml new file mode 100644 index 000000000..6a3ffd137 --- /dev/null +++ b/scripts/qml-tests/tst_keyed_model.qml @@ -0,0 +1,405 @@ +// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates +// instead of a model swap. Guards the order and identity contract under +// random edits, the minimal-move reorder, the silent reset, a removal that +// keeps the viewport and the delegates, the fade that keeps its content, the +// pooled delegate that must come back opaque (76 blank rows, 2026-09-14), and +// the Repeater-in-a-Column shape the sidebar queue uses. +import QtQuick +import QtTest +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 400; height: 400 + + property var countedRows: [] + property var fadeRows: [] + property var plainRows: [] + property int created: 0 + property int addRuns: 0 + property int removeRuns: 0 + property int moveSignals: 0 + + function make(n, prefix) { + var out = [] + for (var i = 0; i < n; i++) + out.push({ "id": prefix + i, "title": "T" + prefix + i }) + return out + } + + // --- counted transitions: the reset / structure tests ------------------ + QbzKeyedModel { id: counted; rows: root.countedRows; views: [countedView] } + Connections { + target: counted + function onRowsMoved() { root.moveSignals++ } + } + ListView { + id: countedView + width: 200; height: 400 + model: counted + reuseItems: true + add: Transition { + enabled: counted.animate + ScriptAction { script: root.addRuns++ } + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 } + } + remove: Transition { + enabled: counted.animate + ScriptAction { script: root.removeRuns++ } + SequentialAnimation { + NumberAnimation { property: "opacity"; to: 0; duration: 60 } + PropertyAction { property: "opacity"; value: 1 } + } + } + displaced: QbzRowDisplaced { enabled: counted.animate; duration: 60 } + delegate: Item { + required property string rowKey + required property int rowRev + readonly property var modelData: counted.row(rowKey, rowRev) + width: 200; height: 20 + Component.onCompleted: root.created++ + ListView.onReused: { opacity = 1; scale = 1 } + } + } + + // --- the shipped transitions: fade / reuse tests ----------------------- + QbzKeyedModel { id: faded; rows: root.fadeRows; views: [fadeView] } + ListView { + id: fadeView + x: 200 + width: 200; height: 400 + model: faded + reuseItems: true + remove: QbzRowRemove { enabled: faded.animate; duration: 240 } + displaced: QbzRowDisplaced { enabled: faded.animate; duration: 240 } + delegate: Item { + required property string rowKey + required property int rowRev + readonly property var modelData: faded.row(rowKey, rowRev) + readonly property string shown: modelData.title || "" + width: 200; height: 20 + } + } + + // --- no view: reconcile arithmetic ------------------------------------- + QbzKeyedModel { id: plain; rows: root.plainRows } + + // --- a Repeater in a Column (the sidebar queue shape) ------------------- + property var columnRows: [] + property int columnAdds: 0 + property int columnMoves: 0 + QbzKeyedModel { id: columnModel; rows: root.columnRows; views: [column] } + Column { + id: column + x: 0; y: 0 + width: 200 + add: Transition { + enabled: columnModel.animate + ScriptAction { script: root.columnAdds++ } + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 } + } + move: Transition { + enabled: columnModel.animate + ScriptAction { script: root.columnMoves++ } + NumberAnimation { properties: "y"; duration: 120 } + } + Repeater { + id: columnRepeater + model: columnModel + delegate: Rectangle { + required property string rowKey + required property int rowRev + required property int index + readonly property var modelData: columnModel.row(rowKey, rowRev) + width: 200; height: 10 + color: "transparent" + } + } + } + + TestCase { + name: "KeyedModel" + when: windowShown + + function settle(view) { wait(0); waitForRendering(view); wait(0) } + function order(m) { + var out = [] + for (var i = 0; i < m.count; i++) + out.push(m.get(i).rowKey) + return out + } + function topKey(view) { + var it = view.itemAt(10, view.contentY + 1) + return it ? it.rowKey : "" + } + function prng(seed) { + var a = seed >>> 0 + return function () { + a = (a + 0x6D2B79F5) >>> 0 + var t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + function test_a_random_edits_reconcile_order_identity_and_revs() { + var rnd = prng(20260914) + var rows = root.make(60, "r") + var serial = 60 + root.plainRows = rows + plain.sync() + for (var step = 0; step < 400; step++) { + var next = rows.slice() + var kind = Math.floor(rnd() * 6) + if (kind === 0 && next.length > 0) { + next.splice(Math.floor(rnd() * next.length), 1 + Math.floor(rnd() * 3)) + } else if (kind === 1) { + var at = Math.floor(rnd() * (next.length + 1)) + var burst = [] + for (var b = 0; b < 1 + Math.floor(rnd() * 4); b++) + burst.push({ "id": "r" + (serial++), "title": "new" }) + Array.prototype.splice.apply(next, [at, 0].concat(burst)) + } else if (kind === 2 && next.length > 1) { + var from = Math.floor(rnd() * next.length) + var moved = next.splice(from, 1)[0] + next.splice(Math.floor(rnd() * (next.length + 1)), 0, moved) + } else if (kind === 3 && next.length > 0) { + // A repeat of an existing entity (the queue allows it). + var dup = next[Math.floor(rnd() * next.length)] + next.splice(Math.floor(rnd() * (next.length + 1)), 0, + { "id": dup.id, "title": dup.title }) + } else if (kind === 4 && next.length > 0) { + var ci = Math.floor(rnd() * next.length) + next[ci] = { "id": next[ci].id, "title": "changed " + step } + } else { + // Same data, all-new objects (a republished document). + next = next.map(function (r) { return { "id": r.id, "title": r.title } }) + } + var keys = plain.keysFor(next) + var revsBefore = ({}) + for (var k = 0; k < plain.count; k++) + revsBefore[plain.get(k).rowKey] = plain.get(k).rowRev + var before = ({}) + var prevKeys = plain.keysFor(rows) + for (var p = 0; p < rows.length; p++) + before[prevKeys[p]] = rows[p] + root.plainRows = next + plain.sync() + compare(order(plain), keys, "order after step " + step) + for (var i = 0; i < next.length; i++) { + verify(plain.row(keys[i]) === next[i], "identity " + keys[i]) + var prev = before[keys[i]] + if (prev !== undefined && revsBefore[keys[i]] !== undefined) { + var contentChanged = prev.title !== next[i].title + compare(plain.get(i).rowRev !== revsBefore[keys[i]], contentChanged, + "rev bump iff content changed: " + keys[i] + " step " + step) + } + } + rows = next + } + } + + function test_b_one_row_moved_far_is_one_move() { + root.moveSignals = 0 + var rows = root.make(100, "m") + root.countedRows = rows + counted.scope = "moves" + counted.sync() + settle(countedView) + root.moveSignals = 0 + var next = rows.slice() + next.splice(90, 0, next.splice(3, 1)[0]) + root.countedRows = next + counted.sync() + compare(root.moveSignals, 1, "down") + compare(order(counted), counted.keysFor(next)) + root.moveSignals = 0 + var back = next.slice() + back.splice(3, 0, back.splice(90, 1)[0]) + root.countedRows = back + counted.sync() + compare(root.moveSignals, 1, "up") + compare(order(counted), counted.keysFor(back)) + } + + function test_c_scope_change_resets_without_transitions_at_the_top() { + root.countedRows = root.make(300, "s") + counted.scope = "tab-a" + counted.sync() + settle(countedView) + countedView.contentY = countedView.originY + 2000 + settle(countedView) + var adds = root.addRuns + var removes = root.removeRuns + counted.scope = "tab-b" + root.countedRows = root.make(120, "t") + wait(0) + settle(countedView) + wait(150) + compare(counted.count, 120) + compare(root.addRuns, adds, "no add transition for a reset") + compare(root.removeRuns, removes, "no remove transition for a reset") + verify(countedView.atYBeginning, "a reset starts at the top") + compare(topKey(countedView), "t0") + } + + function test_c2_reset_from_empty_does_not_animate_either() { + counted.scope = "empty" + root.countedRows = [] + wait(0) + settle(countedView) + var adds = root.addRuns + counted.scope = "filled" + root.countedRows = root.make(50, "e") + wait(0) + settle(countedView) + wait(150) + compare(counted.count, 50) + compare(root.addRuns, adds, "the first page of a scope appears at once") + } + + function test_d_removal_keeps_the_viewport_and_the_delegates() { + var rows = root.make(400, "v") + root.countedRows = rows + counted.scope = "viewport" + counted.sync() + settle(countedView) + countedView.contentY = countedView.originY + 1000 + settle(countedView) + var top = topKey(countedView) + compare(top, "v50") + var made = root.created + var removes = root.removeRuns + // Ten rows above the viewport and one inside it leave. + root.countedRows = rows.filter(function (r, i) { return i >= 10 && i !== 55 }) + counted.sync() + settle(countedView) + wait(150) + compare(topKey(countedView), top, "the row at the top stays at the top") + verify(root.created - made <= 2, "no rebuild: " + (root.created - made) + " delegates created") + verify(root.removeRuns > removes, "the visible row animated out") + } + + function test_e_removed_row_fades_out_with_its_content() { + root.fadeRows = root.make(40, "f") + faded.scope = "fade" + faded.sync() + settle(fadeView) + root.fadeRows = root.make(40, "f").filter(function (r) { return r.id !== "f5" }) + faded.sync() + wait(80) + var ghost = null + for (var i = 0; i < fadeView.contentItem.children.length; i++) { + var ch = fadeView.contentItem.children[i] + if (ch.rowKey === "f5" && ch.opacity > 0.05 && ch.opacity < 0.99) + ghost = ch + } + verify(ghost !== null, "the leaving row is mid-fade") + compare(ghost.shown, "Tf5", "and still shows its own content") + wait(400) + } + + function test_f_pooled_rows_come_back_opaque() { + var rows = root.make(40, "p") + root.fadeRows = rows + faded.scope = "reuse" + faded.sync() + settle(fadeView) + for (var pass = 0; pass < 3; pass++) { + rows = rows.filter(function (r, i) { return i % 7 !== 3 }) + root.fadeRows = rows + faded.sync() + wait(400) + } + root.fadeRows = rows.concat(root.make(300, "q")) + faded.sync() + settle(fadeView) + var blank = [] + for (var y = 0; y < 5000; y += 150) { + fadeView.contentY = fadeView.originY + y + wait(20) + for (var c = 0; c < fadeView.contentItem.children.length; c++) { + var it = fadeView.contentItem.children[c] + if (it.rowKey === undefined || it.index === -1) + continue + if (fadeView.itemAtIndex(faded.indexOfKey(it.rowKey)) !== it) + continue + if (it.opacity < 0.99) + blank.push(it.rowKey) + } + } + compare(blank.length, 0, "rows reused from the pool are opaque: " + blank.slice(0, 8).join(" ")) + } + + function test_g_paused_holds_rows_until_it_clears() { + root.plainRows = root.make(5, "h") + plain.scope = "paused" + plain.sync() + plain.paused = true + root.plainRows = root.make(8, "h") + plain.sync() + compare(plain.count, 5) + plain.paused = false + wait(0) + compare(plain.count, 8) + } + + function test_h_ignored_keys_do_not_count_as_a_change() { + plain.ignoredKeys = ["_order"] + root.plainRows = [{ "id": "a", "title": "A", "_order": 0 }, { "id": "b", "title": "B", "_order": 1 }] + plain.scope = "ignored" + plain.sync() + var revA = plain.get(0).rowRev + root.plainRows = [{ "id": "a", "title": "A", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }] + plain.sync() + compare(plain.get(0).rowRev, revA) + root.plainRows = [{ "id": "a", "title": "A2", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }] + plain.sync() + verify(plain.get(0).rowRev !== revA) + plain.ignoredKeys = [] + } + + function test_j_column_repeater_resets_silently_and_slides_on_removal() { + columnModel.scope = "page-1" + root.columnRows = root.make(12, "c") + wait(0) + wait(150) + compare(columnRepeater.count, 12) + compare(root.columnAdds, 0, "the first page is not animated in") + var survivor = columnRepeater.itemAt(4) + // One row leaves: the rows below slide up, nothing is re-created. + root.columnRows = root.make(12, "c").filter(function (r) { return r.id !== "c2" }) + columnModel.sync() + wait(30) + verify(root.columnMoves > 0, "the rows below slide") + wait(200) + compare(columnRepeater.itemAt(3), survivor, "the same row item, one slot up") + compare(survivor.y, 30) + // A row joins at the top: it fades in. + var adds = root.columnAdds + root.columnRows = [{ "id": "new", "title": "N" }].concat(root.columnRows) + columnModel.sync() + wait(150) + verify(root.columnAdds > adds, "the new row fades in") + // Another page replaces the list without animation. + adds = root.columnAdds + columnModel.scope = "page-2" + root.columnRows = root.make(8, "d") + wait(0) + wait(150) + compare(columnRepeater.count, 8) + compare(root.columnAdds, adds, "a page change is not animated") + } + + function test_i_repeated_entities_are_distinct_rows() { + root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }] + plain.scope = "dups" + plain.sync() + compare(order(plain), ["x", "y", "x#1"]) + root.plainRows = [{ "id": "y" }, { "id": "x" }] + plain.sync() + compare(order(plain), ["y", "x"]) + } + } +} diff --git a/scripts/qml-tests/tst_library_keyed.qml b/scripts/qml-tests/tst_library_keyed.qml new file mode 100644 index 000000000..2a890a08f --- /dev/null +++ b/scripts/qml-tests/tst_library_keyed.qml @@ -0,0 +1,167 @@ +// The Library grid over QbzKeyedModel, with the REAL FeedGridCell delegate and +// LibraryView's key and transitions: an album leaving (a republished feed with +// all-new row objects, exactly what publish_library_document produces) must +// keep every other card's delegate and its decoded cover — no rebuild, no +// covers fading back in — and keep the page where it was. +import QtQuick +import QtTest +import com.blitzfc.qbz +import "../../crates/qbz-qt/qml/controls" +import "../../crates/qbz-qt/qml/views/library" as Library + +Item { + id: root + width: 880; height: 800 + + property var feed: [] + property int created: 0 + + QtObject { + id: host + property var artMap: ({}) + property bool showSourceBadges: false + property string activeTab: "albums" + property bool skelPhase: false + function askRemoveReleaseFavorites(item) {} + function isLocalFeedItem(item) { return false } + } + + function album(i) { + return { "kind": "album", "id": String(1000 + i), "artKey": "cover" + i, + "title": "Album " + i, "artist": "Artist", "artistId": "1", + "genre": "", "year": "2000", "qualityTier": "", "source": "qobuz", + "group": "favorites", "imageUrl": "fixture", "isFavorite": true, + "isPinned": false, "_membershipKey": "album:" + (1000 + i), + "_feedOrder": i } + } + // A republished document: same data, all-new objects, renumbered order. + function publish(ids) { + var out = [] + for (var i = 0; i < ids.length; i++) { + var row = album(ids[i]) + row._feedOrder = i + out.push(row) + } + root.feed = out + } + + QbzKeyedModel { + id: feedRows + rows: root.feed + keyOf: function (row) { + if (row.kind === "group-header") + return row.id + return (row._membershipKey || (row.kind + ":" + row.id)) + + (host.activeTab === "all" ? "|" + (row.group || "") : "") + } + scope: "albums" + ignoredKeys: ["_feedOrder"] + views: [grid] + } + + GridView { + id: grid + width: 880; height: 800 + cellWidth: 220; cellHeight: 266 + cacheBuffer: 266 * 2 + reuseItems: true + clip: true + model: feedRows + add: QbzRowAdd { enabled: feedRows.animate; grow: 0.94 } + remove: QbzRowRemove { enabled: feedRows.animate; shrink: 0.92 } + move: QbzRowDisplaced { enabled: feedRows.animate } + displaced: QbzRowDisplaced { enabled: feedRows.animate } + delegate: Library.FeedGridCell { + required property string rowKey + required property int rowRev + required property int index + readonly property var modelData: feedRows.row(rowKey, rowRev) + view: host + item: modelData + Component.onCompleted: root.created++ + } + } + + TestCase { + name: "LibraryKeyed" + when: windowShown + + function card(delegate) { + for (var i = 0; i < delegate.children.length; i++) { + var child = delegate.children[i] + if (child.item && child.item.artworkReady !== undefined) + return child.item + } + return null + } + // GridView fills its cache buffer a few cells per frame, so creation + // counts are only comparable once they stop moving. + function settleCreation() { + var last = -1 + var stable = 0 + while (stable < 3) { + wait(120) + if (root.created === last) + stable++ + else + stable = 0 + last = root.created + } + } + function delegateFor(key) { + var i = feedRows.indexOfKey(key) + return i < 0 ? null : grid.itemAtIndex(i) + } + + function test_album_leaving_keeps_cards_covers_and_page() { + var ids = [] + for (var i = 0; i < 120; i++) + ids.push(i) + var paths = ({}) + for (i = 0; i < 120; i++) + paths["cover" + i] = Qt.resolvedUrl("fixtures/red.ppm").toString() + host.artMap = paths + root.publish(ids) + wait(0) + waitForRendering(grid) + // Scroll to the fourth row of cards and let its covers decode. + grid.contentY = grid.originY + 3 * 266 + waitForRendering(grid) + var watched = ["album:1013", "album:1014", "album:1015", "album:1019"] + for (var w = 0; w < watched.length; w++) { + var key = watched[w] + tryVerify(function () { + var d = delegateFor(key) + return d !== null && card(d) !== null && card(d).artworkReady + }, 3000, "cover decoded for " + key) + } + settleCreation() + var before = ({}) + for (w = 0; w < watched.length; w++) + before[watched[w]] = delegateFor(watched[w]) + var madeBefore = root.created + var offsetBefore = grid.contentY - grid.originY + + // Album 1013 (on screen) leaves: the feed is republished. + root.publish(ids.filter(function (id) { return id !== 13 })) + wait(0) + wait(400) + waitForRendering(grid) + settleCreation() + + compare(feedRows.indexOfKey("album:1013"), -1) + var survivors = ["album:1014", "album:1015", "album:1019"] + for (w = 0; w < survivors.length; w++) { + var d = delegateFor(survivors[w]) + verify(d === before[survivors[w]], "same delegate for " + survivors[w]) + verify(card(d).artworkReady, "cover still decoded for " + survivors[w]) + compare(d.opacity, 1) + } + // One card left, so at most one row's worth of cells is new; a + // rebuild creates every visible and buffered cell again (20+). + verify(root.created - madeBefore <= 4, + "no rebuild: " + (root.created - madeBefore) + " cells created") + compare(grid.contentY - grid.originY, offsetBefore, "the page stays where it was") + } + } +} diff --git a/scripts/qml-tests/tst_wallpaper_field.qml b/scripts/qml-tests/tst_wallpaper_field.qml new file mode 100644 index 000000000..e162e0f38 --- /dev/null +++ b/scripts/qml-tests/tst_wallpaper_field.qml @@ -0,0 +1,85 @@ +// WallpaperField: the wallpaper crop sits under the window. On X11 / macOS / +// Windows the offset is the window's own position; on Wayland it is the +// compositor's rectangle for this window (picked by size out of this +// process's windows); with neither, the crop is centred. An invisible field +// reads nothing and asks for nothing. +import QtQuick +import QtQuick.Window +import QtTest +import com.blitzfc.qbz +import "../../crates/qbz-qt/qml/shell" + +Item { + id: root + width: 800; height: 600 + + QtObject { + id: fakeWindow + property real x: 300 + property real y: 100 + property real width: 800 + property real height: 600 + } + + WallpaperField { + id: field + width: 800; height: 600 + hostWindow: fakeWindow + source: Qt.resolvedUrl("fixtures/red.ppm") + isWayland: false + } + + TestCase { + name: "WallpaperField" + when: windowShown + + function init() { + QbzShell.wallpaperWindowsJson = "[]" + field.visible = true + field.isWayland = false + fakeWindow.x = 300 + fakeWindow.y = 100 + } + + function test_a_x11_follows_the_window_position() { + compare(field.offX, 300 - Screen.virtualX) + compare(field.offY, 100 - Screen.virtualY) + fakeWindow.x = 20 + compare(field.offX, 20 - Screen.virtualX) + } + + function test_b_wayland_uses_the_compositor_rect_for_this_window() { + var before = QbzShell.trackRequests + field.isWayland = true + verify(QbzShell.trackRequests > before, "asks Rust to follow the window") + // Without a report the crop is centred. + verify(!field.positionKnown) + compare(field.offX, Math.max(0, (field.screenW - 800) / 2)) + // Two of our windows: the miniplayer and the main window. + QbzShell.wallpaperWindowsJson = JSON.stringify([ + { "x": 10, "y": 10, "w": 320, "h": 96 }, + { "x": 640, "y": 120, "w": 800, "h": 600 } + ]) + verify(field.positionKnown) + compare(field.offX, 640 - Screen.virtualX) + compare(field.offY, 120 - Screen.virtualY) + // The window moved: the compositor says so. + QbzShell.wallpaperWindowsJson = JSON.stringify([{ "x": 900, "y": 40, "w": 800, "h": 600 }]) + compare(field.offX, 900 - Screen.virtualX) + } + + function test_c_wayland_ignores_windows_that_are_not_this_size() { + field.isWayland = true + QbzShell.wallpaperWindowsJson = JSON.stringify([{ "x": 10, "y": 10, "w": 320, "h": 96 }]) + verify(!field.positionKnown, "the miniplayer is not this window") + } + + function test_d_invisible_field_reads_nothing() { + field.isWayland = true + QbzShell.wallpaperWindowsJson = JSON.stringify([{ "x": 640, "y": 120, "w": 800, "h": 600 }]) + field.visible = false + compare(field.compositorRects.length, 0) + verify(!field.positionKnown) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index c6d17b6dc..f845cea3b 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -9,6 +9,10 @@ bash scripts/test-playback-cache-qml.sh bash scripts/test-text-edit-menu-qml.sh bash scripts/test-compact-seek-qml.sh bash scripts/test-library-folders-qml.sh +bash scripts/test-array-model-qml.sh +bash scripts/test-keyed-model-qml.sh +bash scripts/test-wallpaper-field-qml.sh +bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh node scripts/test_qt_local_views.mjs node scripts/test_qt_library.mjs diff --git a/scripts/test-array-model-qml.sh b/scripts/test-array-model-qml.sh new file mode 100755 index 000000000..cb8a1c85d --- /dev/null +++ b/scripts/test-array-model-qml.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# QbzArrayModel regression: a rows swap keeps the header's keyboard focus and +# the viewport (Qt 6.11 ListView.model assignment side effects). Same run in +# CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_array_model_bins="$QT_ROOT_DIR/bin" +else + qt_array_model_qmake="${QMAKE:-}" + if [[ -z "$qt_array_model_qmake" ]]; then + qt_array_model_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_array_model_bins="$("$qt_array_model_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_array_model_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_array_model.qml \ + -import scripts/qml-tests/imports diff --git a/scripts/test-card-menu-qml.sh b/scripts/test-card-menu-qml.sh new file mode 100755 index 000000000..3ea889ae9 --- /dev/null +++ b/scripts/test-card-menu-qml.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# CardMenu submenu regression: hovering the child keeps the pair open, +# leaving both closes both, a pick in the child forwards and closes all. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_card_menu_bins="$QT_ROOT_DIR/bin" +else + qt_card_menu_qmake="${QMAKE:-}" + if [[ -z "$qt_card_menu_qmake" ]]; then + qt_card_menu_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_card_menu_bins="$("$qt_card_menu_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_card_menu_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_card_menu.qml \ + -import scripts/qml-tests/imports diff --git a/scripts/test-keyed-model-qml.sh b/scripts/test-keyed-model-qml.sh new file mode 100755 index 000000000..f18d247db --- /dev/null +++ b/scripts/test-keyed-model-qml.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# QbzKeyedModel regression: rows reconcile into removes / inserts / moves / +# updates, a scope change resets silently at the top, a removal keeps the +# viewport and the delegates, pooled delegates come back opaque, and the +# Library grid keeps its cards and covers when an album leaves. Same run in CI +# and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_keyed_model_bins="$QT_ROOT_DIR/bin" +else + qt_keyed_model_qmake="${QMAKE:-}" + if [[ -z "$qt_keyed_model_qmake" ]]; then + qt_keyed_model_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_keyed_model_bins="$("$qt_keyed_model_qmake" -query QT_INSTALL_BINS)" +fi +for suite in tst_keyed_model tst_library_keyed; do + QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_keyed_model_bins/qmltestrunner" \ + -input "scripts/qml-tests/$suite.qml" \ + -import scripts/qml-tests/imports +done diff --git a/scripts/test-wallpaper-field-qml.sh b/scripts/test-wallpaper-field-qml.sh new file mode 100755 index 000000000..631cffad6 --- /dev/null +++ b/scripts/test-wallpaper-field-qml.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# WallpaperField regression: the Wallpaper background's crop follows the +# window — Qt's position on X11/macOS/Windows, the compositor's rectangle for +# this window on Plasma Wayland, centred otherwise — and an invisible field +# reads nothing. Same run in CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_wallpaper_bins="$QT_ROOT_DIR/bin" +else + qt_wallpaper_qmake="${QMAKE:-}" + if [[ -z "$qt_wallpaper_qmake" ]]; then + qt_wallpaper_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_wallpaper_bins="$("$qt_wallpaper_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_wallpaper_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_wallpaper_field.qml \ + -import scripts/qml-tests/imports