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
This commit is contained in:
vicrodh committed 2026-09-14 13:08:25 -06:00
1 parent 1f802d4a2b
commit 7ca6c9fa80
8 files changed
+391 -103

No files matched your search

+1
View File
@@ -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
@@ -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
}
}
+142 -103
View File
@@ -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 { }
+43
View File
@@ -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<String>,
#[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<AppRuntime<LoggingAdapter>>, 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::*;