feat(qbz-qt): phase 19 — full Settings subnav, Appearance + Integrations, live theming

Theme system as data: qbz_theme registry (36 themes + auto/custom)
resolved from the shared ui_prefs slug into a themeJson document;
QbzTheme rebound to it so the whole app repaints live (OLED->Light
probe-verified). Full subnav (8 sections + Share logs footer);
Appearance 1:1 (theme dropdown with dark/light filter, ambient/titlebar
rows in their real home, typography, library visuals, window chrome,
tray via tray_settings.db, renderer); Integrations 1:1 (MusicBrainz,
Last.fm 2-step browser connect + authed state, ListenBrainz token
validate, Discord toggle -> live DiscordRpc.set_enabled). Offline/Local
Library/Blacklist/Developer render honest placeholders.

km
This commit is contained in:
vicrodh committed 2026-07-28 06:13:04 -06:00
1 parent c59c883ac1
commit ea644f5e05
70 files changed
+3128 -546

No files matched your search

+2
View File
@@ -6626,6 +6626,7 @@ dependencies = [
"qbz-core",
"qbz-credentials",
"qbz-i18n",
"qbz-integrations",
"qbz-library",
"qbz-log",
"qbz-lyrics",
@@ -6633,6 +6634,7 @@ dependencies = [
"qbz-offline-cache",
"qbz-qobuz",
"qbz-text-utils",
"qbz-theme",
"rand 0.10.1",
"reqwest",
"rusqlite",
+2
View File
@@ -37,6 +37,8 @@ qbz-text-utils = { path = "../qbz-text-utils" }
qbz-credentials = { path = "../qbz-credentials" }
qbz-i18n = { path = "../qbz-i18n" }
qbz-log = { path = "../qbz-log" }
qbz-theme = { path = "../qbz-theme" }
qbz-integrations = { path = "../qbz-integrations" }
rusqlite = { version = "0.31", features = ["bundled"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
+11
View File
@@ -55,6 +55,17 @@ fn main() {
"qml/HomeView.qml",
"qml/LibraryView.qml",
"qml/SettingsView.qml",
"qml/AppearanceSettings.qml",
"qml/IntegrationsSettings.qml",
"qml/SettingRow.qml",
"qml/GroupHeader.qml",
"qml/SettingsDivider.qml",
"qml/SettingsSpacer.qml",
"qml/SettingsButton.qml",
"qml/QbzToggle.qml",
"qml/QbzSelect.qml",
"qml/QbzSlider.qml",
"qml/QbzLineEdit.qml",
"qml/SearchView.qml",
"qml/Cortinilla.qml",
"qml/PlaylistView.qml",
+471
View File
@@ -0,0 +1,471 @@
// Settings > Appearance (phase 19) — the QML port of
// crates/qbz-ui/ui/settings/AppearanceSettings.slint. Row order, group
// headers and gating mirror the Slint 1:1; every control rides the single
// settingsJson document (root.doc) and the settingsBool/Select/String
// invokables — never local truth.
//
// The THEME row is live: QbzBridge.themeSlug / themeListJson / themeFilter
// (theme_qt.rs) and themeSet() repaints the whole app through QbzTheme.qml
// (the Slint theme::push_colors equivalent).
//
// POC-NOTEs (deliberate cuts):
// - "Auto (dynamic)" rows: the Source dropdown persists, Regenerate
// re-resolves via AutoSource::System; the "Select Image..." flow
// (file dialog + AutoSource::Image) is not ported.
// - "Custom" rows: the Slint token editor is not ported (owner: read-only
// apply of custom_theme.json) — an info row stands in.
// - The "system" registry theme maps to the Dark palette (Slint reads the
// OS palette).
// - Language persists but applies on next launch (live switch = phase 20).
// - UI scale / renderer / preferred GPU persist with restart semantics;
// the Slint restart toasts are logs here.
// - Preferred GPU: no adapter enumeration — Auto + the persisted adapter.
// - The commented-out Slint blocks (immersive background/panels, title
// template) stay out, 1:1 the owner's cut.
import QtQuick
import com.blitzfc.qbz
Column {
id: root
property var doc: ({})
QbzTheme { id: theme }
spacing: 4
// --- Theme row state (theme_qt.rs via the bridge) --------------------
readonly property var themeEntries: {
try {
return JSON.parse(QbzBridge.themeListJson)
} catch (e) {
return []
}
}
// 0 All / 1 Dark / 2 Light; the Auto/Custom synthetics only list under
// All (theme.rs:217-227).
readonly property var filteredThemes: {
const f = QbzBridge.themeFilter
const out = []
for (let i = 0; i < themeEntries.length; i++) {
const e = themeEntries[i]
const synthetic = e.slug === "auto" || e.slug === "custom"
if (f === 1 && (e.isLight || synthetic)) continue
if (f === 2 && (!e.isLight || synthetic)) continue
out.push(e)
}
return out
}
readonly property var filteredLabels: filteredThemes.map(function (e) { return e.label })
readonly property int currentThemeIndex: {
for (let i = 0; i < filteredThemes.length; i++) {
if (filteredThemes[i].slug === QbzBridge.themeSlug) return i
}
return -1
}
readonly property bool tbLocked: doc.hideTitleBar === true || doc.useSystemTitleBar === true
// ============================ THEME ==================================
GroupHeader { text: QbzBridge.tr("THEME") }
SettingRow {
label: QbzBridge.tr("Theme")
Row {
spacing: 8
// Filter cycle (All -> Dark -> Light): sun-moon / moon / sun.
Rectangle {
width: 34
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: fArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
anchors.centerIn: parent
name: QbzBridge.themeFilter === 1 ? "moon"
: QbzBridge.themeFilter === 2 ? "sun" : "sun-moon"
width: 16
height: 16
tintName: "secondary"
}
MouseArea {
id: fArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.themeSetFilter((QbzBridge.themeFilter + 1) % 3)
}
}
QbzSelect {
menuWidth: 220
searchable: true
options: root.filteredLabels
currentIndex: root.currentThemeIndex
onSelected: function (i) {
if (i >= 0 && i < root.filteredThemes.length)
QbzBridge.themeSet(root.filteredThemes[i].slug)
}
}
}
}
SettingRow {
label: QbzBridge.tr("Album header gradient")
description: QbzBridge.tr("Use artwork-derived blur as a backdrop in album and artist detail views.")
QbzToggle {
checked: root.doc.albumHeaderGradient === true
onToggled: function (v) { QbzBridge.settingsBool("album-header-gradient", v) }
}
}
SettingRow {
label: QbzBridge.tr("Dynamic background")
QbzSelect {
menuWidth: 200
options: root.doc.appBackgroundModes || []
currentIndex: root.doc.appBackgroundIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("app-background", i) }
}
}
SettingRow {
label: QbzBridge.tr("Intelligent Search")
QbzToggle {
checked: root.doc.intelligentSearch === true
onToggled: function (v) { QbzBridge.settingsBool("intelligent-search", v) }
}
}
// Auto-theme rows (theme slug "auto" only).
SettingRow {
visible: QbzBridge.themeSlug === "auto"
label: QbzBridge.tr("Source")
QbzSelect {
menuWidth: 200
options: [QbzBridge.tr("System Colors"), QbzBridge.tr("Wallpaper Sync"), QbzBridge.tr("Custom Image")]
currentIndex: root.doc.autoThemeSourceIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("auto-theme-source", i) }
}
}
SettingRow {
visible: QbzBridge.themeSlug === "auto"
label: QbzBridge.tr("Regenerate")
description: QbzBridge.tr("Rebuild the palette from the source (System colors in the POC).")
SettingsButton {
text: QbzBridge.tr("Regenerate")
onClicked: QbzBridge.themeSet(QbzBridge.themeSlug)
}
}
// Custom-theme rows (theme slug "custom" only) — no token editor.
SettingRow {
visible: QbzBridge.themeSlug === "custom"
label: QbzBridge.tr("Custom theme")
description: QbzBridge.tr("Applied read-only from custom_theme.json — the token editor is not ported to the Qt frontend (POC).")
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ===================== TYPOGRAPHY & LANGUAGE =========================
GroupHeader { text: QbzBridge.tr("TYPOGRAPHY & LANGUAGE") }
SettingRow {
label: QbzBridge.tr("Language")
QbzSelect {
menuWidth: 200
options: root.doc.languages || []
currentIndex: root.doc.languageIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("language", i) }
}
}
SettingRow {
label: QbzBridge.tr("Interface size")
description: QbzBridge.tr("Restart to apply.")
QbzSelect {
menuWidth: 200
options: root.doc.uiScales || []
currentIndex: root.doc.uiScaleIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("ui-scale", i) }
}
}
SettingRow {
label: QbzBridge.tr("Immersive search")
QbzSelect {
menuWidth: 220
options: root.doc.immersiveSearchActions || []
currentIndex: root.doc.immersiveSearchActionIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("immersive-search-action", i) }
}
}
SettingRow {
label: QbzBridge.tr("Immersive default view")
QbzSelect {
menuWidth: 220
options: root.doc.immersiveDefaultViews || []
currentIndex: root.doc.immersiveDefaultViewIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("immersive-default-view", i) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ======================= LIBRARY & VISUALS ===========================
GroupHeader { text: QbzBridge.tr("LIBRARY & VISUALS") }
SettingRow {
label: QbzBridge.tr("Show navigation in sidebar")
QbzToggle {
checked: root.doc.navInSidebar === true
onToggled: function (v) { QbzBridge.settingsBool("nav-in-sidebar", v) }
}
}
SettingRow {
// ADR-010: only mounted when navigation is NOT in the sidebar.
visible: root.doc.navInSidebar !== true
label: QbzBridge.tr("Compact header navigation")
QbzToggle {
checked: root.doc.navHeaderCompact === true
onToggled: function (v) { QbzBridge.settingsBool("nav-header-compact", v) }
}
}
SettingRow {
label: QbzBridge.tr("My QBZ")
Row {
spacing: 8
RoundedImage {
width: 34
height: 34
source: "assets/qbz-logo.png"
radius: theme.radiusSm
}
QbzLineEdit {
width: 150
text: root.doc.myQbzLabel || ""
placeholder: QbzBridge.tr("My QBZ")
onCommitted: function (v) { QbzBridge.settingsString("myqbz-label", v) }
}
SettingsButton {
iconName: "rotate-ccw"
onClicked: QbzBridge.settingsString("myqbz-label", "")
}
}
}
SettingRow {
label: QbzBridge.tr("Show playlist cover collages in sidebar")
QbzToggle {
checked: root.doc.sidebarPlaylistCollage === true
onToggled: function (v) { QbzBridge.settingsBool("sidebar-playlist-collage", v) }
}
}
SettingRow {
label: QbzBridge.tr("Show album artwork in Local Library track list")
QbzToggle {
checked: root.doc.localLibraryTrackArtwork === true
onToggled: function (v) { QbzBridge.settingsBool("local-library-track-artwork", v) }
}
}
SettingRow {
label: QbzBridge.tr("Play indicator animation")
QbzToggle {
checked: root.doc.playIndicatorAnimation === true
onToggled: function (v) { QbzBridge.settingsBool("play-indicator-animation", v) }
}
}
SettingRow {
label: QbzBridge.tr("Invert swipe navigation direction")
QbzToggle {
checked: root.doc.invertSwipeNavigation === true
onToggled: function (v) { QbzBridge.settingsBool("invert-swipe-navigation", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================= NOTIFICATIONS =============================
GroupHeader { text: QbzBridge.tr("NOTIFICATIONS") }
SettingRow {
label: QbzBridge.tr("In-app toasts notifications")
QbzToggle {
checked: root.doc.inAppToasts === true
onToggled: function (v) { QbzBridge.settingsBool("in-app-toasts", v) }
}
}
SettingRow {
label: QbzBridge.tr("System Notifications")
QbzToggle {
checked: root.doc.systemNotifications === true
onToggled: function (v) { QbzBridge.settingsBool("system-notifications", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================= WINDOW TITLE ==============================
GroupHeader { text: QbzBridge.tr("WINDOW TITLE") }
SettingRow {
label: QbzBridge.tr("Show track in window title")
QbzToggle {
checked: root.doc.windowTitleShow === true
onToggled: function (v) { QbzBridge.settingsBool("window-title-show", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================== TITLE BAR ================================
GroupHeader { text: QbzBridge.tr("TITLE BAR") }
SettingRow {
label: QbzBridge.tr("Use system title bar")
description: QbzBridge.tr("Restart to apply.")
QbzToggle {
checked: root.doc.useSystemTitleBar === true
onToggled: function (v) { QbzBridge.settingsBool("use-system-title-bar", v) }
}
}
SettingRow {
label: QbzBridge.tr("Hide title bar")
rowEnabled: root.doc.useSystemTitleBar !== true
QbzToggle {
enabled: root.doc.useSystemTitleBar !== true
checked: root.doc.hideTitleBar === true
onToggled: function (v) { QbzBridge.settingsBool("hide-title-bar", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ======================= WINDOW CONTROLS =============================
GroupHeader { text: QbzBridge.tr("WINDOW CONTROLS") }
SettingRow {
label: QbzBridge.tr("Window controls position")
rowEnabled: !root.tbLocked
QbzSelect {
enabled: !root.tbLocked
menuWidth: 160
options: root.doc.wcPositions || []
currentIndex: root.doc.wcPositionIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("wc-position", i) }
}
}
SettingRow {
label: QbzBridge.tr("Show window controls")
rowEnabled: !root.tbLocked
QbzToggle {
enabled: !root.tbLocked
checked: root.doc.showWindowControls === true
onToggled: function (v) { QbzBridge.settingsBool("show-window-controls", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================= PLAYER VIEWS ==============================
GroupHeader { text: QbzBridge.tr("PLAYER VIEWS") }
SettingRow {
label: QbzBridge.tr("Show volume +/- buttons")
QbzToggle {
checked: root.doc.showVolumeSteppers === true
onToggled: function (v) { QbzBridge.settingsBool("show-volume-steppers", v) }
}
}
SettingRow {
label: QbzBridge.tr("Mini player default view")
QbzSelect {
menuWidth: 220
options: root.doc.miniDefaultViews || []
currentIndex: root.doc.miniDefaultViewIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("mini-default-view", i) }
}
}
SettingRow {
label: QbzBridge.tr("Startup page")
QbzSelect {
menuWidth: 220
options: root.doc.startupPages || []
currentIndex: root.doc.startupPageIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("startup-page", i) }
}
}
SettingRow {
label: QbzBridge.tr("Show Purchases")
QbzToggle {
checked: root.doc.showPurchases === true
onToggled: function (v) { QbzBridge.settingsBool("show-purchases", v) }
}
}
SettingRow {
label: QbzBridge.tr("Purchases in title bar")
rowEnabled: !root.tbLocked
QbzToggle {
enabled: !root.tbLocked
checked: root.doc.navTbPurchases === true
onToggled: function (v) { QbzBridge.settingsBool("nav-tb-purchases", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================== SYSTEM TRAY ==============================
GroupHeader { text: QbzBridge.tr("SYSTEM TRAY") }
SettingRow {
label: QbzBridge.tr("Enable tray icon")
QbzToggle {
checked: root.doc.trayEnable === true
onToggled: function (v) { QbzBridge.settingsBool("tray-enable", v) }
}
}
SettingRow {
label: QbzBridge.tr("Close to tray")
rowEnabled: root.doc.trayEnable === true
QbzToggle {
enabled: root.doc.trayEnable === true
checked: root.doc.trayCloseToTray === true
onToggled: function (v) { QbzBridge.settingsBool("tray-close-to-tray", v) }
}
}
SettingRow {
label: QbzBridge.tr("Tray icon variant")
QbzSelect {
menuWidth: 200
options: root.doc.trayIconThemes || []
currentIndex: root.doc.trayIconThemeIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("tray-icon-theme", i) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// =========================== RENDERER ================================
GroupHeader { text: QbzBridge.tr("RENDERER") }
SettingRow {
label: QbzBridge.tr("Rendering backend")
description: QbzBridge.tr("Restart to apply.")
QbzSelect {
menuWidth: 220
options: root.doc.renderers || []
currentIndex: root.doc.rendererIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("renderer", i) }
}
}
SettingRow {
label: QbzBridge.tr("Preferred GPU")
description: QbzBridge.tr("Restart to apply.")
QbzSelect {
menuWidth: 260
options: root.doc.gpuPowers || []
currentIndex: root.doc.gpuPowerIndex || 0
onSelected: function (i) { QbzBridge.settingsSelect("gpu-power", i) }
}
}
}
+15
View File
@@ -0,0 +1,15 @@
// Settings group header (AppearanceSettings.slint GroupHeader) — extracted
// from SettingsView.qml in phase 19: 11px muted uppercase, 1.5px tracking,
// semibold.
import QtQuick
import com.blitzfc.qbz
Text {
QbzTheme { id: theme }
color: theme.textMuted
font.pixelSize: 11
font.letterSpacing: 1.5
font.weight: theme.weightSemibold
}
+238
View File
@@ -0,0 +1,238 @@
// Settings > Integrations (phase 19) — the QML port of
// crates/qbz-ui/ui/settings/IntegrationsSettings.slint. Row order, group
// headers and auth-gating mirror the Slint 1:1; all state rides the single
// settingsJson document (integrations_qt.rs over the SAME scrobbler /
// discover / ui_prefs stores the Slint app uses).
//
// POC-NOTEs (named in integrations_qt.rs too):
// - No live scrobbling (scrobble::start / on_track_changed not ported) —
// toggles + credentials persist for the Slint scrobbler / a future port.
// - Discord: the pref + live DiscordRpc flag flip; presence updates are
// not fed (no NowListening pushes on track change).
// - The Last.fm connect flow opens the system browser (open::that) — the
// offscreen smoke never clicks it.
import QtQuick
import com.blitzfc.qbz
Column {
id: root
property var doc: ({})
QbzTheme { id: theme }
spacing: 4
// ======================= RECOMMENDATIONS =============================
GroupHeader { text: QbzBridge.tr("RECOMMENDATIONS") }
SettingRow {
label: QbzBridge.tr("Show Recommendations in Discover")
description: QbzBridge.tr("A personalized Discover tab built from your listening. For the best results connect Last.fm and ListenBrainz below — MusicBrainz is used automatically to match releases.")
QbzToggle {
checked: root.doc.showRecommendations === true
onToggled: function (v) { QbzBridge.settingsBool("show-recommendations", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// =========================== METADATA ================================
GroupHeader { text: QbzBridge.tr("METADATA") }
SettingRow {
label: QbzBridge.tr("MusicBrainz")
description: QbzBridge.tr("Enable artist relationships and enhanced metadata from MusicBrainz. No telemetry — it only matches releases to enrich artist pages and playlist song suggestions. Turn off to disable those sections.")
QbzToggle {
checked: root.doc.musicbrainzEnabled === true
onToggled: function (v) { QbzBridge.settingsBool("musicbrainz", v) }
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// ========================== SCROBBLERS ===============================
// Master header row (toggle + collapse chevron), then the body gated
// on enabled && !collapsed (IntegrationsSettings.slint:113-169).
Item {
width: parent.width
height: 64
Column {
anchors.left: parent.left
anchors.right: scrobbleControl.left
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: QbzBridge.tr("SCROBBLERS")
color: theme.textMuted
font.pixelSize: 11
font.letterSpacing: 1.5
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
Text {
width: parent.width
text: QbzBridge.tr("Send your plays to Last.fm and ListenBrainz. Works for Qobuz, local, and Plex tracks.")
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
Row {
id: scrobbleControl
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Collapse chevron (only when the master toggle is on).
Rectangle {
visible: root.doc.scrobbleEnabled === true
width: 28
height: 28
radius: theme.radiusSm
color: colArea.containsMouse ? theme.surfaceHover : "transparent"
QbzIcon {
anchors.centerIn: parent
name: root.doc.scrobbleUiCollapsed === true ? "chevron-right" : "chevron-down"
width: 16
height: 16
tintName: "secondary"
}
MouseArea {
id: colArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.settingsBool("scrobble-collapse", root.doc.scrobbleUiCollapsed !== true)
}
}
QbzToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.doc.scrobbleEnabled === true
onToggled: function (v) { QbzBridge.settingsBool("scrobble-enable", v) }
}
}
}
Column {
visible: root.doc.scrobbleEnabled === true && root.doc.scrobbleUiCollapsed !== true
width: parent.width
spacing: 4
// ------------------------- LAST.FM ------------------------------
GroupHeader { text: QbzBridge.tr("LAST.FM") }
SettingRow {
label: QbzBridge.tr("Scrobble to Last.fm")
description: root.doc.lastfmAuthed === true
? QbzBridge.tr("Signed in as {}.").replace("{}", root.doc.lastfmUsername || "")
: QbzBridge.tr("Connect your Last.fm account to enable scrobbling.")
QbzToggle {
checked: root.doc.lastfmEnabled === true
onToggled: function (v) { QbzBridge.settingsBool("lastfm-enable", v) }
}
}
SettingRow {
visible: root.doc.lastfmAuthed !== true
label: QbzBridge.tr("Connect")
description: QbzBridge.tr("Authorize QBZ in your browser, then click Finish.")
Row {
spacing: 8
SettingsButton {
text: root.doc.lastfmBusy === true ? QbzBridge.tr("Working...") : QbzBridge.tr("Connect Last.fm")
enabled: root.doc.lastfmBusy !== true
onClicked: QbzBridge.integrationsAction("lastfm-connect")
}
SettingsButton {
visible: (root.doc.lastfmAuthUrl || "") !== ""
text: QbzBridge.tr("Open authorize page")
onClicked: QbzBridge.integrationsAction("lastfm-open-auth-url")
}
SettingsButton {
visible: (root.doc.lastfmAuthUrl || "") !== ""
text: QbzBridge.tr("Finish")
onClicked: QbzBridge.integrationsAction("lastfm-finish")
}
}
}
SettingRow {
visible: root.doc.lastfmAuthed === true
label: QbzBridge.tr("Disconnect Last.fm")
description: QbzBridge.tr("Sign out of Last.fm.")
SettingsButton {
danger: true
text: QbzBridge.tr("Disconnect")
onClicked: QbzBridge.integrationsAction("lastfm-disconnect")
}
}
SettingsSpacer { }
// ----------------------- LISTENBRAINZ ----------------------------
GroupHeader { text: QbzBridge.tr("LISTENBRAINZ") }
SettingRow {
label: QbzBridge.tr("Scrobble to ListenBrainz")
description: root.doc.listenbrainzAuthed === true
? QbzBridge.tr("Signed in as {}.").replace("{}", root.doc.listenbrainzUsername || "")
: QbzBridge.tr("Paste your ListenBrainz user token to enable scrobbling.")
QbzToggle {
checked: root.doc.listenbrainzEnabled === true
onToggled: function (v) { QbzBridge.settingsBool("listenbrainz-enable", v) }
}
}
SettingRow {
visible: root.doc.listenbrainzAuthed !== true
label: QbzBridge.tr("User token")
description: QbzBridge.tr("From listenbrainz.org/settings.")
QbzLineEdit {
isPassword: true
placeholder: QbzBridge.tr("ListenBrainz token")
onCommitted: function (v) { QbzBridge.settingsString("listenbrainz-token", v) }
}
}
SettingRow {
visible: root.doc.listenbrainzAuthed === true
label: QbzBridge.tr("Disconnect ListenBrainz")
description: QbzBridge.tr("Sign out of ListenBrainz.")
SettingsButton {
danger: true
text: QbzBridge.tr("Disconnect")
onClicked: QbzBridge.integrationsAction("listenbrainz-disconnect")
}
}
// Status line (scrobble.rs set_status; kind 1 info / 2 ok / 3 error).
Text {
visible: (root.doc.integrationsStatusText || "") !== ""
width: parent.width
text: root.doc.integrationsStatusText || ""
color: root.doc.integrationsStatusKind === 2 ? theme.success
: root.doc.integrationsStatusKind === 3 ? theme.danger
: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
// =========================== DISCORD =================================
GroupHeader { text: QbzBridge.tr("DISCORD") }
SettingRow {
label: QbzBridge.tr("Discord Rich Presence")
description: QbzBridge.tr("Show what you're listening to as your Discord status. Opt-in — Discord must be running.")
QbzToggle {
checked: root.doc.discordEnabled === true
onToggled: function (v) { QbzBridge.settingsBool("discord-rpc", v) }
}
}
SettingRow {
label: QbzBridge.tr("Flatpak socket access")
description: QbzBridge.tr("Flatpak install and the presence isn't showing? Grant access to Discord's IPC socket, then restart QBZ:\nflatpak override --user --filesystem=xdg-run/discord-ipc-0 com.blitzfc.qbz")
}
}
+57
View File
@@ -0,0 +1,57 @@
// QbzLineEdit (std-widgets LineEdit, settings styling) — extracted from
// SettingsView.qml in phase 19. 240px / 34px elevated r8 bordered input;
// commits on Enter AND on focus loss (the Tauri onchange semantics —
// PlaybackSettings.slint).
import QtQuick
import com.blitzfc.qbz
Rectangle {
property string text: ""
property string placeholder: ""
property bool isPassword: false
signal committed(string value)
QbzTheme { id: theme }
width: 240
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
clip: true
echoMode: parent.isPassword ? TextInput.Password : TextInput.Normal
text: parent.text
onAccepted: parent.committed(text)
onActiveFocusChanged: if (!activeFocus) parent.committed(text)
// External republish (e.g. Reset) re-seeds the field while it is
// not being edited.
Binding {
target: input
property: "text"
value: input.parent.text
when: !input.activeFocus
}
}
Text {
visible: input.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
text: placeholder
color: theme.textMuted
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
+292
View File
@@ -0,0 +1,292 @@
// QbzSelect (primitives/QbzSelect.slint, standard size) — extracted from
// SettingsView.qml in phase 19 so every settings panel shares the one
// replica (the ui-control-alignment-standard searchable dropdown: the
// filter box shows past ~8 options via `searchable: true`).
// 34px bordered control (elevated, hover -> surface-hover) + a Popup
// list (surface-main r8 border-muted, 32px rows, capped at 360px with
// scroll, optional 42px filter box, 24px group-header rows, "BP" badge
// / speaker glyph on device options). `options` entries are either
// plain strings or {label, bp, group} objects (the device list).
import QtQuick
import QtQuick.Controls
import com.blitzfc.qbz
Rectangle {
property var options: []
property int currentIndex: 0
property int menuWidth: 240
property int popupWidth: 0
property bool enabled: true
property bool searchable: false
signal selected(int index)
QbzTheme { id: theme }
id: selectRoot
width: menuWidth
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated
opacity: enabled ? 1.0 : 0.4
readonly property int listWidth: Math.max(popupWidth, menuWidth)
readonly property int rowHeight: 32
readonly property int headerHeight: 24
readonly property int searchHeight: searchable ? 42 : 0
readonly property int maxListHeight: 360
property string filter: ""
function optLabel(i) {
const o = options[i]
return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "")
}
function optHasBadges() {
return options.length > 0 && typeof options[0] !== "string"
}
function optBp(i) {
const o = options[i]
return o && o.bp === true
}
function optGroup(i) {
const o = options[i]
return (o && o.group !== undefined) ? o.group : ""
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 10
spacing: 8
Text {
width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
height: parent.height
text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
? selectRoot.optLabel(selectRoot.currentIndex) : ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Trailing BP badge / speaker glyph for the CURRENT device option.
Item {
id: badgeSlot
visible: selectRoot.optHasBadges()
width: visible ? 20 : 0
height: parent.height
Text {
visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
&& selectRoot.optBp(selectRoot.currentIndex)
anchors.centerIn: parent
text: "BP"
color: theme.accent
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
QbzIcon {
visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
&& !selectRoot.optBp(selectRoot.currentIndex)
name: "volume-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "muted"
}
}
QbzIcon {
name: "chevron-down"
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
}
MouseArea {
id: selArea
anchors.fill: parent
hoverEnabled: true
cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (selectRoot.enabled) {
selectRoot.filter = ""
popup.open()
}
}
}
Popup {
id: popup
parent: selectRoot
// Right-anchored: a list wider than the control grows leftward.
x: selectRoot.width - selectRoot.listWidth
y: selectRoot.height + 4
width: selectRoot.listWidth
height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10
padding: 0
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Item {
implicitWidth: selectRoot.listWidth
implicitHeight: popup.height
// Filter box (searchable lists only).
Rectangle {
id: searchBox
visible: selectRoot.searchable
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: selectRoot.searchHeight
color: "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.bottomMargin: 6
spacing: 8
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
width: parent.width - 14 - 8
height: parent.height
TextInput {
id: searchInput
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
clip: true
text: selectRoot.filter
onTextChanged: selectRoot.filter = text
}
Text {
visible: searchInput.text === ""
anchors.fill: parent
text: QbzBridge.tr("Search…")
color: theme.textMuted
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
}
}
}
}
ListView {
id: listContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: searchBox.bottom
anchors.bottom: parent.bottom
anchors.topMargin: 5
anchors.bottomMargin: 5
clip: true
boundsBehavior: Flickable.StopAtBounds
model: selectRoot.options
delegate: Column {
id: optRow
required property int index
required property var modelData
width: listContent.width
readonly property string label: selectRoot.optLabel(index)
readonly property bool shown: selectRoot.filter === ""
|| label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0
// Group-header row (ALSA device sections).
Rectangle {
visible: optRow.shown && selectRoot.optGroup(optRow.index) !== ""
width: parent.width
height: visible ? selectRoot.headerHeight : 0
color: "transparent"
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 4
height: parent.height - 4
text: selectRoot.optGroup(optRow.index)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Rectangle {
visible: optRow.shown
width: parent.width
height: visible ? selectRoot.rowHeight : 0
color: optArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
Text {
width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0)
height: parent.height
text: optRow.label
color: optRow.index === selectRoot.currentIndex
? theme.accent : theme.textSecondary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Item {
id: rowBadge
visible: selectRoot.optHasBadges()
width: visible ? 20 : 0
height: parent.height
Text {
visible: selectRoot.optBp(optRow.index)
anchors.centerIn: parent
text: "BP"
color: theme.accent
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
QbzIcon {
visible: !selectRoot.optBp(optRow.index)
name: "volume-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "muted"
}
}
}
MouseArea {
id: optArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
popup.close()
selectRoot.selected(optRow.index)
}
}
}
}
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// QbzSlider (primitives/QbzSlider.slint) — extracted from SettingsView.qml
// in phase 19. 200x22, 4px r2 track, accent fill, 16px thumb; integer
// steps. Like the Slint original the thumb follows the pointer during a
// drag (local dragValue) and commits each step via changed(int).
import QtQuick
import com.blitzfc.qbz
Item {
property int minimum: 0
property int maximum: 10
property int value: 0
signal changed(int newValue)
QbzTheme { id: theme }
width: 200
height: 22
readonly property int thumbSize: 16
readonly property real travel: width - thumbSize
readonly property real fraction: maximum > minimum
? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0
property bool dragging: false
property real dragFraction: fraction
readonly property real shownFraction: dragging ? dragFraction : fraction
onFractionChanged: if (!dragging) dragFraction = fraction
function commit(frac) {
const v = Math.round(minimum + Math.max(0, Math.min(1, frac)) * (maximum - minimum))
if (v !== value) changed(v)
}
Rectangle { // track
x: 0
y: Math.round((parent.height - height) / 2)
width: parent.width
height: 4
radius: 2
color: theme.surfaceElevated
}
Rectangle { // accent fill
x: 0
y: Math.round((parent.height - height) / 2)
width: parent.thumbSize / 2 + parent.shownFraction * parent.travel
height: 4
radius: 2
color: theme.accent
}
Rectangle { // thumb
width: parent.thumbSize
height: parent.thumbSize
radius: parent.thumbSize / 2
x: parent.shownFraction * parent.travel
anchors.verticalCenter: parent.verticalCenter
color: theme.textPrimary
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: {
parent.dragging = true
parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
parent.commit(parent.dragFraction)
}
onPositionChanged: {
if (pressed) {
parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
parent.commit(parent.dragFraction)
}
}
onReleased: parent.dragging = false
}
}
+91 -33
View File
@@ -1,43 +1,101 @@
// Shared dark-theme constants for the POC — the values the Slint app
// pushes into its ThemeColors struct for the dark theme
// (crates/qbz-ui/ui/foundation/theme.slint, dark block), plus the spacing /
// radius / typography / layout tokens the shell uses (foundation/*.slint).
// Shared theme tokens (phase 19: LIVE) — the color tokens now bind to
// QbzBridge.themeJson, the serialized qbz-theme ThemeColors for the
// persisted ui_prefs `theme` slug (theme_qt.rs — the Qt equivalent of the
// Slint theme::push_colors: switching the theme republishes the document
// and every consumer repaints, no restart). Until the first publish lands
// the baked dark fallback below applies (the bridge seeds themeJson at
// construction, so the fallback is purely defensive).
//
// The non-color tokens (spacing / radius / typography / layout from
// foundation/*.slint) stay static regardless of theme.
//
// Kept as a plain instantiable QtObject (NOT a pragma Singleton — the
// cxx-qt-build generated qmldir cannot mark singletons).
import QtQuick
import com.blitzfc.qbz
QtObject {
// --- Theme colors (dark) ------------------------------------------
readonly property color surfaceMain: "#0f0f0f"
readonly property color surfaceCard: "#1a1a1a"
readonly property color surfaceElevated: "#2a2a2a"
readonly property color surfaceHover: "#10ffffff"
readonly property color textPrimary: "#ffffff"
readonly property color textSecondary: "#cccccc"
readonly property color textMuted: "#888888"
readonly property color textDisabled: "#555555"
readonly property color accent: "#4285f4"
readonly property color accentHover: "#5a9bf4"
readonly property color accentPressed: "#3275e4"
readonly property color accentText: "#ffffff"
readonly property color warning: "#fbbf24"
readonly property color favorite: "#ef4444"
// #ffffff14 / #ffffff38 / #00000066 in the Slint ARGB notation.
readonly property color borderSubtle: "#14ffffff"
readonly property color borderMuted: "#38ffffff"
readonly property color cardShadow: "#66000000"
// Ambient-background layering (phase 14): the alpha variants the Slint
// applies when the dynamic background is active — chrome surface-card
// at 0.5, the frosted content panel surface-main at 0.22 (+ hairline),
// thin bars surface-main at 0.3 (AppearanceState knobs baked at the
// defaults; the live values ride QbzBridge.ambientSurfaceAlpha /
// ambientBarAlpha for the QBZ_BG_* env knobs).
readonly property color surfaceCardA50: "#801a1a1a"
readonly property color surfaceMainA22: "#380f0f0f"
readonly property color surfaceMainA30: "#4d0f0f0f"
readonly property color frostBorder: "#1affffff"
// Baked dark fallback (pre-publish only) — the old static POC tokens.
readonly property var _fallback: ({
"surfaceMain": "#0f0f0f", "surfaceCard": "#1a1a1a",
"surfaceElevated": "#2a2a2a", "surfaceHover": "#10ffffff",
"bgHover": "#2a2a2a",
"textPrimary": "#ffffff", "textSecondary": "#cccccc",
"textMuted": "#888888", "textDisabled": "#555555",
"accent": "#4285f4", "accentHover": "#5a9bf4",
"accentPressed": "#3275e4", "accentText": "#ffffff",
"danger": "#e0564f", "dangerBg": "#26e0564f",
"dangerBorder": "#59e0564f", "dangerHover": "#e87670",
"warning": "#fbbf24", "warningBg": "#26fbbf24",
"warningBorder": "#59fbbf24", "warningHover": "#fdcb47",
"success": "#3fae6a", "successBg": "#263fae6a",
"successBorder": "#593fae6a", "successHover": "#55bd7e",
"borderSubtle": "#14ffffff", "borderMuted": "#38ffffff",
"borderStrong": "#59ffffff", "focusRing": "#4285f4",
"favorite": "#ef4444", "cardShadow": "#66000000",
"surfaceCardA50": "#801a1a1a", "surfaceMainA22": "#380f0f0f",
"surfaceMainA30": "#4d0f0f0f", "frostBorder": "#1affffff",
"isDark": true
})
readonly property var _doc: {
if (QbzBridge.themeJson === "") return _fallback
try {
return JSON.parse(QbzBridge.themeJson)
} catch (e) {
return _fallback
}
}
function _c(key) {
const v = _doc[key]
return v === undefined ? _fallback[key] : v
}
// --- Theme colors (live; ThemeColors contract) ----------------------
readonly property bool isDark: _doc.isDark === undefined ? true : _doc.isDark
readonly property color surfaceMain: _c("surfaceMain")
readonly property color surfaceCard: _c("surfaceCard")
readonly property color surfaceElevated: _c("surfaceElevated")
readonly property color surfaceHover: _c("surfaceHover")
readonly property color bgHover: _c("bgHover")
readonly property color textPrimary: _c("textPrimary")
readonly property color textSecondary: _c("textSecondary")
readonly property color textMuted: _c("textMuted")
readonly property color textDisabled: _c("textDisabled")
readonly property color accent: _c("accent")
readonly property color accentHover: _c("accentHover")
readonly property color accentPressed: _c("accentPressed")
readonly property color accentText: _c("accentText")
readonly property color danger: _c("danger")
readonly property color dangerBg: _c("dangerBg")
readonly property color dangerBorder: _c("dangerBorder")
readonly property color dangerHover: _c("dangerHover")
readonly property color warning: _c("warning")
readonly property color warningBg: _c("warningBg")
readonly property color warningBorder: _c("warningBorder")
readonly property color warningHover: _c("warningHover")
readonly property color success: _c("success")
readonly property color successBg: _c("successBg")
readonly property color successBorder: _c("successBorder")
readonly property color successHover: _c("successHover")
readonly property color borderSubtle: _c("borderSubtle")
readonly property color borderMuted: _c("borderMuted")
readonly property color borderStrong: _c("borderStrong")
readonly property color focusRing: _c("focusRing")
readonly property color favorite: _c("favorite")
readonly property color cardShadow: _c("cardShadow")
// 24 alpha tiers ([4..95]%, white-based dark / black-based light).
readonly property var alphaTiers: _doc.alpha === undefined ? [] : _doc.alpha
// Ambient-background layering (phase 14; theme-derived since phase 19):
// chrome surface-card @50%, frosted content panel surface-main @22%,
// thin bars surface-main @30%, hairline = alpha tier 10%. The live
// values for the QBZ_BG_* env knobs still ride QbzBridge
// .ambientSurfaceAlpha / .ambientBarAlpha.
readonly property color surfaceCardA50: _c("surfaceCardA50")
readonly property color surfaceMainA22: _c("surfaceMainA22")
readonly property color surfaceMainA30: _c("surfaceMainA30")
readonly property color frostBorder: _c("frostBorder")
// --- Spacing (spacing.slint) --------------------------------------
readonly property int spacingXs: 4
+36
View File
@@ -0,0 +1,36 @@
// QbzToggle (primitives/QbzToggle.slint) — extracted from SettingsView.qml
// in phase 19 so every settings panel shares the one replica.
// 40x22 pill r11, 16px knob, accent when on, opacity .4 disabled,
// 120ms ease-out knob travel. Emits toggled(newValue); never self-flips.
import QtQuick
import com.blitzfc.qbz
Rectangle {
property bool checked: false
property bool enabled: true
signal toggled(bool value)
QbzTheme { id: theme }
width: 40
height: 22
radius: 11
color: checked ? theme.accent : theme.surfaceElevated
opacity: enabled ? 1.0 : 0.4
Rectangle {
width: 16
height: 16
radius: 8
color: theme.textPrimary
y: 3
x: parent.checked ? parent.width - width - 3 : 3
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutQuad } }
}
MouseArea {
anchors.fill: parent
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (parent.enabled) parent.toggled(!parent.checked)
}
}
+50
View File
@@ -0,0 +1,50 @@
// SettingRow (settings/SettingRow.slint) — extracted from SettingsView.qml
// in phase 19. 52px (64 with a description); label 15 medium + description
// 12 muted on the left (opacity .45 when disabled), the control flush right.
import QtQuick
import com.blitzfc.qbz
Item {
property string label: ""
property string description: ""
property bool rowEnabled: true
default property alias control: controlHost.data
QbzTheme { id: theme }
width: parent ? parent.width : 0
height: description === "" ? 52 : 64
Column {
anchors.left: parent.left
anchors.right: controlHost.left
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
spacing: 3
opacity: rowEnabled ? 1.0 : 0.45
Text {
width: parent.width
text: label
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
visible: description !== ""
width: parent.width
text: description
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
Item {
id: controlHost
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: childrenRect.width
height: childrenRect.height
}
}
+55
View File
@@ -0,0 +1,55 @@
// Settings SecondaryButton (IntegrationsSettings.slint:27 /
// AppearanceSettings.slint:83) — min 160px (compact variant 34px square for
// icon buttons), 34px tall, bordered, optional danger red (#e0564f ≈
// theme.danger), busy spinner text.
import QtQuick
import com.blitzfc.qbz
Rectangle {
property string text: ""
property string iconName: ""
property bool danger: false
property bool busy: false
property bool enabled: true
signal clicked()
QbzTheme { id: theme }
width: Math.max(iconName !== "" && text === "" ? 34 : 160, row.implicitWidth + 24)
height: 34
radius: theme.radiusSm
border.width: 1
border.color: danger ? theme.danger : theme.borderSubtle
color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated
opacity: enabled ? 1.0 : 0.4
Row {
id: row
anchors.centerIn: parent
spacing: 8
QbzIcon {
visible: iconName !== ""
name: iconName
width: 15
height: 15
anchors.verticalCenter: parent.verticalCenter
tintName: danger ? "favorite" : "secondary"
}
Text {
visible: text !== ""
text: parent.parent.text
color: parent.parent.danger ? theme.danger : theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: btnArea
anchors.fill: parent
hoverEnabled: true
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (parent.enabled) parent.clicked()
}
}
+14
View File
@@ -0,0 +1,14 @@
// Settings 1px divider (settings panel group separator) — extracted from
// SettingsView.qml in phase 19 (named SettingsDivider so it never shadows
// QtQuick.Controls.Divider in the shared qml directory).
import QtQuick
import com.blitzfc.qbz
Rectangle {
QbzTheme { id: theme }
width: parent ? parent.width : 0
height: 1
color: theme.borderSubtle
}
+5
View File
@@ -0,0 +1,5 @@
// Settings 12px group spacer — extracted from SettingsView.qml in phase 19.
import QtQuick
Item { width: 1; height: 12 }
+111 -513
View File
@@ -10,9 +10,10 @@
// "single source of truth in SettingsState" pattern).
//
// POC-NOTEs (deliberate cuts, named for the effort report):
// - Sub-nav: only Audio + Playback exist (sections 0/1). Appearance,
// Offline, Local Library, Blacklist, Integrations, Developer, Flatpak/
// Snap and the "Share logs" entry have no backing glue in the POC.
// - Sections: Audio / Playback / Appearance / Integrations render live
// (phase 19); Offline / Local Library / Blacklist / Developer render an
// honest placeholder page with the exact section header; the conditional
// Flatpak/Snap entry and the "Share logs" LogViewer are not ported.
// - The NavButtons row inside the 92px header is a 0px placeholder (nav
// history lives in the global HeaderBar here, like every other view).
// - Audio > "Detected device limit" read-only row: skipped — the #638
@@ -33,7 +34,8 @@ Item {
// The parsed settingsJson document ({}) until the first publish lands).
property var doc: ({})
// Active sub-section: 0 = Audio, 1 = Playback (SettingsState.section).
// Active sub-section (display order): 0 Audio, 1 Playback, 2 Appearance,
// 3 Offline, 4 Local Library, 5 Blacklist, 6 Integrations, 7 Developer.
property int section: 0
function reload() {
@@ -49,496 +51,6 @@ Item {
function onSettingsJsonChanged() { root.reload() }
}
// --- QbzToggle (primitives/QbzToggle.slint) ---------------------------
// 40x22 pill r11, 16px knob, accent when on, opacity .4 disabled,
// 120ms ease-out knob travel. Emits toggled(newValue); never self-flips.
component QbzToggle: Rectangle {
property bool checked: false
property bool enabled: true
signal toggled(bool value)
width: 40
height: 22
radius: 11
color: checked ? theme.accent : theme.surfaceElevated
opacity: enabled ? 1.0 : 0.4
Rectangle {
width: 16
height: 16
radius: 8
color: theme.textPrimary
y: 3
x: parent.checked ? parent.width - width - 3 : 3
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutQuad } }
}
MouseArea {
anchors.fill: parent
cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (parent.enabled) parent.toggled(!parent.checked)
}
}
// --- QbzSelect (primitives/QbzSelect.slint, standard size) ------------
// 34px bordered control (elevated, hover -> surface-hover) + a Popup
// list (surface-main r8 border-muted, 32px rows, capped at 360px with
// scroll, optional 42px filter box, 24px group-header rows, "BP" badge
// / speaker glyph on device options). `options` entries are either
// plain strings or {label, bp, group} objects (the device list).
component QbzSelect: Rectangle {
property var options: []
property int currentIndex: 0
property int menuWidth: 240
property int popupWidth: 0
property bool enabled: true
property bool searchable: false
signal selected(int index)
id: selectRoot
width: menuWidth
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: selArea.containsMouse && enabled ? theme.surfaceHover : theme.surfaceElevated
opacity: enabled ? 1.0 : 0.4
readonly property int listWidth: Math.max(popupWidth, menuWidth)
readonly property int rowHeight: 32
readonly property int headerHeight: 24
readonly property int searchHeight: searchable ? 42 : 0
readonly property int maxListHeight: 360
property string filter: ""
function optLabel(i) {
const o = options[i]
return (typeof o === "string") ? o : (o && o.label !== undefined ? o.label : "")
}
function optHasBadges() {
return options.length > 0 && typeof options[0] !== "string"
}
function optBp(i) {
const o = options[i]
return o && o.bp === true
}
function optGroup(i) {
const o = options[i]
return (o && o.group !== undefined) ? o.group : ""
}
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 10
spacing: 8
Text {
width: parent.width - 16 - 8 - badgeSlot.width - (badgeSlot.visible ? 8 : 0)
height: parent.height
text: selectRoot.currentIndex >= 0 && selectRoot.currentIndex < selectRoot.options.length
? selectRoot.optLabel(selectRoot.currentIndex) : ""
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
// Trailing BP badge / speaker glyph for the CURRENT device option.
Item {
id: badgeSlot
visible: selectRoot.optHasBadges()
width: visible ? 20 : 0
height: parent.height
Text {
visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
&& selectRoot.optBp(selectRoot.currentIndex)
anchors.centerIn: parent
text: "BP"
color: theme.accent
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
QbzIcon {
visible: selectRoot.optHasBadges() && selectRoot.currentIndex < selectRoot.options.length
&& !selectRoot.optBp(selectRoot.currentIndex)
name: "volume-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "muted"
}
}
QbzIcon {
name: "chevron-down"
width: 16
height: 16
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
}
MouseArea {
id: selArea
anchors.fill: parent
hoverEnabled: true
cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (selectRoot.enabled) {
selectRoot.filter = ""
popup.open()
}
}
}
Popup {
id: popup
parent: selectRoot
// Right-anchored: a list wider than the control grows leftward.
x: selectRoot.width - selectRoot.listWidth
y: selectRoot.height + 4
width: selectRoot.listWidth
height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10
padding: 0
closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape
background: Rectangle {
color: theme.surfaceMain
radius: theme.radiusSm
border.width: 1
border.color: theme.borderMuted
}
contentItem: Item {
implicitWidth: selectRoot.listWidth
implicitHeight: popup.height
// Filter box (searchable lists only).
Rectangle {
id: searchBox
visible: selectRoot.searchable
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: selectRoot.searchHeight
color: "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.bottomMargin: 6
spacing: 8
QbzIcon {
name: "search"
width: 14
height: 14
anchors.verticalCenter: parent.verticalCenter
tintName: "muted"
}
Item {
width: parent.width - 14 - 8
height: parent.height
TextInput {
id: searchInput
anchors.fill: parent
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
clip: true
text: selectRoot.filter
onTextChanged: selectRoot.filter = text
}
Text {
visible: searchInput.text === ""
anchors.fill: parent
text: QbzBridge.tr("Search…")
color: theme.textMuted
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
}
}
}
}
ListView {
id: listContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: searchBox.bottom
anchors.bottom: parent.bottom
anchors.topMargin: 5
anchors.bottomMargin: 5
clip: true
boundsBehavior: Flickable.StopAtBounds
model: selectRoot.options
delegate: Column {
id: optRow
required property int index
required property var modelData
width: listContent.width
readonly property string label: selectRoot.optLabel(index)
readonly property bool shown: selectRoot.filter === ""
|| label.toLowerCase().indexOf(selectRoot.filter.toLowerCase()) >= 0
// Group-header row (ALSA device sections).
Rectangle {
visible: optRow.shown && selectRoot.optGroup(optRow.index) !== ""
width: parent.width
height: visible ? selectRoot.headerHeight : 0
color: "transparent"
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 4
height: parent.height - 4
text: selectRoot.optGroup(optRow.index)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
Rectangle {
visible: optRow.shown
width: parent.width
height: visible ? selectRoot.rowHeight : 0
color: optArea.containsMouse ? theme.surfaceHover : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 8
Text {
width: parent.width - rowBadge.width - (rowBadge.visible ? 8 : 0)
height: parent.height
text: optRow.label
color: optRow.index === selectRoot.currentIndex
? theme.accent : theme.textSecondary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
Item {
id: rowBadge
visible: selectRoot.optHasBadges()
width: visible ? 20 : 0
height: parent.height
Text {
visible: selectRoot.optBp(optRow.index)
anchors.centerIn: parent
text: "BP"
color: theme.accent
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
QbzIcon {
visible: !selectRoot.optBp(optRow.index)
name: "volume-2"
width: 14
height: 14
anchors.centerIn: parent
tintName: "muted"
}
}
}
MouseArea {
id: optArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
popup.close()
selectRoot.selected(optRow.index)
}
}
}
}
}
}
}
}
// --- SettingRow (settings/SettingRow.slint) ---------------------------
// 52px (64 with a description); label 15 medium + description 12 muted
// on the left (opacity .45 when disabled), the control flush right.
component SettingRow: Item {
property string label: ""
property string description: ""
property bool rowEnabled: true
default property alias control: controlHost.data
width: parent ? parent.width : 0
height: description === "" ? 52 : 64
Column {
anchors.left: parent.left
anchors.right: controlHost.left
anchors.rightMargin: 24
anchors.verticalCenter: parent.verticalCenter
spacing: 3
opacity: rowEnabled ? 1.0 : 0.45
Text {
width: parent.width
text: label
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
visible: description !== ""
width: parent.width
text: description
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
Item {
id: controlHost
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: childrenRect.width
height: childrenRect.height
}
}
// --- GroupHeader / Divider --------------------------------------------
component GroupHeader: Text {
color: theme.textMuted
font.pixelSize: 11
font.letterSpacing: 1.5
font.weight: theme.weightSemibold
}
component Divider: Rectangle {
width: parent ? parent.width : 0
height: 1
color: theme.borderSubtle
}
component Spacer12: Item { width: 1; height: 12 }
// --- QbzSlider (primitives/QbzSlider.slint) ---------------------------
// 200x22, 4px r2 track, accent fill, 16px thumb; integer steps. Like the
// Slint original the thumb follows the pointer during a drag (local
// dragValue) and commits each step via changed(int).
component QbzSlider: Item {
property int minimum: 0
property int maximum: 10
property int value: 0
signal changed(int newValue)
width: 200
height: 22
readonly property int thumbSize: 16
readonly property real travel: width - thumbSize
readonly property real fraction: maximum > minimum
? Math.max(0, Math.min(1, (value - minimum) / (maximum - minimum))) : 0
property bool dragging: false
property real dragFraction: fraction
readonly property real shownFraction: dragging ? dragFraction : fraction
onFractionChanged: if (!dragging) dragFraction = fraction
function commit(frac) {
const v = Math.round(minimum + Math.max(0, Math.min(1, frac)) * (maximum - minimum))
if (v !== value) changed(v)
}
Rectangle { // track
x: 0
y: Math.round((parent.height - height) / 2)
width: parent.width
height: 4
radius: 2
color: theme.surfaceElevated
}
Rectangle { // accent fill
x: 0
y: Math.round((parent.height - height) / 2)
width: parent.thumbSize / 2 + parent.shownFraction * parent.travel
height: 4
radius: 2
color: theme.accent
}
Rectangle { // thumb
width: parent.thumbSize
height: parent.thumbSize
radius: parent.thumbSize / 2
x: parent.shownFraction * parent.travel
anchors.verticalCenter: parent.verticalCenter
color: theme.textPrimary
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onPressed: {
parent.dragging = true
parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
parent.commit(parent.dragFraction)
}
onPositionChanged: {
if (pressed) {
parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel))
parent.commit(parent.dragFraction)
}
}
onReleased: parent.dragging = false
}
}
// --- QbzLineEdit (std-widgets LineEdit, settings styling) -------------
// 240px / 34px elevated r8 bordered input; commits on Enter AND on
// focus loss (the Tauri onchange semantics — PlaybackSettings.slint).
component QbzLineEdit: Rectangle {
property string text: ""
property string placeholder: ""
signal committed(string value)
width: 240
height: 34
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
TextInput {
id: input
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
color: theme.textPrimary
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
clip: true
text: parent.text
onAccepted: parent.committed(text)
onActiveFocusChanged: if (!activeFocus) parent.committed(text)
// External republish (e.g. Reset) re-seeds the field while it is
// not being edited.
Binding {
target: input
property: "text"
value: input.parent.text
when: !input.activeFocus
}
}
Text {
visible: input.text === ""
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
text: placeholder
color: theme.textMuted
font.pixelSize: theme.fontBody
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
// ============================ the view ================================
Column {
anchors.fill: parent
@@ -629,10 +141,57 @@ Item {
active: root.section === 1
onClicked: root.section = 1
}
// POC-NOTE: Appearance / Offline / Local Library /
// Blacklist / Integrations / Developer / Flatpak / Snap /
// "Share logs" sub-nav entries are omitted (no backing
// glue in the POC).
SubNavItem {
name: "layers"
label: QbzBridge.tr("Appearance")
active: root.section === 2
onClicked: root.section = 2
}
SubNavItem {
name: "cloud-download"
label: QbzBridge.tr("Offline")
active: root.section === 3
onClicked: root.section = 3
}
SubNavItem {
name: "hard-drive"
label: QbzBridge.tr("Local Library")
active: root.section === 4
onClicked: root.section = 4
}
SubNavItem {
name: "blind-eye"
label: QbzBridge.tr("Blacklist")
active: root.section === 5
onClicked: root.section = 5
}
SubNavItem {
name: "refresh-cw"
label: QbzBridge.tr("Integrations")
active: root.section === 6
onClicked: root.section = 6
}
SubNavItem {
name: "bug"
label: QbzBridge.tr("Developer")
active: root.section === 7
onClicked: root.section = 7
}
// POC-NOTE: the conditional Flatpak/Snap sub-nav entry
// (SandboxState.install-method) is omitted — the POC is
// not a sandboxed install.
// "Share logs" footer (SettingsView.slint:202-215),
// pinned to the subnav bottom.
Item { width: 1; height: parent.height - 8 * 42 - 38 - 4 }
SubNavItem {
name: "cloud-upload"
label: QbzBridge.tr("Share logs")
active: false
// POC-NOTE: LogViewerState (copy + upload viewer) is
// not ported — the row is inert.
onClicked: { }
}
}
}
@@ -687,9 +246,9 @@ Item {
// skipped — the device-cap probe glue is not
// ported.
Spacer12 { }
Divider { }
Spacer12 { }
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("OUTPUT") }
SettingRow {
@@ -779,9 +338,9 @@ Item {
}
}
Spacer12 { }
Divider { }
Spacer12 { }
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("BIT-PERFECT") }
SettingRow {
@@ -834,9 +393,9 @@ Item {
// guided DAC setup) is skipped — DacWizardActions
// is not ported.
Spacer12 { }
Divider { }
Spacer12 { }
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("STARTUP") }
SettingRow {
@@ -922,9 +481,9 @@ Item {
}
}
Spacer12 { }
Divider { }
Spacer12 { }
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("SESSION") }
SettingRow {
@@ -965,9 +524,9 @@ Item {
}
}
Spacer12 { }
Divider { }
Spacer12 { }
SettingsSpacer { }
SettingsDivider { }
SettingsSpacer { }
GroupHeader { text: QbzBridge.tr("STREAMING") }
SettingRow {
@@ -1021,6 +580,45 @@ Item {
}
}
}
// ================== APPEARANCE (phase 19) ==========
AppearanceSettings {
visible: root.section === 2
width: parent.width
doc: root.doc
}
// ================== INTEGRATIONS (phase 19) ========
IntegrationsSettings {
visible: root.section === 6
width: parent.width
doc: root.doc
}
// ============ placeholder sections (phase 19) ======
// Offline (3) / Local Library (4) / Blacklist (5) /
// Developer (7): the subnav rows render the EXACT
// section header; the panels land in a later phase
// (POC-NOTE).
Column {
visible: root.section === 3 || root.section === 4
|| root.section === 5 || root.section === 7
width: parent.width
spacing: 4
GroupHeader {
text: root.section === 3 ? QbzBridge.tr("OFFLINE")
: root.section === 4 ? QbzBridge.tr("LOCAL LIBRARY")
: root.section === 5 ? QbzBridge.tr("BLACKLIST")
: QbzBridge.tr("DEVELOPER")
}
Text {
width: parent.width
text: QbzBridge.tr("This section is not ported to the Qt frontend yet.")
color: theme.textMuted
font.pixelSize: 12
wrapMode: Text.WordWrap
}
}
}
}
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20v-9" />
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
<path d="M14.12 3.88 16 2" />
<path d="M21 21a4 4 0 0 0-3.81-4" />
<path d="M21 5a4 4 0 0 1-3.55 3.97" />
<path d="M22 13h-4" />
<path d="M3 21a4 4 0 0 1 3.81-4" />
<path d="M3 5a4 4 0 0 0 3.55 3.97" />
<path d="M6 13H2" />
<path d="m8 2 1.88 1.88" />
<path d="M9 7.13V6a3 3 0 1 1 6 0v1.13" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84z" />
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8a2.83 2.83 0 0 0 4 4 4 4 0 1 1-4-4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.9 4.9 1.4 1.4" />
<path d="m17.7 17.7 1.4 1.4" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.3 17.7-1.4 1.4" />
<path d="m19.1 4.9-1.4 1.4" />
</svg>

After

Width:  |  Height:  |  Size: 465 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>

After

Width:  |  Height:  |  Size: 461 B

+48
View File
@@ -136,6 +136,19 @@ pub mod qbz_bridge {
// SongCard layers icon.
#[qproperty(bool, show_context_icon)]
// --- Theme (phase 19) --------------------------------------------
// ONE JSON token document (theme_qt.rs ThemeTokens: 30 colors + 24
// alpha tiers + ambient derivations + isDark) — QbzTheme.qml binds
// to it, so a theme switch repaints the whole app live (the Slint
// theme::push_colors equivalent).
#[qproperty(QString, theme_json)]
// The persisted ui_prefs theme slug ("oled", "auto", "custom", ...).
#[qproperty(QString, theme_slug)]
// The dropdown catalog (36 registry themes + Auto/Custom rows).
#[qproperty(QString, theme_list_json)]
// Dropdown filter: 0 All / 1 Dark / 2 Light (ui_prefs theme_filter).
#[qproperty(i32, theme_filter)]
// --- Lyrics panel (phase 9) ----------------------------------------
#[qproperty(bool, lyrics_open)]
// One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/
@@ -453,6 +466,21 @@ pub mod qbz_bridge {
#[qinvokable]
fn refresh_devices(self: Pin<&mut QbzBridge>);
// --- Theme (phase 19) ---------------------------------------------
/// Appearance > Theme row: persist the picked slug + republish
/// `themeJson` (live switch).
#[qinvokable]
fn theme_set(self: Pin<&mut QbzBridge>, slug: QString);
/// Appearance > theme filter cycle button (0 All / 1 Dark / 2 Light).
#[qinvokable]
fn theme_set_filter(self: Pin<&mut QbzBridge>, index: i32);
// --- Integrations (phase 19) ---------------------------------------
/// Non-toggle integration actions (integrations_qt.rs): Last.fm
/// connect/open-auth-url/finish/disconnect, ListenBrainz disconnect.
#[qinvokable]
fn integrations_action(self: Pin<&mut QbzBridge>, action: QString);
/// App-menu chrome toggle: flip the persisted `use_system_title_bar`
/// pref (applies on the next launch — the window flags are fixed at
/// creation, 1:1 Slint). Updates `systemTitleBarPref` only.
@@ -631,6 +659,10 @@ pub struct QbzBridgeRust {
np_artist_id: QString,
npb_mode: i32,
show_context_icon: bool,
theme_json: QString,
theme_slug: QString,
theme_list_json: QString,
theme_filter: i32,
queue_json: QString,
lyrics_open: bool,
lyrics_json: QString,
@@ -723,6 +755,10 @@ impl Default for QbzBridgeRust {
np_artist_id: QString::default(),
npb_mode: crate::settings_qt::npb_mode_index(),
show_context_icon: crate::settings_qt::show_context_icon(),
theme_json: QString::from(crate::theme_qt::theme_json().as_str()),
theme_slug: QString::from(crate::theme_qt::current_slug().as_str()),
theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()),
theme_filter: crate::theme_qt::theme_filter(),
queue_json: QString::from("{}"),
lyrics_open: false,
lyrics_json: QString::from("{}"),
@@ -1011,6 +1047,18 @@ impl qbz_bridge::QbzBridge {
crate::npb_set_mode(mode);
}
pub fn theme_set(self: Pin<&mut Self>, slug: QString) {
crate::theme_set(slug.to_string());
}
pub fn theme_set_filter(self: Pin<&mut Self>, index: i32) {
crate::theme_set_filter(index);
}
pub fn integrations_action(self: Pin<&mut Self>, action: QString) {
crate::integrations_action(action.to_string());
}
pub fn settings_bool(self: Pin<&mut Self>, key: QString, value: bool) {
crate::settings_bool(key.to_string(), value);
}
+275
View File
@@ -0,0 +1,275 @@
//! Integrations settings controller (phase 19) — the Slint
//! `crates/qbz/src/scrobble.rs` + `discord_rpc.rs` + the settings.rs
//! integration rows, ported onto the SAME stores:
//! `scrobbler_settings.db` (`qbz_app::settings::scrobblers`, per-user),
//! `discover_prefs.db` (`qbz_app::settings::discover_prefs`, per-user) and
//! the shared `ui_prefs.json` (`musicbrainz_enabled`, `discord_rpc_enabled`).
//!
//! POC-NOTEs:
//! - No live scrobbling: `scrobble::start` / `on_track_changed` (the
//! now-playing/scrobble firing + offline queues) is NOT ported — the
//! toggles + credentials persist to the SAME DB the Slint scrobbler
//! reads, so they take effect in the Slint app / a future port.
//! - Discord: the enable pref persists AND flips the live `DiscordRpc`
//! flag, but presence updates (`DiscordRpc::update` on track change)
//! are not wired (no rich-presence feed in the POC).
//! - ListenBrainz disconnect does NOT clear the shared
//! `cache/listenbrainz_v2.db` credentials row (scrobble.rs:334-342 —
//! that cache feeds the scrobbler runtime, which the POC does not run).
use std::sync::{Arc, Mutex, OnceLock};
use qbz_app::settings::discover_prefs::DiscoverPrefsStore;
use qbz_app::settings::scrobblers::{ScrobblerSettings, ScrobblerSettingsState};
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_integrations::discord::DiscordRpc;
use qbz_integrations::lastfm::LastFmClient;
use qbz_integrations::listenbrainz::ListenBrainzClient;
use crate::settings_qt::{pref_bool, save_pref};
// ---------------------------------------------------------------------------
// Stores (SAME files as the Slint app)
// ---------------------------------------------------------------------------
static SCROBBLE: OnceLock<ScrobblerSettingsState> = OnceLock::new();
fn scrobble() -> &'static ScrobblerSettingsState {
SCROBBLE.get_or_init(|| {
let state = ScrobblerSettingsState::new_empty();
if let Some(dir) = crate::sidebar_qt::user_dir() {
if let Err(e) = state.init_at(&dir) {
log::warn!("[qbz-qt] scrobbler settings store unavailable: {e}");
}
}
state
})
}
static DISCOVER: OnceLock<Mutex<Option<DiscoverPrefsStore>>> = OnceLock::new();
fn with_discover<T>(f: impl FnOnce(&DiscoverPrefsStore) -> T) -> Option<T> {
let cell = DISCOVER.get_or_init(|| {
let store = crate::sidebar_qt::user_dir()
.and_then(|dir| DiscoverPrefsStore::new_at(&dir).ok());
if store.is_none() {
log::warn!("[qbz-qt] discover prefs store unavailable");
}
Mutex::new(store)
});
let guard = cell.lock().ok()?;
guard.as_ref().map(f)
}
static DISCORD: OnceLock<DiscordRpc> = OnceLock::new();
fn discord() -> &'static DiscordRpc {
DISCORD.get_or_init(DiscordRpc::new)
}
// ---------------------------------------------------------------------------
// Transient UI state (ScrobbleState busy/auth-url/status + the Last.fm
// pending request token — process memory only, like LASTFM_PENDING_TOKEN).
// ---------------------------------------------------------------------------
#[derive(Default)]
pub struct IntegrationUi {
pub lastfm_busy: bool,
pub listenbrainz_busy: bool,
pub lastfm_auth_url: String,
pub status_text: String,
/// 0 none / 1 info / 2 ok / 3 error (ScrobbleState.status-kind).
pub status_kind: i32,
pending_lastfm_token: String,
}
static UI: OnceLock<Mutex<IntegrationUi>> = OnceLock::new();
fn ui_state() -> &'static Mutex<IntegrationUi> {
UI.get_or_init(|| Mutex::new(IntegrationUi::default()))
}
pub fn ui_snapshot() -> IntegrationUi {
let g = ui_state().lock().unwrap();
IntegrationUi {
lastfm_busy: g.lastfm_busy,
listenbrainz_busy: g.listenbrainz_busy,
lastfm_auth_url: g.lastfm_auth_url.clone(),
status_text: g.status_text.clone(),
status_kind: g.status_kind,
pending_lastfm_token: String::new(),
}
}
fn set_status(text: &str, kind: i32) {
let mut g = ui_state().lock().unwrap();
g.status_text = text.to_string();
g.status_kind = kind;
}
// ---------------------------------------------------------------------------
// Snapshot fields (folded into SettingsDoc by settings_qt::publish_snapshot)
// ---------------------------------------------------------------------------
pub fn scrobble_settings() -> ScrobblerSettings {
scrobble().get_settings().unwrap_or_default()
}
pub fn show_recommendations() -> bool {
with_discover(|s| s.load().show_recommendations).unwrap_or(true)
}
pub fn discord_enabled() -> bool {
pref_bool("discord_rpc_enabled", false)
}
// ---------------------------------------------------------------------------
// Toggle handlers (settings_qt.rs bool arms delegate here)
// ---------------------------------------------------------------------------
pub fn set_show_recommendations(value: bool) -> Result<(), String> {
with_discover(|s| {
let mut prefs = s.load();
prefs.show_recommendations = value;
s.save(&prefs)
})
.unwrap_or_else(|| Err("discover prefs store not open".to_string()))
}
pub fn set_scrobble_enabled(value: bool) -> Result<(), String> {
scrobble().set_enabled(value)
}
pub fn set_scrobble_collapsed(value: bool) -> Result<(), String> {
scrobble().set_ui_collapsed(value)
}
pub fn set_lastfm_enabled(value: bool) -> Result<(), String> {
scrobble().set_lastfm_enabled(value)
}
pub fn set_listenbrainz_enabled(value: bool) -> Result<(), String> {
scrobble().set_listenbrainz_enabled(value)
}
pub fn set_discord_enabled(value: bool) -> Result<(), String> {
save_pref("discord_rpc_enabled", serde_json::json!(value));
discord().set_enabled(value);
log::info!("[qbz-qt] discord_rpc_enabled -> {value}");
Ok(())
}
pub fn set_musicbrainz_enabled(value: bool) -> Result<(), String> {
save_pref("musicbrainz_enabled", serde_json::json!(value));
Ok(())
}
// ---------------------------------------------------------------------------
// Connection flows (ScrobbleActions)
// ---------------------------------------------------------------------------
/// ListenBrainz paste-token flow (scrobble.rs:315): validate against
/// /validate-token, persist token + username, force-enable on success.
pub async fn listenbrainz_set_token(token: &str) {
let token = token.trim().to_string();
if token.is_empty() {
return;
}
ui_state().lock().unwrap().listenbrainz_busy = true;
crate::settings_qt::publish_snapshot().await;
let client = ListenBrainzClient::new();
match client.set_token(&token).await {
Ok(info) => {
if let Err(e) = scrobble().set_listenbrainz_token(&token, &info.user_name) {
log::error!("[qbz-qt] persist listenbrainz token failed: {e}");
}
// First-connect force-enable (scrobble.rs).
let _ = scrobble().set_listenbrainz_enabled(true);
set_status(&format!("Signed in as {}.", info.user_name), 2);
}
Err(e) => set_status(&format!("ListenBrainz: {e}"), 3),
}
ui_state().lock().unwrap().listenbrainz_busy = false;
crate::settings_qt::publish_snapshot().await;
}
/// integrations_action dispatch (non-toggle rows).
pub async fn handle_action(_runtime: &Arc<AppRuntime<LoggingAdapter>>, action: &str) {
match action {
// Last.fm two-step browser auth (scrobble.rs:190): request token ->
// authorize URL in the browser -> Finish exchanges for a session.
"lastfm-connect" => {
ui_state().lock().unwrap().lastfm_busy = true;
crate::settings_qt::publish_snapshot().await;
let client = LastFmClient::new();
match client.get_token().await {
Ok((token, url)) => {
{
let mut g = ui_state().lock().unwrap();
g.pending_lastfm_token = token;
g.lastfm_auth_url = url.clone();
}
set_status("Authorize QBZ in your browser, then click Finish.", 1);
if let Err(e) = open::that(&url) {
log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}");
}
}
Err(e) => set_status(&format!("Last.fm: {e}"), 3),
}
ui_state().lock().unwrap().lastfm_busy = false;
crate::settings_qt::publish_snapshot().await;
}
"lastfm-open-auth-url" => {
let url = ui_state().lock().unwrap().lastfm_auth_url.clone();
if !url.is_empty() {
if let Err(e) = open::that(&url) {
log::warn!("[qbz-qt] open Last.fm authorize page failed: {e}");
}
}
}
"lastfm-finish" => {
let token = ui_state().lock().unwrap().pending_lastfm_token.clone();
if token.is_empty() {
return;
}
ui_state().lock().unwrap().lastfm_busy = true;
crate::settings_qt::publish_snapshot().await;
let mut client = LastFmClient::new();
match client.get_session(&token).await {
Ok(session) => {
if let Err(e) = scrobble().set_lastfm_session(&session.key, &session.name) {
log::error!("[qbz-qt] persist lastfm session failed: {e}");
}
// First-connect force-enable (scrobble.rs).
let _ = scrobble().set_lastfm_enabled(true);
{
let mut g = ui_state().lock().unwrap();
g.pending_lastfm_token.clear();
g.lastfm_auth_url.clear();
}
set_status(&format!("Signed in as {}.", session.name), 2);
}
Err(e) => set_status(&format!("Last.fm: {e}"), 3),
}
ui_state().lock().unwrap().lastfm_busy = false;
crate::settings_qt::publish_snapshot().await;
}
"lastfm-disconnect" => {
if let Err(e) = scrobble().disconnect_lastfm() {
log::error!("[qbz-qt] lastfm disconnect failed: {e}");
}
set_status("", 0);
crate::settings_qt::publish_snapshot().await;
}
"listenbrainz-disconnect" => {
if let Err(e) = scrobble().disconnect_listenbrainz() {
log::error!("[qbz-qt] listenbrainz disconnect failed: {e}");
}
set_status("", 0);
crate::settings_qt::publish_snapshot().await;
}
other => log::warn!("[qbz-qt] unknown integrations action: {other}"),
}
}
+23
View File
@@ -25,6 +25,8 @@ mod queue_qt;
mod search_qt;
mod settings_qt;
mod sidebar_qt;
mod theme_qt;
mod integrations_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -992,6 +994,27 @@ pub(crate) fn refresh_devices() {
spawn(async move { settings_qt::refresh_devices(&runtime).await });
}
/// Appearance > Theme row: persist the slug + republish the token document
/// (live switch — QbzTheme.qml rebinds every consumer).
pub(crate) fn theme_set(slug: String) {
theme_qt::set_theme(&slug);
theme_qt::publish_theme();
}
/// Appearance > theme filter cycle (0 All / 1 Dark / 2 Light).
pub(crate) fn theme_set_filter(index: i32) {
let index = index.clamp(0, 2);
theme_qt::set_theme_filter(index);
ui(move |mut b| b.as_mut().set_theme_filter(index));
}
/// Integrations panel non-toggle actions (Last.fm connect flow, LB/LFM
/// disconnects).
pub(crate) fn integrations_action(action: String) {
let runtime = app();
spawn(async move { integrations_qt::handle_action(&runtime, &action).await });
}
/// App-menu chrome toggle: persist the flipped pref and update the menu
/// state. The APPLIED mode (`systemTitleBar`) is deliberately untouched —
/// it changes on the next launch (the window flags are read once at
+581
View File
@@ -284,6 +284,107 @@ pub fn show_context_icon() -> bool {
with_playback(|s| s.get_preferences().map(|p| p.show_context_icon)).unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Appearance + Integrations prefs (phase 19) — generic ui_prefs.json
// accessors (SAME additive patch discipline) + the per-user stores the
// Appearance/Integrations panels read (tray, My-QBZ branding).
// ---------------------------------------------------------------------------
pub fn pref_bool(key: &str, default: bool) -> bool {
let Some(path) = prefs_path() else {
return default;
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get(key).and_then(|q| q.as_bool()))
.unwrap_or(default)
}
pub fn pref_str(key: &str, default: &str) -> String {
let Some(path) = prefs_path() else {
return default.to_string();
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get(key).and_then(|q| q.as_str().map(str::to_string)))
.unwrap_or_else(|| default.to_string())
}
pub fn save_pref(key: &str, value: serde_json::Value) {
let Some(path) = prefs_path() else {
return;
};
let mut doc: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = doc.as_object_mut() {
obj.insert(key.to_string(), value);
if let Ok(text) = serde_json::to_string_pretty(&doc) {
let _ = std::fs::write(&path, text);
}
}
}
/// The Appearance > app_background index: 0 off / 1 ambient / 2 blurred
/// (the POC renders blurred AS ambient — ambient_qt.rs POC-NOTE).
pub fn app_background_index() -> i32 {
match pref_str("app_background", "off").as_str() {
"ambient" => 1,
"blurred" => 2,
_ => 0,
}
}
// Tray store (qbz_app::settings::tray — per-user tray_settings.db, the SAME
// file the Slint tray_settings.rs glue writes).
static TRAY: OnceLock<qbz_app::settings::tray::TraySettingsState> = OnceLock::new();
fn tray() -> &'static qbz_app::settings::tray::TraySettingsState {
TRAY.get_or_init(|| {
let state = qbz_app::settings::tray::TraySettingsState::new_empty();
if let Some(dir) = crate::sidebar_qt::user_dir() {
if let Err(e) = state.init_at(&dir) {
log::warn!("[qbz-qt] tray settings store unavailable: {e}");
}
}
state
})
}
// My-QBZ branding (myqbz_prefs.rs — per-user myqbz_branding.json; the label
// row in LIBRARY & VISUALS).
fn myqbz_label() -> String {
crate::sidebar_qt::user_dir()
.map(|d| d.join("myqbz_branding.json"))
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get("label").and_then(|q| q.as_str().map(str::to_string)))
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "My QBZ".to_string())
}
fn save_myqbz_label(label: &str) {
let Some(path) = crate::sidebar_qt::user_dir().map(|d| d.join("myqbz_branding.json")) else {
return;
};
let mut doc: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = doc.as_object_mut() {
obj.insert(
"label".to_string(),
serde_json::Value::String(label.trim().to_string()),
);
if let Ok(text) = serde_json::to_string_pretty(&doc) {
let _ = std::fs::write(&path, text);
}
}
}
pub const STREAMING_QUALITY_KEYS: &[&str] = &["mp3", "cd", "hires", "hires_plus"];
pub const STREAMING_QUALITY_LABELS: &[&str] = &["MP3", "CD Quality", "Hi-Res", "Hi-Res+"];
@@ -303,6 +404,54 @@ const RETRY_BEHAVIOR_LABELS: &[&str] = &["Ask me", "Always try lowest quality",
const RETRY_BEHAVIOR_VALUES: &[&str] = &["ask", "always_fallback", "always_skip"];
const QCONNECT_STARTUP_LABELS: &[&str] = &["Remember state", "On by default", "Off by default"];
const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"];
// Appearance option tables (AppearanceSettings.slint / ui_prefs.rs).
const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"];
const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"];
const LANGUAGE_LABELS: &[&str] = &[
"Auto", "English", "Español", "Français", "Deutsch", "Português", "Русский", "日本語",
"Nederlands",
];
const LANGUAGE_VALUES: &[&str] = &["auto", "en", "es", "fr", "de", "pt", "ru", "ja", "nl"];
const UI_SCALE_LABELS: &[&str] = &["Extra small", "Small", "Default", "Large", "Extra large"];
const UI_SCALE_VALUES: &[&str] = &["xs", "small", "default", "large", "xl"];
const IMMERSIVE_SEARCH_LABELS: &[&str] =
&["Disabled", "Replace current queue", "Play next", "Add to queue"];
const IMMERSIVE_SEARCH_VALUES: &[&str] = &["disabled", "replace", "next", "queue"];
const IMMERSIVE_VIEW_LABELS: &[&str] =
&["Remember last", "Album Reactive", "Static", "Coverflow", "Spectrum", "Lyrics", "Queue"];
const IMMERSIVE_VIEW_VALUES: &[&str] =
&["remember", "reactive", "static", "coverflow", "spectrum", "lyrics", "queue"];
const MINI_VIEW_LABELS: &[&str] =
&["Remember last used", "Micro", "Compact", "Artwork", "Queue", "Lyrics"];
const MINI_VIEW_VALUES: &[&str] = &["remember", "micro", "compact", "artwork", "queue", "lyrics"];
const STARTUP_PAGE_LABELS: &[&str] = &["Home", "Where you left off"];
const STARTUP_PAGE_VALUES: &[&str] = &["home", "remember"];
const WC_POSITION_LABELS: &[&str] = &["Left", "Right"];
const WC_POSITION_VALUES: &[&str] = &["left", "right"];
const RENDERER_LABELS: &[&str] =
&["Auto (recommended)", "GPU", "GPU (compatibility)", "Software"];
const RENDERER_VALUES: &[&str] = &["auto", "wgpu", "gl", "software"];
const TRAY_ICON_LABELS: &[&str] = &["Auto", "Mono light", "Mono dark", "Color"];
const TRAY_ICON_VALUES: &[&str] = &["auto", "mono-light", "mono-dark", "color"];
fn index_of(values: &[&str], key: &str, default: usize) -> i32 {
values.iter().position(|v| *v == key).unwrap_or(default) as i32
}
/// Preferred-GPU dropdown (AppearanceSettings.slint): "Auto (recommended)"
/// + detected adapter names built in Rust. POC-NOTE: no adapter enumeration
/// in the POC — the list is Auto + the currently persisted adapter (so the
/// owner's real value shows selected); picking it again is a no-op.
fn gpu_power_choice() -> (Vec<String>, i32) {
let persisted = pref_str("gpu_power", "auto");
let mut opts = vec![qbz_i18n::t("Auto (recommended)")];
if persisted != "auto" && !persisted.is_empty() {
opts.push(persisted);
(opts, 1)
} else {
(opts, 0)
}
}
#[derive(Clone, Default, Serialize)]
pub struct DeviceOption {
@@ -386,6 +535,125 @@ pub struct SettingsDoc {
pub qconnect_device_name: String,
#[serde(rename = "qconnectDeviceNameDefault")]
pub qconnect_device_name_default: String,
// Appearance (phase 19; the ui_prefs defaults mirror ui_prefs.rs)
#[serde(rename = "albumHeaderGradient")]
pub album_header_gradient: bool,
#[serde(rename = "appBackgroundModes")]
pub app_background_modes: Vec<String>,
#[serde(rename = "appBackgroundIndex")]
pub app_background_index: i32,
#[serde(rename = "autoThemeSourceIndex")]
pub auto_theme_source_index: i32,
#[serde(rename = "intelligentSearch")]
pub intelligent_search: bool,
pub languages: Vec<String>,
#[serde(rename = "languageIndex")]
pub language_index: i32,
#[serde(rename = "uiScales")]
pub ui_scales: Vec<String>,
#[serde(rename = "uiScaleIndex")]
pub ui_scale_index: i32,
#[serde(rename = "immersiveSearchActions")]
pub immersive_search_actions: Vec<String>,
#[serde(rename = "immersiveSearchActionIndex")]
pub immersive_search_action_index: i32,
#[serde(rename = "immersiveDefaultViews")]
pub immersive_default_views: Vec<String>,
#[serde(rename = "immersiveDefaultViewIndex")]
pub immersive_default_view_index: i32,
#[serde(rename = "navInSidebar")]
pub nav_in_sidebar: bool,
#[serde(rename = "navHeaderCompact")]
pub nav_header_compact: bool,
#[serde(rename = "myQbzLabel")]
pub my_qbz_label: String,
#[serde(rename = "sidebarPlaylistCollage")]
pub sidebar_playlist_collage: bool,
#[serde(rename = "localLibraryTrackArtwork")]
pub local_library_track_artwork: bool,
#[serde(rename = "playIndicatorAnimation")]
pub play_indicator_animation: bool,
#[serde(rename = "invertSwipeNavigation")]
pub invert_swipe_navigation: bool,
#[serde(rename = "inAppToasts")]
pub in_app_toasts: bool,
#[serde(rename = "systemNotifications")]
pub system_notifications: bool,
#[serde(rename = "windowTitleShow")]
pub window_title_show: bool,
#[serde(rename = "useSystemTitleBar")]
pub use_system_title_bar: bool,
#[serde(rename = "hideTitleBar")]
pub hide_title_bar: bool,
#[serde(rename = "wcPositions")]
pub wc_positions: Vec<String>,
#[serde(rename = "wcPositionIndex")]
pub wc_position_index: i32,
#[serde(rename = "showWindowControls")]
pub show_window_controls: bool,
#[serde(rename = "showVolumeSteppers")]
pub show_volume_steppers: bool,
#[serde(rename = "miniDefaultViews")]
pub mini_default_views: Vec<String>,
#[serde(rename = "miniDefaultViewIndex")]
pub mini_default_view_index: i32,
#[serde(rename = "startupPages")]
pub startup_pages: Vec<String>,
#[serde(rename = "startupPageIndex")]
pub startup_page_index: i32,
#[serde(rename = "showPurchases")]
pub show_purchases: bool,
#[serde(rename = "navTbPurchases")]
pub nav_tb_purchases: bool,
pub renderers: Vec<String>,
#[serde(rename = "rendererIndex")]
pub renderer_index: i32,
#[serde(rename = "gpuPowers")]
pub gpu_powers: Vec<String>,
#[serde(rename = "gpuPowerIndex")]
pub gpu_power_index: i32,
// Appearance > SYSTEM TRAY (tray_settings.db)
#[serde(rename = "trayEnable")]
pub tray_enable: bool,
#[serde(rename = "trayCloseToTray")]
pub tray_close_to_tray: bool,
#[serde(rename = "trayIconThemes")]
pub tray_icon_themes: Vec<String>,
#[serde(rename = "trayIconThemeIndex")]
pub tray_icon_theme_index: i32,
// Integrations (phase 19)
#[serde(rename = "showRecommendations")]
pub show_recommendations: bool,
#[serde(rename = "musicbrainzEnabled")]
pub musicbrainz_enabled: bool,
#[serde(rename = "scrobbleEnabled")]
pub scrobble_enabled: bool,
#[serde(rename = "scrobbleUiCollapsed")]
pub scrobble_ui_collapsed: bool,
#[serde(rename = "lastfmEnabled")]
pub lastfm_enabled: bool,
#[serde(rename = "lastfmAuthed")]
pub lastfm_authed: bool,
#[serde(rename = "lastfmUsername")]
pub lastfm_username: String,
#[serde(rename = "lastfmAuthUrl")]
pub lastfm_auth_url: String,
#[serde(rename = "lastfmBusy")]
pub lastfm_busy: bool,
#[serde(rename = "listenbrainzEnabled")]
pub listenbrainz_enabled: bool,
#[serde(rename = "listenbrainzAuthed")]
pub listenbrainz_authed: bool,
#[serde(rename = "listenbrainzUsername")]
pub listenbrainz_username: String,
#[serde(rename = "listenbrainzBusy")]
pub listenbrainz_busy: bool,
#[serde(rename = "integrationsStatusText")]
pub integrations_status_text: String,
#[serde(rename = "integrationsStatusKind")]
pub integrations_status_kind: i32,
#[serde(rename = "discordEnabled")]
pub discord_enabled: bool,
}
/// Index -> value maps the select handlers resolve against.
@@ -527,6 +795,8 @@ pub async fn publish_snapshot() {
.position(|v| *v == audio_settings.quality_fallback_behavior)
.unwrap_or(0);
let qconnect_startup = qconnect_load_startup_mode();
let scrobble_snap = crate::integrations_qt::scrobble_settings();
let integ_ui = crate::integrations_qt::ui_snapshot();
let qconnect_startup_index = QCONNECT_STARTUP_VALUES
.iter()
.position(|v| *v == qconnect_startup)
@@ -595,6 +865,111 @@ pub async fn publish_snapshot() {
qconnect_startup_index: qconnect_startup_index as i32,
qconnect_device_name: qconnect_load_device_name().unwrap_or_default(),
qconnect_device_name_default: qconnect_default_name(),
album_header_gradient: pref_bool("album_header_gradient", true),
app_background_modes: APP_BACKGROUND_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
app_background_index: APP_BACKGROUND_VALUES
.iter()
.position(|v| *v == pref_str("app_background", "off"))
.unwrap_or(0) as i32,
auto_theme_source_index: index_of(
&["system", "wallpaper", "image"],
&pref_str("auto_theme_source", "system"),
0,
),
intelligent_search: pref_bool("intelligent_search", true),
languages: LANGUAGE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
language_index: index_of(LANGUAGE_VALUES, &pref_str("language", "auto"), 0),
ui_scales: UI_SCALE_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
ui_scale_index: index_of(UI_SCALE_VALUES, &pref_str("ui_scale", "default"), 2),
immersive_search_actions: IMMERSIVE_SEARCH_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
immersive_search_action_index: index_of(
IMMERSIVE_SEARCH_VALUES,
&pref_str("immersive_search_action", "replace"),
1,
),
immersive_default_views: IMMERSIVE_VIEW_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
immersive_default_view_index: index_of(
IMMERSIVE_VIEW_VALUES,
&pref_str("immersive_default_view", "remember"),
0,
),
nav_in_sidebar: pref_bool("nav_in_sidebar", true),
nav_header_compact: pref_bool("nav_header_compact", true),
my_qbz_label: myqbz_label(),
sidebar_playlist_collage: pref_bool("sidebar_playlist_collage", true),
local_library_track_artwork: pref_bool("local_library_track_artwork", false),
play_indicator_animation: pref_bool("play_indicator_animation", false),
invert_swipe_navigation: pref_bool("invert_swipe_navigation", false),
in_app_toasts: pref_bool("in_app_toasts", true),
system_notifications: pref_bool("system_notifications", true),
window_title_show: pref_bool("window_title_show", false),
use_system_title_bar: use_system_title_bar(),
hide_title_bar: pref_bool("hide_title_bar", false),
wc_positions: WC_POSITION_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
wc_position_index: index_of(WC_POSITION_VALUES, &pref_str("wc_position", "right"), 1),
show_window_controls: pref_bool("show_window_controls", true),
show_volume_steppers: pref_bool("show_volume_steppers", false),
mini_default_views: MINI_VIEW_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
mini_default_view_index: index_of(
MINI_VIEW_VALUES,
&pref_str("mini_default_view", "remember"),
0,
),
startup_pages: STARTUP_PAGE_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
startup_page_index: index_of(STARTUP_PAGE_VALUES, &pref_str("startup_page", "home"), 0),
show_purchases: pref_bool("show_purchases", false),
nav_tb_purchases: pref_bool("nav_tb_purchases", false),
renderers: RENDERER_LABELS.iter().map(|l| qbz_i18n::t(l)).collect(),
renderer_index: index_of(RENDERER_VALUES, &pref_str("renderer", "auto"), 0),
gpu_powers: gpu_power_choice().0,
gpu_power_index: gpu_power_choice().1,
tray_enable: tray().get_settings().map(|t| t.enable_tray).unwrap_or(true),
tray_close_to_tray: tray()
.get_settings()
.map(|t| t.close_to_tray)
.unwrap_or(false),
tray_icon_themes: TRAY_ICON_LABELS
.iter()
.map(|l| qbz_i18n::t(l))
.collect(),
tray_icon_theme_index: tray()
.get_settings()
.map(|t| index_of(TRAY_ICON_VALUES, &t.tray_icon_theme, 0))
.unwrap_or(0),
show_recommendations: crate::integrations_qt::show_recommendations(),
musicbrainz_enabled: pref_bool("musicbrainz_enabled", true),
scrobble_enabled: scrobble_snap.enabled,
scrobble_ui_collapsed: scrobble_snap.ui_collapsed,
lastfm_enabled: scrobble_snap.lastfm_enabled,
lastfm_authed: scrobble_snap.lastfm_is_authed(),
lastfm_username: scrobble_snap.lastfm_username.clone(),
lastfm_auth_url: integ_ui.lastfm_auth_url.clone(),
lastfm_busy: integ_ui.lastfm_busy,
listenbrainz_enabled: scrobble_snap.listenbrainz_enabled,
listenbrainz_authed: scrobble_snap.listenbrainz_is_authed(),
listenbrainz_username: scrobble_snap.listenbrainz_username.clone(),
listenbrainz_busy: integ_ui.listenbrainz_busy,
integrations_status_text: integ_ui.status_text.clone(),
integrations_status_kind: integ_ui.status_kind,
discord_enabled: crate::integrations_qt::discord_enabled(),
}
})
.await
@@ -724,6 +1099,118 @@ pub async fn settings_bool(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &str,
"resume-position" => {
with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None)
}
// --- Appearance (phase 19): plain ui_prefs bools (+ live bridge
// side-effects where the POC already has the consumer).
"album-header-gradient" => {
save_pref("album_header_gradient", serde_json::json!(value));
Ok(Apply::None)
}
"intelligent-search" => {
save_pref("intelligent_search", serde_json::json!(value));
crate::ui(move |mut b| b.as_mut().set_intelligent_search(value));
Ok(Apply::None)
}
"nav-in-sidebar" => {
save_pref("nav_in_sidebar", serde_json::json!(value));
Ok(Apply::None)
}
"nav-header-compact" => {
save_pref("nav_header_compact", serde_json::json!(value));
Ok(Apply::None)
}
"sidebar-playlist-collage" => {
save_pref("sidebar_playlist_collage", serde_json::json!(value));
Ok(Apply::None)
}
"local-library-track-artwork" => {
save_pref("local_library_track_artwork", serde_json::json!(value));
Ok(Apply::None)
}
"play-indicator-animation" => {
save_pref("play_indicator_animation", serde_json::json!(value));
Ok(Apply::None)
}
"invert-swipe-navigation" => {
save_pref("invert_swipe_navigation", serde_json::json!(value));
Ok(Apply::None)
}
"in-app-toasts" => {
save_pref("in_app_toasts", serde_json::json!(value));
Ok(Apply::None)
}
"system-notifications" => {
save_pref("system_notifications", serde_json::json!(value));
Ok(Apply::None)
}
"window-title-show" => {
save_pref("window_title_show", serde_json::json!(value));
Ok(Apply::None)
}
"use-system-title-bar" => {
save_pref("use_system_title_bar", serde_json::json!(value));
// Restart semantics (1:1 Slint): the APPLIED mode is untouched;
// only the menu/row state flips live.
log::info!("[qbz-qt] use_system_title_bar -> {value} (applies on next launch)");
crate::ui(move |mut b| b.as_mut().set_system_title_bar_pref(value));
Ok(Apply::None)
}
"hide-title-bar" => {
save_pref("hide_title_bar", serde_json::json!(value));
Ok(Apply::None)
}
"show-window-controls" => {
save_pref("show_window_controls", serde_json::json!(value));
Ok(Apply::None)
}
"show-volume-steppers" => {
save_pref("show_volume_steppers", serde_json::json!(value));
Ok(Apply::None)
}
"show-purchases" => {
save_pref("show_purchases", serde_json::json!(value));
Ok(Apply::None)
}
"nav-tb-purchases" => {
save_pref("nav_tb_purchases", serde_json::json!(value));
Ok(Apply::None)
}
"tray-enable" => tray()
.set_enable_tray(value)
.map_err(|e| e.to_string())
.map(|_| Apply::None),
"tray-close-to-tray" => tray()
.set_close_to_tray(value)
.map_err(|e| e.to_string())
.map(|_| Apply::None),
// --- Integrations (phase 19) --------------------------------------
"show-recommendations" => {
crate::integrations_qt::set_show_recommendations(value).map(|_| Apply::None)
}
"musicbrainz" => {
match crate::integrations_qt::set_musicbrainz_enabled(value) {
Ok(()) => {
// Live apply (main.rs:9019-9029 seeds core from this pref).
runtime.core().musicbrainz_set_enabled(value).await;
Ok(Apply::None)
}
Err(e) => Err(e),
}
}
"scrobble-enable" => {
crate::integrations_qt::set_scrobble_enabled(value).map(|_| Apply::None)
}
"scrobble-collapse" => {
crate::integrations_qt::set_scrobble_collapsed(value).map(|_| Apply::None)
}
"lastfm-enable" => {
crate::integrations_qt::set_lastfm_enabled(value).map(|_| Apply::None)
}
"listenbrainz-enable" => {
crate::integrations_qt::set_listenbrainz_enabled(value).map(|_| Apply::None)
}
"discord-rpc" => {
crate::integrations_qt::set_discord_enabled(value).map(|_| Apply::None)
}
other => {
log::warn!("[qbz-qt] unknown settings bool key: {other}");
return;
@@ -838,6 +1325,92 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
};
qconnect_persist_startup_mode(mode);
}
// --- Appearance (phase 19) ----------------------------------------
"app-background" => {
let Some(mode) = APP_BACKGROUND_VALUES.get(index) else {
return;
};
save_pref("app_background", serde_json::json!(mode));
// Live (pure QML layering; blurred renders AS ambient in the POC).
let ambient = if *mode == "off" { 0 } else { 1 };
crate::ui(move |mut b| b.as_mut().set_ambient_mode(ambient));
}
"language" => {
let Some(lang) = LANGUAGE_VALUES.get(index) else {
return;
};
save_pref("language", serde_json::json!(lang));
// POC-NOTE: live language switching is phase 20; the pref
// persists here and applies on the next launch.
log::info!("[qbz-qt] language -> {lang} (applies on next launch; live switch is phase 20)");
}
"ui-scale" => {
let Some(scale) = UI_SCALE_VALUES.get(index) else {
return;
};
save_pref("ui_scale", serde_json::json!(scale));
log::info!("[qbz-qt] ui_scale -> {scale} (restart to apply)");
}
"immersive-search-action" => {
let Some(v) = IMMERSIVE_SEARCH_VALUES.get(index) else {
return;
};
save_pref("immersive_search_action", serde_json::json!(v));
}
"immersive-default-view" => {
let Some(v) = IMMERSIVE_VIEW_VALUES.get(index) else {
return;
};
save_pref("immersive_default_view", serde_json::json!(v));
}
"wc-position" => {
let Some(v) = WC_POSITION_VALUES.get(index) else {
return;
};
save_pref("wc_position", serde_json::json!(v));
}
"mini-default-view" => {
let Some(v) = MINI_VIEW_VALUES.get(index) else {
return;
};
save_pref("mini_default_view", serde_json::json!(v));
}
"startup-page" => {
let Some(v) = STARTUP_PAGE_VALUES.get(index) else {
return;
};
save_pref("startup_page", serde_json::json!(v));
}
"renderer" => {
let Some(v) = RENDERER_VALUES.get(index) else {
return;
};
save_pref("renderer", serde_json::json!(v));
log::info!("[qbz-qt] renderer -> {v} (restart to apply)");
}
"gpu-power" => {
// Only "Auto" is actionable in the POC (no adapter enumeration —
// the persisted-name row is display-only).
if index == 0 {
save_pref("gpu_power", serde_json::json!("auto"));
log::info!("[qbz-qt] gpu_power -> auto (restart to apply)");
}
}
"tray-icon-theme" => {
let Some(v) = TRAY_ICON_VALUES.get(index) else {
return;
};
if let Err(e) = tray().set_tray_icon_theme(v) {
log::error!("[qbz-qt] persist tray icon theme failed: {e}");
}
}
"auto-theme-source" => {
const SOURCES: &[&str] = &["system", "wallpaper", "image"];
let Some(v) = SOURCES.get(index) else {
return;
};
save_pref("auto_theme_source", serde_json::json!(v));
}
other => log::warn!("[qbz-qt] unknown settings select key: {other}"),
}
publish_snapshot().await;
@@ -860,6 +1433,14 @@ pub async fn settings_string(key: &str, value: String) {
let stored = (!trimmed.is_empty()).then_some(trimmed.as_str());
qconnect_persist_device_name(stored);
}
if key == "myqbz-label" {
save_myqbz_label(&value);
}
if key == "listenbrainz-token" {
// Validates + persists + republishes itself (async flow).
crate::integrations_qt::listenbrainz_set_token(&value).await;
return;
}
publish_snapshot().await;
}
+313
View File
@@ -0,0 +1,313 @@
//! Theme controller (phase 19) — the Slint `crates/qbz/src/theme.rs` port:
//! the persisted `ui_prefs.theme` slug resolves through the SAME `qbz-theme`
//! registry (`ThemeId`/`palette()`), and the materialized `ThemeColors` are
//! serialized to ONE JSON document that QbzTheme.qml binds to — the Qt
//! equivalent of `theme::push_colors` (live switch, no restart).
//!
//! Synthetic slugs (theme.rs): "auto" (AutoSource::System regeneration —
//! falls back to OLED when headless generation fails) and "custom"
//! (`<data_dir>/qbz/custom_theme.json`, the SAME file the Slint custom-theme
//! editor writes; read-only apply here — POC-NOTE: no token editor). The
//! "system" registry theme resolves via the OS palette in Slint; the POC
//! maps it to the Dark palette (POC-NOTE).
use cxx_qt_lib::QString;
use qbz_theme::{Rgba, ThemeColors, ThemeId};
use serde::Serialize;
// ---------------------------------------------------------------------------
// ui_prefs.json keys (theme + theme_filter) — additive patch like
// settings_qt.rs so every other Slint key survives.
// ---------------------------------------------------------------------------
fn prefs_path() -> Option<std::path::PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("ui_prefs.json"))
}
fn read_pref(key: &str) -> Option<serde_json::Value> {
let path = prefs_path()?;
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|v| v.get(key).cloned())
}
fn write_pref(key: &str, value: serde_json::Value) {
let Some(path) = prefs_path() else {
return;
};
let mut doc: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = doc.as_object_mut() {
obj.insert(key.to_string(), value);
if let Ok(text) = serde_json::to_string_pretty(&doc) {
let _ = std::fs::write(&path, text);
}
}
}
/// The persisted theme slug (ui_prefs.rs default: "oled").
pub fn current_slug() -> String {
read_pref("theme")
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "oled".to_string())
}
/// The dropdown filter (theme.rs: 0 All / 1 Dark / 2 Light; default 0).
pub fn theme_filter() -> i32 {
read_pref("theme_filter")
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32
}
pub fn set_theme_filter(index: i32) {
write_pref("theme_filter", serde_json::json!(index.clamp(0, 2)));
}
/// Persist a new slug (additive); the caller republishes `themeJson`.
pub fn set_theme(slug: &str) {
write_pref("theme", serde_json::Value::String(slug.to_string()));
log::info!("[qbz-qt] theme -> {slug}");
}
// ---------------------------------------------------------------------------
// Slug -> ThemeColors (theme.rs apply_theme / auto / custom startup dispatch)
// ---------------------------------------------------------------------------
fn custom_theme_path() -> Option<std::path::PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("custom_theme.json"))
}
fn custom_colors() -> ThemeColors {
let base = custom_theme_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|text| serde_json::from_str::<qbz_theme::CustomThemeBase>(&text).ok())
.unwrap_or_else(qbz_theme::CustomThemeBase::default_oled);
qbz_theme::theme_from_base(&base)
}
fn auto_colors() -> ThemeColors {
match qbz_theme::generate_auto_theme(&qbz_theme::AutoSource::System) {
Ok(c) => c,
Err(e) => {
// Headless / no DE portal: keep the row selectable but fall back
// to the default dark tokens (POC-NOTE).
log::warn!("[qbz-qt] auto theme generation failed ({e}); falling back to OLED");
qbz_theme::palette(qbz_theme::default_theme_id())
}
}
}
/// Resolve a slug to its materialized colors (unknown -> OLED, theme.rs
/// `id_for_slug` fallback).
pub fn colors_for_slug(slug: &str) -> ThemeColors {
match slug {
"auto" => auto_colors(),
"custom" => custom_colors(),
// POC-NOTE: "system" reads the OS palette in Slint; the POC maps it
// to the Dark registry palette.
"system" => qbz_theme::palette(ThemeId::Dark),
other => ThemeId::from_slug(other)
.map(qbz_theme::palette)
.unwrap_or_else(|| qbz_theme::palette(qbz_theme::default_theme_id())),
}
}
// ---------------------------------------------------------------------------
// ThemeColors -> QML token JSON (theme::push_colors equivalent)
// ---------------------------------------------------------------------------
/// QML color literal: "#aarrggbb" (Qt ARGB — the notation QbzTheme.qml
/// already uses).
fn argb(c: Rgba) -> String {
format!("#{:02x}{:02x}{:02x}{:02x}", c.a, c.r, c.g, c.b)
}
fn with_alpha(c: Rgba, a: u8) -> String {
argb(Rgba {
r: c.r,
g: c.g,
b: c.b,
a,
})
}
/// The alpha-ramp percents (colors.rs:12-17) — indices into the 24 tiers.
const ALPHA_PCTS: [u32; 24] = [
4, 5, 6, 8, 10, 12, 15, 18, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95,
];
#[derive(Serialize)]
struct ThemeTokens {
// 30 named colors (ThemeColors contract).
#[serde(rename = "surfaceMain")]
surface_main: String,
#[serde(rename = "surfaceCard")]
surface_card: String,
#[serde(rename = "surfaceElevated")]
surface_elevated: String,
#[serde(rename = "surfaceHover")]
surface_hover: String,
#[serde(rename = "bgHover")]
bg_hover: String,
#[serde(rename = "textPrimary")]
text_primary: String,
#[serde(rename = "textSecondary")]
text_secondary: String,
#[serde(rename = "textMuted")]
text_muted: String,
#[serde(rename = "textDisabled")]
text_disabled: String,
accent: String,
#[serde(rename = "accentHover")]
accent_hover: String,
#[serde(rename = "accentPressed")]
accent_pressed: String,
#[serde(rename = "accentText")]
accent_text: String,
danger: String,
#[serde(rename = "dangerBg")]
danger_bg: String,
#[serde(rename = "dangerBorder")]
danger_border: String,
#[serde(rename = "dangerHover")]
danger_hover: String,
warning: String,
#[serde(rename = "warningBg")]
warning_bg: String,
#[serde(rename = "warningBorder")]
warning_border: String,
#[serde(rename = "warningHover")]
warning_hover: String,
success: String,
#[serde(rename = "successBg")]
success_bg: String,
#[serde(rename = "successBorder")]
success_border: String,
#[serde(rename = "successHover")]
success_hover: String,
#[serde(rename = "borderSubtle")]
border_subtle: String,
#[serde(rename = "borderMuted")]
border_muted: String,
#[serde(rename = "borderStrong")]
border_strong: String,
#[serde(rename = "focusRing")]
focus_ring: String,
favorite: String,
#[serde(rename = "cardShadow")]
card_shadow: String,
// 24 alpha tiers (alpha4..alpha95, white-based dark / black-based light).
alpha: Vec<String>,
// Ambient-layer derivations (phase-14 tokens, now theme-derived):
// chrome surface-card @50%, frosted panel surface-main @22%, thin bars
// surface-main @30%, hairline = alpha tier 10%.
#[serde(rename = "surfaceCardA50")]
surface_card_a50: String,
#[serde(rename = "surfaceMainA22")]
surface_main_a22: String,
#[serde(rename = "surfaceMainA30")]
surface_main_a30: String,
#[serde(rename = "frostBorder")]
frost_border: String,
// Flags (Theme.is-dark).
#[serde(rename = "isDark")]
is_dark: bool,
}
fn tokens_for(colors: &ThemeColors) -> ThemeTokens {
let is_dark = qbz_theme::relative_luminance(colors.surface_main) < 0.5;
ThemeTokens {
surface_main: argb(colors.surface_main),
surface_card: argb(colors.surface_card),
surface_elevated: argb(colors.surface_elevated),
surface_hover: argb(colors.surface_hover),
bg_hover: argb(colors.bg_hover),
text_primary: argb(colors.text_primary),
text_secondary: argb(colors.text_secondary),
text_muted: argb(colors.text_muted),
text_disabled: argb(colors.text_disabled),
accent: argb(colors.accent),
accent_hover: argb(colors.accent_hover),
accent_pressed: argb(colors.accent_pressed),
accent_text: argb(colors.accent_text),
danger: argb(colors.danger),
danger_bg: argb(colors.danger_bg),
danger_border: argb(colors.danger_border),
danger_hover: argb(colors.danger_hover),
warning: argb(colors.warning),
warning_bg: argb(colors.warning_bg),
warning_border: argb(colors.warning_border),
warning_hover: argb(colors.warning_hover),
success: argb(colors.success),
success_bg: argb(colors.success_bg),
success_border: argb(colors.success_border),
success_hover: argb(colors.success_hover),
border_subtle: argb(colors.border_subtle),
border_muted: argb(colors.border_muted),
border_strong: argb(colors.border_strong),
focus_ring: argb(colors.focus_ring),
favorite: argb(colors.favorite),
card_shadow: argb(colors.card_shadow),
alpha: colors.alpha.iter().map(|c| argb(*c)).collect(),
surface_card_a50: with_alpha(colors.surface_card, 0x80),
surface_main_a22: with_alpha(colors.surface_main, 0x38),
surface_main_a30: with_alpha(colors.surface_main, 0x4d),
frost_border: argb(colors.alpha[ALPHA_PCTS.iter().position(|p| *p == 10).unwrap_or(4)]),
is_dark,
}
}
/// The current theme as the QML token document.
pub fn theme_json() -> String {
let colors = colors_for_slug(&current_slug());
serde_json::to_string(&tokens_for(&colors)).unwrap_or_else(|_| "{}".into())
}
/// Re-resolve + republish after a slug change (the push_colors moment).
pub fn publish_theme() {
let json = theme_json();
let slug = current_slug();
crate::ui(move |mut b| {
b.as_mut().set_theme_json(QString::from(json.as_str()));
b.as_mut().set_theme_slug(QString::from(slug.as_str()));
});
}
// ---------------------------------------------------------------------------
// The dropdown catalog (theme.rs dropdown_labels + the 2 synthetic rows)
// ---------------------------------------------------------------------------
#[derive(Serialize)]
struct ThemeEntry {
label: String,
slug: String,
#[serde(rename = "isLight")]
is_light: bool,
}
/// All 36 registry themes + "Auto (dynamic)" + "Custom" (theme.rs:128-142,
/// 217-227 — the synthetics only show under the All filter; QML filters).
pub fn theme_list_json() -> String {
let mut entries: Vec<ThemeEntry> = qbz_theme::theme_list()
.into_iter()
.map(|e| ThemeEntry {
label: e.display_name.to_string(),
slug: e.slug.to_string(),
is_light: e.is_light,
})
.collect();
entries.push(ThemeEntry {
label: "Auto (dynamic)".to_string(),
slug: "auto".to_string(),
is_light: false,
});
entries.push(ThemeEntry {
label: "Custom".to_string(),
slug: "custom".to_string(),
is_light: false,
});
serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into())
}