diff --git a/.github/workflows/test-crates.yml b/.github/workflows/test-crates.yml index 633a33692..39c5a556c 100644 --- a/.github/workflows/test-crates.yml +++ b/.github/workflows/test-crates.yml @@ -89,6 +89,7 @@ on: - "scripts/test-array-model-qml.sh" - "scripts/test-keyed-model-qml.sh" - "scripts/test-multi-select-qml.sh" + - "scripts/test-settings-fieldset-qml.sh" - "scripts/test-wallpaper-field-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" @@ -122,6 +123,7 @@ on: - "scripts/test-array-model-qml.sh" - "scripts/test-keyed-model-qml.sh" - "scripts/test-multi-select-qml.sh" + - "scripts/test-settings-fieldset-qml.sh" - "scripts/test-wallpaper-field-qml.sh" - "scripts/test-card-menu-qml.sh" - "scripts/test-orbit-daemon.py" 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 9350ec72b..7b63a7d54 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -7723,3 +7723,11 @@ 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." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Unschärfe des Hintergrundbilds" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "Wie stark das Hintergrundbild hinter dem Fenster weichgezeichnet wird: 0 % lässt es scharf, 100 % ist die stärkste Unschärfe." 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 89d4a7dfb..4a98d92cb 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -3378,3 +3378,11 @@ 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." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Wallpaper blur" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." 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 71487a0ae..42d63a1ba 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -7729,3 +7729,11 @@ 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." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Desenfoque del fondo de pantalla" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "Qué tan desenfocado se ve el fondo de pantalla detrás de la ventana: 0 % lo deja nítido y 100 % es el desenfoque máximo." 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 cf8555f94..fd06ad2be 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -7721,3 +7721,11 @@ 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." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Flou du fond d'écran" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "Intensité du flou du fond d'écran derrière la fenêtre : 0 % le laisse net, 100 % est le flou maximal." 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 1ff5a9aaf..c9ce5e137 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -7704,3 +7704,11 @@ msgstr "システムの壁紙" #: crates/qbz-qt/qml/views/PlaylistView.qml msgid "{} tracks will be removed from this playlist." msgstr "{} 曲をこのプレイリストから削除します。" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "壁紙のぼかし" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "ウィンドウの背後に見える壁紙のぼかしの強さ。0% でくっきり、100% で最も強くぼかします。" 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 442e84d83..0e1f272e1 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -7711,3 +7711,11 @@ msgstr "Systeemachtergrond" #: crates/qbz-qt/qml/views/PlaylistView.qml msgid "{} tracks will be removed from this playlist." msgstr "{} nummers worden uit deze afspeellijst verwijderd." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Vervaging van de bureaubladachtergrond" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "Hoe sterk de bureaubladachtergrond achter het venster wordt vervaagd: 0% houdt hem scherp, 100% is de sterkste vervaging." 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 a862cf6c7..a119c1b52 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -7721,3 +7721,11 @@ 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." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Desfoque do papel de parede" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "Quanto o papel de parede atrás da janela fica desfocado: 0% o mantém nítido, 100% é o desfoque máximo." 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 e17cb65d9..23ad50f7e 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -7742,3 +7742,11 @@ msgstr "Обои системы" #: crates/qbz-qt/qml/views/PlaylistView.qml msgid "{} tracks will be removed from this playlist." msgstr "Из этого плейлиста будет удалено {} треков." + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "Wallpaper blur" +msgstr "Размытие обоев" + +#: crates/qbz-qt/qml/settings/AppearanceSettings.qml +msgid "How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur." +msgstr "Насколько размыты обои за окном: 0 % — чётко, 100 % — максимальное размытие." diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 454c413de..6d1e16aec 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -628,6 +628,7 @@ fn main() { "qml/controls/IconTextButton.qml", "qml/controls/SettingsButton.qml", "qml/controls/SettingsDivider.qml", + "qml/controls/SettingsFieldset.qml", "qml/controls/SettingsSpacer.qml", // Moved out of views/local/ on 2026-07-31: the album/track // CARD badges mount it too, and Slint keeps its counterpart in diff --git a/crates/qbz-qt/qml/controls/SettingsFieldset.qml b/crates/qbz-qt/qml/controls/SettingsFieldset.qml new file mode 100644 index 000000000..f5fac0cbd --- /dev/null +++ b/crates/qbz-qt/qml/controls/SettingsFieldset.qml @@ -0,0 +1,101 @@ +// SettingsFieldset — a bordered, collapsible group of settings rows that +// belong to ONE control above them (the options of the selected theme). +// +// The border and the faint fill are what say "these rows are part of the +// same thing"; the header's chevron (> collapsed, v expanded) hides the body. +// The component does not own the collapsed state: the host passes the +// persisted value in and writes it back from `toggleRequested`, the same way +// the sidebar sections and the media-server panels keep theirs across +// restarts (settingsBool -> ui_prefs -> settingsJson). +// +// Rows go straight in as children; the body is a Column with a side inset, +// so a SettingRow still takes the full inner width. + +import QtQuick +import com.blitzfc.qbz +import "../theme" + +Rectangle { + id: root + + property bool kioskHost: false + property string title: "" + property bool collapsed: false + signal toggleRequested() + default property alias content: body.data + + QbzTheme { id: theme } + + readonly property int inset: kioskHost ? 16 : 12 + + width: parent ? parent.width : 0 + height: header.height + (collapsed ? 0 : body.height + inset) + radius: theme.radiusMd + color: theme.alphaTier(3) + border.width: 1 + border.color: theme.borderMuted + + Rectangle { + id: header + x: 1 + y: 1 + width: parent.width - 2 + height: root.kioskHost ? 52 : 40 + radius: theme.radiusMd + color: headerArea.containsMouse ? theme.elevatedHoverFill : "transparent" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: root.title + Accessible.onPressAction: root.toggleRequested() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.toggleRequested() + event.accepted = true + } + } + + Row { + anchors.left: parent.left + anchors.leftMargin: root.inset - 2 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + QbzIcon { + anchors.verticalCenter: parent.verticalCenter + name: root.collapsed ? "chevron-right" : "chevron-down" + width: root.kioskHost ? 20 : 16 + height: root.kioskHost ? 20 : 16 + tintName: "secondary" + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.title + color: theme.textPrimary + font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody + font.weight: theme.weightSemibold + } + } + + MouseArea { + id: headerArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: header.forceActiveFocus() + onClicked: root.toggleRequested() + } + } + + Column { + id: body + visible: !root.collapsed + x: root.inset + y: header.y + header.height + width: root.width - 2 * root.inset + spacing: 4 + } +} diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index 90869ca31..d4abbcc6d 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -98,6 +98,15 @@ Column { readonly property bool tbLocked: doc.hideTitleBar === true || doc.useSystemTitleBar === true // ============================ THEME ================================== + // ── EACH CHOICE'S OPTIONS SIT UNDER ITS OWN PICKER (2026-09-14) ──────── + // The rows that exist only for one choice used to trail the whole group: + // Custom's editor and Auto's source rows came after Album header + // gradient, Compact header and Dynamic background, and Background image + // (an option of the Wallpaper background) ran straight into the Custom + // editor, so it read as part of the theme. Now each picker is followed by + // ITS options, boxed in a SettingsFieldset whose chevron state persists: + // the theme's right under Theme, the background's right under Dynamic + // background. The rows themselves are unchanged. GroupHeader { kioskHost: root.kioskHost; text: QbzSession.tr("THEME", QbzSession.trRev) } SettingRow { kioskHost: root.kioskHost; label: QbzSession.tr("Theme", QbzSession.trRev) @@ -122,6 +131,113 @@ Column { } } } + /// The applied theme's display name, the legend of its options box. + readonly property string currentThemeLabel: { + for (let i = 0; i < themeEntries.length; i++) { + if (themeEntries[i].slug === QbzShell.themeSlug) return themeEntries[i].label + } + return "" + } + + // Auto-theme rows (the "auto" theme only). + SettingsFieldset { kioskHost: root.kioskHost; + visible: QbzShell.themeSlug === "auto" + title: root.currentThemeLabel + collapsed: root.doc.themeAutoCollapsed === true + onToggleRequested: QbzBridge.settingsBool("theme-auto-collapsed", root.doc.themeAutoCollapsed !== true) + + SettingRow { kioskHost: root.kioskHost; + label: QbzSession.tr("Source", QbzSession.trRev) + description: QbzSession.tr("Generate a color theme from your system wallpaper or a custom image", QbzSession.trRev) + QbzSelect { kioskHost: root.kioskHost; + menuWidth: 200 + options: root.doc.autoThemeSources || [] + currentIndex: root.doc.autoThemeSourceIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("auto-theme-source", i) } + } + } + // "Custom Image" only (source index 2) — AppearanceSettings.slint:283-292. + // The row's LABEL is the picked path, which is how the reference shows + // what is currently in use; with nothing picked it repeats the button's + // text. Both strings already exist in the catalogs. + SettingRow { kioskHost: root.kioskHost; + visible: (root.doc.autoThemeSourceIndex || 0) === 2 + label: (root.doc.autoThemeImagePath || "") !== "" + ? root.doc.autoThemeImagePath + : QbzSession.tr("Select Image...", QbzSession.trRev) + SettingsButton { kioskHost: root.kioskHost; + text: QbzSession.tr("Select Image...", QbzSession.trRev) + // The action seam for button rows is settingsString with an empty + // payload — the `library-pick-folder` precedent + // (LibraryFolderTable.qml:111). + onClicked: QbzBridge.settingsString("auto-theme-select-image", "") + } + } + // The detected desktop, with the experimental caveat under it + // (`:294-297`). Hint only — no control, and it hides when the shared + // detector cannot name a desktop. + SettingRow { kioskHost: root.kioskHost; + visible: (root.doc.autoThemeDetectedDe || "") !== "" + label: QbzSession.tr("Detected: ", QbzSession.trRev) + + (root.doc.autoThemeDetectedDe || "") + description: QbzSession.tr("Experimental: theme may not match your system exactly.", QbzSession.trRev) + } + SettingRow { kioskHost: root.kioskHost; + label: QbzSession.tr("Regenerate", QbzSession.trRev) + SettingsButton { kioskHost: root.kioskHost; + text: QbzSession.tr("Regenerate", QbzSession.trRev) + onClicked: QbzShell.themeSet(QbzShell.themeSlug) + } + } + } + + // Custom-theme editor rows (the appended "Custom" theme only), 1:1 with + // AppearanceSettings.slint:313-425. "Start from current theme" re-seeds + // the base from the applied palette; the "Dark theme" toggle flips + // polarity; the grid below edits each base token and the rest of the + // palette is derived live in Rust on every change. + // + // There is deliberately NO save / save-as, NO delete, NO named-theme list + // and NO import/export: the model is ONE implicit custom theme that + // autosaves, and the reference has none of those affordances either. + SettingsFieldset { kioskHost: root.kioskHost; + visible: QbzShell.themeSlug === "custom" + title: root.currentThemeLabel + collapsed: root.doc.themeCustomCollapsed === true + onToggleRequested: { + // A picker left open inside a folded box would come back open. + customThemeEditor.closePicker() + QbzBridge.settingsBool("theme-custom-collapsed", root.doc.themeCustomCollapsed !== true) + } + + SettingRow { kioskHost: root.kioskHost; + label: QbzSession.tr("Start from current theme", QbzSession.trRev) + description: QbzSession.tr("Copy the colors of the currently applied theme into the editor as a starting point.", QbzSession.trRev) + SettingsButton { kioskHost: root.kioskHost; + text: QbzSession.tr("Use current colors", QbzSession.trRev) + onClicked: { + QbzShell.customSeedFromCurrent() + customThemeEditor.closePicker() + } + } + } + SettingRow { kioskHost: root.kioskHost; + label: QbzSession.tr("Dark theme", QbzSession.trRev) + description: QbzSession.tr("Set the overall light or dark polarity. Affects derived shades, borders and overlays.", QbzSession.trRev) + QbzToggle { kioskHost: root.kioskHost; + checked: customThemeEditor.isDark + onToggled: function (v) { QbzShell.customToggleDark(v) } + } + } + // NOT inside a SettingRow: that control hardcodes 52/64px and centres one + // child, which would clip the ~250px picker. The reference keeps the grid + // outside its own rows for the same reason. + CustomThemeEditor { kioskHost: root.kioskHost; + id: customThemeEditor + visible: QbzShell.themeSlug === "custom" + } + } + SettingRow { kioskHost: root.kioskHost; label: QbzSession.tr("Album header gradient", QbzSession.trRev) description: QbzSession.tr("Use artwork-derived blur as a backdrop in album and artist detail views.", QbzSession.trRev) @@ -148,113 +264,65 @@ 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" - label: QbzSession.tr("Source", QbzSession.trRev) - description: QbzSession.tr("Generate a color theme from your system wallpaper or a custom image", QbzSession.trRev) - QbzSelect { kioskHost: root.kioskHost; - menuWidth: 200 - options: root.doc.autoThemeSources || [] - currentIndex: root.doc.autoThemeSourceIndex || 0 - onSelected: function (i) { QbzBridge.settingsSelect("auto-theme-source", i) } - } - } - // "Custom Image" only (source index 2) — AppearanceSettings.slint:283-292. - // The row's LABEL is the picked path, which is how the reference shows - // what is currently in use; with nothing picked it repeats the button's - // text. Both strings already exist in the catalogs. - SettingRow { kioskHost: root.kioskHost; - visible: QbzShell.themeSlug === "auto" - && (root.doc.autoThemeSourceIndex || 0) === 2 - label: (root.doc.autoThemeImagePath || "") !== "" - ? root.doc.autoThemeImagePath - : QbzSession.tr("Select Image...", QbzSession.trRev) - SettingsButton { kioskHost: root.kioskHost; - text: QbzSession.tr("Select Image...", QbzSession.trRev) - // The action seam for button rows is settingsString with an empty - // payload — the `library-pick-folder` precedent - // (LibraryFolderTable.qml:111). - onClicked: QbzBridge.settingsString("auto-theme-select-image", "") - } - } - // The detected desktop, with the experimental caveat under it - // (`:294-297`). Hint only — no control, and it hides when the shared - // detector cannot name a desktop. - SettingRow { kioskHost: root.kioskHost; - visible: QbzShell.themeSlug === "auto" - && (root.doc.autoThemeDetectedDe || "") !== "" - label: QbzSession.tr("Detected: ", QbzSession.trRev) - + (root.doc.autoThemeDetectedDe || "") - description: QbzSession.tr("Experimental: theme may not match your system exactly.", QbzSession.trRev) - } - SettingRow { kioskHost: root.kioskHost; - visible: QbzShell.themeSlug === "auto" - label: QbzSession.tr("Regenerate", QbzSession.trRev) - SettingsButton { kioskHost: root.kioskHost; - text: QbzSession.tr("Regenerate", QbzSession.trRev) - onClicked: QbzShell.themeSet(QbzShell.themeSlug) - } - } + // The options of the chosen background: only the two Wallpaper modes + // (3 Wallpaper, 4 Wallpaper, blurred) have any. + readonly property int backgroundIndex: root.doc.appBackgroundIndex || 0 + SettingsFieldset { kioskHost: root.kioskHost; + visible: root.backgroundIndex >= 3 + title: (root.doc.appBackgroundModes || [])[root.backgroundIndex] || "" + collapsed: root.doc.ambientOptionsCollapsed === true + onToggleRequested: QbzBridge.settingsBool("ambient-options-collapsed", root.doc.ambientOptionsCollapsed !== true) - // Custom-theme editor rows (the appended "Custom" theme only), 1:1 with - // AppearanceSettings.slint:313-425. "Start from current theme" re-seeds - // the base from the applied palette; the "Dark theme" toggle flips - // polarity; the grid below edits each base token and the rest of the - // palette is derived live in Rust on every change. - // - // There is deliberately NO save / save-as, NO delete, NO named-theme list - // and NO import/export: the model is ONE implicit custom theme that - // autosaves, and the reference has none of those affordances either. - SettingRow { kioskHost: root.kioskHost; - visible: QbzShell.themeSlug === "custom" - label: QbzSession.tr("Start from current theme", QbzSession.trRev) - description: QbzSession.tr("Copy the colors of the currently applied theme into the editor as a starting point.", QbzSession.trRev) - SettingsButton { kioskHost: root.kioskHost; - text: QbzSession.tr("Use current colors", QbzSession.trRev) - onClicked: { - QbzShell.customSeedFromCurrent() - customThemeEditor.closePicker() + // The picture behind the two Wallpaper modes: the desktop's, or one of + // the user's own (wallpaper_qt.rs). + SettingRow { kioskHost: root.kioskHost; + 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() + } } } - } - SettingRow { kioskHost: root.kioskHost; - visible: QbzShell.themeSlug === "custom" - label: QbzSession.tr("Dark theme", QbzSession.trRev) - description: QbzSession.tr("Set the overall light or dark polarity. Affects derived shades, borders and overlays.", QbzSession.trRev) - QbzToggle { kioskHost: root.kioskHost; - checked: customThemeEditor.isDark - onToggled: function (v) { QbzShell.customToggleDark(v) } + // Wallpaper mode only: how blurred the X-ray view of the desktop is. + // Qt's own range for MultiEffect.blur, 0.0-1.0, shown as 0-100 %; the + // default is the 0.75 the mode shipped with (settings_qt.rs). The drag + // moves the live background; the value is stored on release. + SettingRow { kioskHost: root.kioskHost; + visible: root.backgroundIndex === 3 + label: QbzSession.tr("Wallpaper blur", QbzSession.trRev) + description: QbzSession.tr("How much the wallpaper behind the window is blurred: 0% keeps it sharp, 100% is the strongest blur.", QbzSession.trRev) + Row { + spacing: 12 + QbzSlider { kioskHost: root.kioskHost; + id: wallpaperBlurSlider + anchors.verticalCenter: parent.verticalCenter + minimum: 0 + maximum: 100 + value: Math.round(QbzShell.wallpaperBlur * 100) + onChanged: function (v) { QbzShell.wallpaperBlur = v / 100 } + onReleased: function (v) { QbzBridge.settingsSlider("wallpaper-blur", v) } + } + Text { + anchors.verticalCenter: parent.verticalCenter + width: 40 + horizontalAlignment: Text.AlignRight + text: wallpaperBlurSlider.value + "%" + color: theme.textSecondary + font.pixelSize: root.kioskHost ? theme.fontBody * 1.2 : theme.fontBody + } + } } } - // NOT inside a SettingRow: that control hardcodes 52/64px and centres one - // child, which would clip the ~250px picker. The reference keeps the grid - // outside its own rows for the same reason. - CustomThemeEditor { kioskHost: root.kioskHost; - id: customThemeEditor - visible: QbzShell.themeSlug === "custom" - } SettingsSpacer { } SettingsDivider { } diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 885c04551..631e83573 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -140,7 +140,9 @@ Rectangle { visible: root.wallpaperModeOn source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : "" hostWindow: root.hostWindow - blur: 0.75 + // The user's choice (Settings > Appearance, 0-100 % of Qt's 0.0-1.0), + // live while that slider drags; 0.75 by default. + blur: QbzShell.wallpaperBlur dim: theme.isDark ? QbzShell.ambientDim : 0.0 } Rectangle { diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 0c8058454..5943761e4 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -665,6 +665,30 @@ pub fn app_background_mode() -> i32 { .unwrap_or(0) as i32 } +/// The Wallpaper mode's blur, in MultiEffect's own units: Qt documents +/// `blur` from 0.0 (no blur) to 1.0 (full blur), reached at the field's +/// `blurMax` of 64 (shell/WallpaperField.qml). 0.75 is the look the mode +/// shipped with and stays the default; Settings > Appearance edits it as +/// 0-100 %. +pub const WALLPAPER_BLUR_DEFAULT: f32 = 0.75; + +pub fn wallpaper_blur() -> f32 { + clamp_wallpaper_blur(pref_f32("wallpaper_blur", WALLPAPER_BLUR_DEFAULT)) +} + +/// Qt's range, whatever a hand-edited ui_prefs.json holds; NaN is the default. +fn clamp_wallpaper_blur(value: f32) -> f32 { + if value.is_nan() { + return WALLPAPER_BLUR_DEFAULT; + } + value.clamp(0.0, 1.0) +} + +/// The Settings slider's 0-100 % step as the stored Qt value. +fn wallpaper_blur_from_percent(percent: i32) -> f32 { + percent.clamp(0, 100) as f32 / 100.0 +} + /// Live-tuning knobs (Slint AppearanceState defaults; the QBZ_BG_* envs are /// the same dev knobs the Slint seeds at startup). pub fn ambient_dim() -> f32 { @@ -2188,6 +2212,15 @@ pub struct SettingsDoc { /// The user's own background image ("" = the desktop wallpaper). #[serde(rename = "appBackgroundImage")] pub app_background_image: String, + /// Persisted fieldset state of the Appearance > Theme groups (default + /// expanded): the Custom theme editor, the Auto theme rows, and the + /// options of the selected dynamic background. + #[serde(rename = "themeCustomCollapsed")] + pub theme_custom_collapsed: bool, + #[serde(rename = "themeAutoCollapsed")] + pub theme_auto_collapsed: bool, + #[serde(rename = "ambientOptionsCollapsed")] + pub ambient_options_collapsed: bool, #[serde(rename = "autoThemeSources")] pub auto_theme_sources: Vec, #[serde(rename = "autoThemeSourceIndex")] @@ -2748,6 +2781,9 @@ pub async fn publish_snapshot() { .position(|v| *v == pref_str("app_background", "off")) .unwrap_or(0) as i32, app_background_image: pref_str("app_background_image", ""), + theme_custom_collapsed: pref_bool("theme_custom_collapsed", false), + theme_auto_collapsed: pref_bool("theme_auto_collapsed", false), + ambient_options_collapsed: pref_bool("ambient_options_collapsed", false), auto_theme_sources: AUTO_THEME_SOURCE_LABELS .iter() .map(|l| qbz_i18n::t(l)) @@ -3689,6 +3725,19 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, save_pref("myqbz_collections_collapsed", serde_json::json!(value)); Ok(Apply::None) } + // Appearance > Theme fieldsets, read back from settingsJson. + "theme-custom-collapsed" => { + save_pref("theme_custom_collapsed", serde_json::json!(value)); + Ok(Apply::None) + } + "theme-auto-collapsed" => { + save_pref("theme_auto_collapsed", serde_json::json!(value)); + Ok(Apply::None) + } + "ambient-options-collapsed" => { + save_pref("ambient_options_collapsed", serde_json::json!(value)); + Ok(Apply::None) + } "sidebar-playlist-collage" => { save_pref("sidebar_playlist_collage", serde_json::json!(value)); // LIVE: the sidebar rows swap collage <-> list-music glyph off the @@ -4409,6 +4458,15 @@ pub async fn settings_select(runtime: &Arc>, key: &st } pub async fn settings_slider(runtime: &Arc>, key: &str, value: i32) { + if key == "wallpaper-blur" { + // Persisted on the slider's release; the drag itself already moved + // QbzShell.wallpaperBlur for the live preview. + let blur = wallpaper_blur_from_percent(value); + // Written from the whole percent as f64: an f32 widened on the way to + // JSON would store 0.62 as 0.6200000047683716. + save_pref("wallpaper_blur", serde_json::json!(f64::from(value.clamp(0, 100)) / 100.0)); + crate::shell_bridge::ui(move |mut b| b.as_mut().set_wallpaper_blur(blur)); + } if key == "buffer-seconds" { let seconds = value.clamp(1, 10) as u8; match with_audio(|s| s.set_stream_buffer_seconds(seconds)) { @@ -4882,6 +4940,46 @@ mod local_tab_order_tests { } } +#[cfg(test)] +mod wallpaper_blur_tests { + use super::*; + + #[test] + fn the_slider_percent_maps_onto_qts_blur_range() { + assert_eq!(wallpaper_blur_from_percent(0), 0.0); + assert_eq!(wallpaper_blur_from_percent(75), 0.75); + assert_eq!(wallpaper_blur_from_percent(100), 1.0); + assert_eq!(wallpaper_blur_from_percent(-20), 0.0); + assert_eq!(wallpaper_blur_from_percent(250), 1.0); + } + + #[test] + fn a_hand_edited_value_is_held_to_qts_range() { + assert_eq!(clamp_wallpaper_blur(1.7), 1.0); + assert_eq!(clamp_wallpaper_blur(-0.2), 0.0); + assert_eq!(clamp_wallpaper_blur(0.4), 0.4); + assert_eq!(clamp_wallpaper_blur(f32::NAN), WALLPAPER_BLUR_DEFAULT); + } +} + +#[cfg(test)] +mod theme_fieldset_doc_tests { + use super::*; + + #[test] + fn the_settings_document_names_the_fieldset_states_for_qml() { + let doc = SettingsDoc { + theme_custom_collapsed: true, + ambient_options_collapsed: true, + ..Default::default() + }; + let json = serde_json::to_value(&doc).expect("settings document serializes"); + assert_eq!(json["themeCustomCollapsed"], true); + assert_eq!(json["themeAutoCollapsed"], false); + assert_eq!(json["ambientOptionsCollapsed"], true); + } +} + #[cfg(test)] mod exclusive_gate_tests { use super::*; diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index f03202b3f..b0fd26b36 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -346,6 +346,10 @@ pub mod qbz_shell { #[qproperty(QString, ambient_accent)] // Look knobs (Slint AppearanceState defaults; QBZ_BG_* env seed). #[qproperty(f32, ambient_dim)] + /// The Wallpaper mode's blur, MultiEffect units 0.0-1.0 + /// (settings_qt::wallpaper_blur). Settings writes it while the slider + /// drags, for a live preview, and persists it on release. + #[qproperty(f32, wallpaper_blur)] /// Debug knob QBZ_BG_SCALE — RETIRED 2026-08-13: the ambient field /// renders inline now (no offscreen target to scale), and the knob /// measured as a non-lever anyway. Kept to avoid bridge churn. @@ -923,6 +927,7 @@ pub struct QbzShellRust { ambient_secondary: QString, ambient_accent: QString, ambient_dim: f32, + wallpaper_blur: f32, ambient_scale: f32, viz_tick_ms: i32, pane_layer: bool, @@ -1043,6 +1048,7 @@ impl Default for QbzShellRust { ambient_secondary: QString::from("#9632ff"), ambient_accent: QString::from("#3fd9c8"), ambient_dim: crate::settings_qt::ambient_dim(), + wallpaper_blur: crate::settings_qt::wallpaper_blur(), ambient_scale: crate::settings_qt::ambient_scale(), viz_tick_ms: crate::settings_qt::viz_tick_ms(), pane_layer: crate::settings_qt::pane_layer(), diff --git a/scripts/qml-tests/tst_settings_fieldset.qml b/scripts/qml-tests/tst_settings_fieldset.qml new file mode 100644 index 000000000..973c84745 --- /dev/null +++ b/scripts/qml-tests/tst_settings_fieldset.qml @@ -0,0 +1,81 @@ +// SettingsFieldset: the bordered, collapsible group that holds the options of +// ONE settings control (Appearance > Theme: the theme's editor, the Wallpaper +// background's image and blur). The host owns the persisted collapsed state; +// the fieldset shows or hides its rows and asks for the toggle. +import QtQuick +import QtTest +import com.blitzfc.qbz +import "../../crates/qbz-qt/qml/controls" + +Item { + id: root + width: 700; height: 600 + + property bool storedCollapsed: false + property int toggles: 0 + + Column { + width: 600 + SettingsFieldset { + id: fieldset + title: "Custom" + collapsed: root.storedCollapsed + onToggleRequested: { + root.toggles++ + root.storedCollapsed = !root.storedCollapsed + } + Rectangle { id: rowA; width: parent.width; height: 52; color: "transparent" } + Rectangle { id: rowB; width: parent.width; height: 64; color: "transparent" } + } + Rectangle { id: below; width: 600; height: 20; color: "transparent" } + } + + TestCase { + name: "SettingsFieldset" + when: windowShown + + function init() { + root.storedCollapsed = false + root.toggles = 0 + wait(0) + } + + function test_expanded_shows_its_rows_inside_the_box() { + verify(rowA.visible && rowB.visible) + compare(rowA.width, fieldset.width - 2 * fieldset.inset, "rows take the inner width") + var bottom = rowB.mapToItem(fieldset, 0, rowB.height).y + verify(bottom <= fieldset.height, "the last row sits inside the border") + compare(below.y, fieldset.height, "the next setting starts after the box") + } + + function test_header_click_asks_for_the_toggle_and_folds_the_body() { + var expandedHeight = fieldset.height + mouseClick(fieldset, 60, 20) + compare(root.toggles, 1) + verify(fieldset.collapsed) + verify(!rowA.visible && !rowB.visible, "a folded box hides its rows") + verify(fieldset.height < expandedHeight) + // Column lays out in the next polish pass. + tryCompare(below, "y", fieldset.height, 1000, "the settings below move up") + mouseClick(fieldset, 60, 20) + compare(root.toggles, 2) + verify(rowA.visible) + compare(fieldset.height, expandedHeight) + } + + function test_keyboard_toggles_from_the_focused_header() { + mouseClick(fieldset, 60, 20) + compare(root.toggles, 1) + keyClick(Qt.Key_Space) + compare(root.toggles, 2) + keyClick(Qt.Key_Return) + compare(root.toggles, 3) + } + + function test_a_click_on_a_row_does_not_toggle() { + var p = rowA.mapToItem(fieldset, 20, 20) + mouseClick(fieldset, p.x, p.y) + compare(root.toggles, 0) + } + } +} diff --git a/scripts/qt-runtime-gate.sh b/scripts/qt-runtime-gate.sh index 26ad26890..ed550f610 100644 --- a/scripts/qt-runtime-gate.sh +++ b/scripts/qt-runtime-gate.sh @@ -16,6 +16,7 @@ bash scripts/test-library-folders-qml.sh bash scripts/test-array-model-qml.sh bash scripts/test-keyed-model-qml.sh bash scripts/test-multi-select-qml.sh +bash scripts/test-settings-fieldset-qml.sh bash scripts/test-wallpaper-field-qml.sh bash scripts/test-card-menu-qml.sh bash scripts/test-orbit-qml.sh diff --git a/scripts/test-settings-fieldset-qml.sh b/scripts/test-settings-fieldset-qml.sh new file mode 100755 index 000000000..5e48f3875 --- /dev/null +++ b/scripts/test-settings-fieldset-qml.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# SettingsFieldset regression: the collapsible options box of Appearance > +# Theme shows its rows inside the border, folds them on a header click or key, +# moves the settings below, and leaves the persisted state to its host. Same +# run in CI and locally. +set -euo pipefail +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.." +if [[ -n "${QT_ROOT_DIR:-}" ]]; then + qt_fieldset_bins="$QT_ROOT_DIR/bin" +else + qt_fieldset_qmake="${QMAKE:-}" + if [[ -z "$qt_fieldset_qmake" ]]; then + qt_fieldset_qmake="$(command -v qmake6 || command -v qmake)" + fi + qt_fieldset_bins="$("$qt_fieldset_qmake" -query QT_INSTALL_BINS)" +fi +QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + "$qt_fieldset_bins/qmltestrunner" \ + -input scripts/qml-tests/tst_settings_fieldset.qml \ + -import scripts/qml-tests/imports