From 7ca6c9fa805bd289b24bb3c70eead1c17da08acc Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:08:25 -0600 Subject: [PATCH 1/2] fix(qt): a theme's options sit under its picker, boxed and collapsible In Settings > Appearance the rows that exist only for one theme trailed the whole THEME group: the Custom editor and the Auto source rows came after Album header gradient, Compact header and Dynamic background. With a Wallpaper background, Background image then ran straight into the Custom editor and read as one of its options. Each choice's options now follow their own picker inside a SettingsFieldset: a bordered group with a header whose chevron folds it (> folded, v open). The Custom and Auto theme options sit right under Theme, Custom starting at "Start from current theme", and the Wallpaper background's Background image sits under Dynamic background. The rows themselves are unchanged. Each box keeps its folded state across restarts through settingsBool and ui_prefs, the way the sidebar sections do (theme-custom-collapsed, theme-auto-collapsed, ambient-options-collapsed; default open). Folding the Custom box also closes an open color picker. tst_settings_fieldset covers the rows inside the border, the header click and keys, the settings below moving up, and a row click not toggling. cc --- .github/workflows/test-crates.yml | 2 + crates/qbz-qt/build.rs | 1 + .../qbz-qt/qml/controls/SettingsFieldset.qml | 101 ++++++++ .../qml/settings/AppearanceSettings.qml | 245 ++++++++++-------- crates/qbz-qt/src/settings_qt.rs | 43 +++ scripts/qml-tests/tst_settings_fieldset.qml | 81 ++++++ scripts/qt-runtime-gate.sh | 1 + scripts/test-settings-fieldset-qml.sh | 20 ++ 8 files changed, 391 insertions(+), 103 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/SettingsFieldset.qml create mode 100644 scripts/qml-tests/tst_settings_fieldset.qml create mode 100755 scripts/test-settings-fieldset-qml.sh 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-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..ccf086ef1 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,36 @@ 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) } - } - } - // 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/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 0c8058454..63490f460 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -2188,6 +2188,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 +2757,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 +3701,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 @@ -4882,6 +4907,24 @@ mod local_tab_order_tests { } } +#[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/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 From cca779924d95c90cb8c0a14bf86069574d291fec Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:08:37 -0600 Subject: [PATCH 2/2] feat(qt): the Wallpaper background's blur is the user's to set The Wallpaper background blurred the desktop behind the window by a fixed 0.75. Its options box in Settings > Appearance now has a Wallpaper blur slider, shown for that mode only, from 0 % to 100 %: Qt's documented range for MultiEffect.blur, 0.0 (no blur) to 1.0 (full blur), at the field's blurMax of 64. The default stays 0.75, the look the mode shipped with. Dragging moves the live background through QbzShell.wallpaperBlur; the value is written to ui_prefs once, on release (the slider's `released`), as a two-decimal number, and clamped to Qt's range on read. The Wallpaper, blurred mode keeps its fixed atmosphere look. The label and description are in all eight catalogs. cc --- .../translations/de/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/en/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/es/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 8 +++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 8 +++ .../qml/settings/AppearanceSettings.qml | 29 ++++++++++ crates/qbz-qt/qml/shell/AppShell.qml | 4 +- crates/qbz-qt/src/settings_qt.rs | 55 +++++++++++++++++++ crates/qbz-qt/src/shell_bridge.rs | 6 ++ 12 files changed, 157 insertions(+), 1 deletion(-) 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/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index ccf086ef1..d4abbcc6d 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -293,6 +293,35 @@ Column { } } } + // 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 + } + } + } } SettingsSpacer { } 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 63490f460..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 { @@ -4434,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)) { @@ -4907,6 +4940,28 @@ 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::*; 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(),