From 4d6de9cf969dee41009618a33f9cba62b20378dc Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 20 Sep 2026 21:09:12 -0400 Subject: [PATCH] settings/wallpaper: more restyle to appearance header --- core/internal/server/themes/list_installed.go | 100 ++++++ .../server/themes/list_installed_test.go | 56 ++++ core/internal/server/themes/types.go | 6 + quickshell/Common/CacheData.qml | 5 +- .../Modules/Settings/ThemeColorsTab.qml | 156 +-------- .../Modules/Settings/WallpaperColorsTab.qml | 310 +++++++++++------- .../Settings/Widgets/SettingsSwatchGrid.qml | 20 +- .../Widgets/SettingsThemeColorDots.qml | 73 +++++ .../Widgets/SettingsWallpaperThumb.qml | 13 +- quickshell/Services/DMSService.qml | 2 + quickshell/Services/MatugenPreviewService.qml | 75 +++++ .../translations/settings_search_index.json | 92 ++++-- 12 files changed, 609 insertions(+), 299 deletions(-) create mode 100644 core/internal/server/themes/list_installed_test.go create mode 100644 quickshell/Modules/Settings/Widgets/SettingsThemeColorDots.qml create mode 100644 quickshell/Services/MatugenPreviewService.qml diff --git a/core/internal/server/themes/list_installed.go b/core/internal/server/themes/list_installed.go index 76508bc00..a2de8e552 100644 --- a/core/internal/server/themes/list_installed.go +++ b/core/internal/server/themes/list_installed.go @@ -79,6 +79,104 @@ func addVariantsInfo(info *ThemeInfo, variants *themes.ThemeVariants) { } } +func paletteInfo(theme *themes.Theme) *PaletteInfo { + dark, light := defaultSchemes(theme) + info := &PaletteInfo{Dark: swatchColors(dark), Light: swatchColors(light)} + if info.Dark == nil && info.Light == nil { + return nil + } + return info +} + +func defaultSchemes(theme *themes.Theme) (themes.ColorScheme, themes.ColorScheme) { + v := theme.Variants + if v == nil { + return theme.Dark, theme.Light + } + if v.Type == "multi" { + return multiScheme(v, theme.Dark, false), multiScheme(v, theme.Light, true) + } + for _, opt := range v.Options { + if opt.ID == v.Default { + return overlay(theme.Dark, opt.Dark), overlay(theme.Light, opt.Light) + } + } + if len(v.Options) > 0 { + return overlay(theme.Dark, v.Options[0].Dark), overlay(theme.Light, v.Options[0].Light) + } + return theme.Dark, theme.Light +} + +// variants and flavors often carry surfaces only; the accent colors stay on the base scheme +func overlay(base, top themes.ColorScheme) themes.ColorScheme { + pick := func(fallback, value string) string { + if value != "" { + return value + } + return fallback + } + base.Primary = pick(base.Primary, top.Primary) + base.Secondary = pick(base.Secondary, top.Secondary) + base.PrimaryContainer = pick(base.PrimaryContainer, top.PrimaryContainer) + base.Info = pick(base.Info, top.Info) + base.Error = pick(base.Error, top.Error) + base.Warning = pick(base.Warning, top.Warning) + return base +} + +func multiScheme(v *themes.ThemeVariants, base themes.ColorScheme, light bool) themes.ColorScheme { + defaults := map[string]string{} + if v.Defaults != nil { + if light { + defaults = v.Defaults.Light + } else { + defaults = v.Defaults.Dark + } + } + scheme := base + flavorID := defaults["flavor"] + for _, f := range v.Flavors { + if f.ID != flavorID { + continue + } + if light { + scheme = overlay(base, f.Light) + } else { + scheme = overlay(base, f.Dark) + } + break + } + for _, a := range v.Accents { + if a.ID != defaults["accent"] { + continue + } + if accent, ok := a.FlavorColors[flavorID]; ok { + scheme = overlay(scheme, accent) + } + break + } + return scheme +} + +func swatchColors(scheme themes.ColorScheme) map[string]string { + if scheme.Primary == "" { + return nil + } + colors := map[string]string{"primary": scheme.Primary} + for key, value := range map[string]string{ + "secondary": scheme.Secondary, + "primaryContainer": scheme.PrimaryContainer, + "info": scheme.Info, + "error": scheme.Error, + "warning": scheme.Warning, + } { + if value != "" { + colors[key] = value + } + } + return colors +} + func HandleListInstalled(conn *ipc.ConnWriter, req ipc.Request) { manager, err := themes.NewManager() if err != nil { @@ -120,6 +218,7 @@ func HandleListInstalled(conn *ipc.ConnWriter, req ipc.Request) { HasUpdate: hasUpdate, } addVariantsInfo(&info, theme.Variants) + info.Palette = paletteInfo(&theme) result = append(result, info) } else { installed, err := manager.GetInstalledTheme(id) @@ -141,6 +240,7 @@ func HandleListInstalled(conn *ipc.ConnWriter, req ipc.Request) { FirstParty: isFirstParty(installed.Author), } addVariantsInfo(&info, installed.Variants) + info.Palette = paletteInfo(installed) result = append(result, info) } } diff --git a/core/internal/server/themes/list_installed_test.go b/core/internal/server/themes/list_installed_test.go new file mode 100644 index 000000000..e30f20d9d --- /dev/null +++ b/core/internal/server/themes/list_installed_test.go @@ -0,0 +1,56 @@ +package themes + +import ( + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" + "github.com/stretchr/testify/require" +) + +func TestPaletteInfoResolvesDefaultFlavorAndAccent(t *testing.T) { + theme := &themes.Theme{ + Variants: &themes.ThemeVariants{ + Type: "multi", + Defaults: &themes.MultiVariantDefaults{ + Dark: map[string]string{"flavor": "mocha", "accent": "mauve"}, + Light: map[string]string{"flavor": "latte", "accent": "mauve"}, + }, + Flavors: []themes.ThemeFlavor{ + {ID: "latte", Light: themes.ColorScheme{Surface: "#eff1f5", Info: "#04a5e5"}}, + {ID: "mocha", Dark: themes.ColorScheme{Surface: "#1e1e2e", Info: "#89dceb"}}, + }, + Accents: []themes.ThemeAccent{ + {ID: "rosewater", FlavorColors: map[string]themes.ColorScheme{"mocha": {Primary: "#f5e0dc"}}}, + {ID: "mauve", FlavorColors: map[string]themes.ColorScheme{ + "mocha": {Primary: "#cba6f7", Secondary: "#f5c2e7"}, + "latte": {Primary: "#8839ef", Secondary: "#ea76cb"}, + }}, + }, + }, + } + + palette := paletteInfo(theme) + + require.NotNil(t, palette) + require.Equal(t, map[string]string{"primary": "#cba6f7", "secondary": "#f5c2e7", "info": "#89dceb"}, palette.Dark) + require.Equal(t, map[string]string{"primary": "#8839ef", "secondary": "#ea76cb", "info": "#04a5e5"}, palette.Light) +} + +func TestPaletteInfoOverlaysDefaultOptionOnBase(t *testing.T) { + theme := &themes.Theme{ + Dark: themes.ColorScheme{Primary: "#000000", Secondary: "#111111"}, + Light: themes.ColorScheme{Primary: "#eeeeee"}, + Variants: &themes.ThemeVariants{ + Default: "ocean", + Options: []themes.ThemeVariant{ + {ID: "forest", Dark: themes.ColorScheme{Primary: "#00ff00"}}, + {ID: "ocean", Dark: themes.ColorScheme{Surface: "#001020", Secondary: "#0000ff"}, Light: themes.ColorScheme{Primary: "#0000aa"}}, + }, + }, + } + + palette := paletteInfo(theme) + + require.Equal(t, map[string]string{"primary": "#000000", "secondary": "#0000ff"}, palette.Dark) + require.Equal(t, map[string]string{"primary": "#0000aa"}, palette.Light) +} diff --git a/core/internal/server/themes/types.go b/core/internal/server/themes/types.go index 8f150cb85..ab3b23ddd 100644 --- a/core/internal/server/themes/types.go +++ b/core/internal/server/themes/types.go @@ -35,6 +35,11 @@ type VariantsInfo struct { Accents []AccentInfo `json:"accents,omitempty"` } +type PaletteInfo struct { + Dark map[string]string `json:"dark,omitempty"` + Light map[string]string `json:"light,omitempty"` +} + type ThemeInfo struct { ID string `json:"id"` Name string `json:"name"` @@ -48,5 +53,6 @@ type ThemeInfo struct { HasUpdate bool `json:"hasUpdate,omitempty"` HasVariants bool `json:"hasVariants,omitempty"` Variants *VariantsInfo `json:"variants,omitempty"` + Palette *PaletteInfo `json:"palette,omitempty"` WCAG *themes.ThemeWCAG `json:"wcag,omitempty"` } diff --git a/quickshell/Common/CacheData.qml b/quickshell/Common/CacheData.qml index e13cf0101..30cd136d6 100644 --- a/quickshell/Common/CacheData.qml +++ b/quickshell/Common/CacheData.qml @@ -24,13 +24,14 @@ Singleton { readonly property var _pinKeys: ["brightnessDevicePins", "wifiNetworkPins", "bluetoothDevicePins", "audioInputDevicePins", "audioOutputDevicePins"] readonly property var _historyKeys: ["browserUsageHistory", "filePickerUsageHistory"] - readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "processFilterTypes", "pluginViewSort", "pluginViewFilter", "dashFocusCardId", "mediaLyricsOpen"].concat(_pinKeys, _historyKeys) + readonly property var _dataKeys: ["wallpaperLastPath", "profileLastPath", "fileBrowserSettings", "processFilterTypes", "pluginViewSort", "pluginViewFilter", "dashFocusCardId", "mediaLyricsOpen", "matugenPreviews"].concat(_pinKeys, _historyKeys) property string wallpaperLastPath: "" property string profileLastPath: "" property string pluginViewFilter: "enabled" property string dashFocusCardId: "" property bool mediaLyricsOpen: false + property var matugenPreviews: ({}) property var pluginViewSort: ({ by: "modified", descending: true @@ -182,6 +183,7 @@ Singleton { pluginViewFilter = ["all", "enabled", "disabled", "updates"].includes(cache.pluginViewFilter) ? cache.pluginViewFilter : "enabled"; dashFocusCardId = typeof cache.dashFocusCardId === "string" ? cache.dashFocusCardId : ""; mediaLyricsOpen = cache.mediaLyricsOpen === true; + matugenPreviews = typeof cache.matugenPreviews?.key === "string" ? cache.matugenPreviews : {}; const pluginSort = cache.pluginViewSort; pluginViewSort = { by: ["name", "author", "modified"].includes(pluginSort?.by) ? pluginSort.by : "modified", @@ -248,6 +250,7 @@ Singleton { "pluginViewFilter": pluginViewFilter, "dashFocusCardId": dashFocusCardId, "mediaLyricsOpen": mediaLyricsOpen, + "matugenPreviews": matugenPreviews, "fileBrowserSettings": fileBrowserSettings, "configVersion": cacheConfigVersion }; diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index 9d7bcd9bb..924667da1 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -15,82 +15,17 @@ Item { property var parentModal: null property string pendingExtractJson: "" property var cachedSourceModes: Theme.availableSourceModes.map(option => option.label) - property var matugenSchemePreviews: ({}) - property string matugenPreviewRequestKey: "" - property string matugenPreviewLoadedKey: "" - readonly property string matugenPreviewSource: SettingsData.matugenSeedColor || Theme.getMatugenColor("source_color", Theme.primary).toString() - readonly property string matugenPreviewImage: (!SettingsData.matugenSeedColor && Theme.rawWallpaperPath && !Theme.rawWallpaperPath.startsWith("#")) ? Theme.rawWallpaperPath : "" - readonly property string matugenPreviewKey: matugenPreviewSource + "|" + (SettingsData.matugenContrast ?? 0) + "|" + matugenPreviewImage + "|" + SettingsData.matugenSpec - readonly property var currentPalette: ThemePalette.pick({ - "primary": Theme.primary, - "secondary": Theme.secondary, - "tertiary": Theme.tertiary, - "primaryContainer": Theme.primaryContainer, - "info": Theme.info, - "error": Theme.error, - "warning": Theme.warning - }) - property bool matugenPreviewFailed: false - readonly property bool matugenPreviewsReady: matugenPreviewLoadedKey === matugenPreviewKey || matugenPreviewFailed || !Theme.matugenAvailable - onMatugenPreviewKeyChanged: refreshMatugenSchemePreviews() + readonly property string matugenPreviewKey: MatugenPreviewService.key + readonly property bool matugenAvailable: Theme.matugenAvailable + onMatugenPreviewKeyChanged: MatugenPreviewService.refresh() + onMatugenAvailableChanged: MatugenPreviewService.refresh() property var installedRegistryThemes: [] - readonly property var matugenSchemeOptions: { - const mode = SessionData.isLightMode ? "light" : "dark"; - const options = []; - for (const option of Theme.availableMatugenSchemes) { - if (option.value === "scheme-smart" && !DMSService.matugenSmartSupported) - continue; - const colors = (matugenSchemePreviews[option.value] ?? matugenSchemePreviews["scheme-tonal-spot"])?.[mode]; - // a dms binary older than the tri-color preview returns the primary hex as a plain string - const primary = typeof colors === "string" ? colors : (colors?.primary ?? Theme.primary.toString()); - options.push({ - "value": option.value, - "label": option.label, - "primary": primary, - "secondary": colors?.secondary ?? primary, - "tertiary": colors?.tertiary ?? primary - }); - } - return options; - } - - function refreshMatugenSchemePreviews() { - if (!Theme.matugenAvailable) - return; - const requestKey = matugenPreviewKey; - if (requestKey === matugenPreviewLoadedKey || requestKey === matugenPreviewRequestKey) - return; - matugenPreviewRequestKey = requestKey; - matugenPreviewFailed = false; - - const args = [Proc.dmsBin, "matugen", "preview", "--source-color", matugenPreviewSource, "--contrast", String(SettingsData.matugenContrast ?? 0)]; - if (matugenPreviewImage) - args.push("--image", matugenPreviewImage); - if (SettingsData.matugenSpec === "2025") - args.push("--spec", "2025"); - Proc.runCommand("", args, (output, exitCode) => { - if (requestKey !== themeColorsTab.matugenPreviewRequestKey) - return; - themeColorsTab.matugenPreviewRequestKey = ""; - if (exitCode !== 0) { - themeColorsTab.matugenPreviewFailed = true; - return; - } - try { - themeColorsTab.matugenSchemePreviews = JSON.parse(output.trim()); - themeColorsTab.matugenPreviewLoadedKey = requestKey; - } catch (e) { - themeColorsTab.matugenPreviewFailed = true; - } - }); - } - Component.onCompleted: { if (DMSService.dmsAvailable) DMSService.listInstalledThemes(); if (PopoutService.pendingThemeInstall) Qt.callLater(() => showThemeBrowser()); - refreshMatugenSchemePreviews(); + MatugenPreviewService.refresh(); } Connections { @@ -108,13 +43,6 @@ Item { } } - Connections { - target: Theme - function onMatugenAvailableChanged() { - themeColorsTab.refreshMatugenSchemePreviews(); - } - } - SettingsPage { id: mainColumn @@ -237,73 +165,9 @@ Item { } } - Item { + SettingsThemeColorDots { width: parent.width - height: genericColorGrid.implicitHeight + Math.ceil(genericColorGrid.dotSize * 0.05) visible: Theme.currentThemeCategory === "generic" && Theme.currentTheme !== Theme.dynamic && Theme.currentThemeName !== "custom" - - Grid { - id: genericColorGrid - property var colorList: ["blue", "purple", "green", "orange", "red", "cyan", "pink", "amber", "coral", "monochrome"] - property int dotSize: Theme.minimumTouchTargetSize - columns: Math.ceil(colorList.length / 2) - rowSpacing: Theme.spacingS - columnSpacing: Theme.spacingS - anchors.horizontalCenter: parent.horizontalCenter - - Repeater { - model: genericColorGrid.colorList - - Rectangle { - required property string modelData - property string themeName: modelData - readonly property var colors: Theme.getThemeColors(themeName) - readonly property var palette: ThemePalette.pick(colors) - readonly property bool isActive: Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic - width: genericColorGrid.dotSize - height: genericColorGrid.dotSize - radius: width / 2 - color: "transparent" - scale: isActive ? 1.1 : 1 - - DankPaletteSwatch { - anchors.fill: parent - primaryColor: parent.palette.primary - secondaryColor: parent.palette.secondary - tertiaryColor: parent.palette.tertiary - } - - DankIcon { - anchors.centerIn: parent - name: "check" - size: Theme.iconSizeMedium - color: Theme.isLightColor(parent.colors.primary) ? Theme.contrastDark : Theme.contrastLight - visible: parent.isActive - } - - DankTooltipHost { - text: parent.colors.name - target: parent - hoverArea: mouseArea - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.switchTheme(parent.themeName) - } - - Behavior on scale { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } - } - } - } - } } Row { @@ -431,10 +295,10 @@ Item { SettingsSwatchGrid { id: schemeGrid - options: themeColorsTab.matugenSchemeOptions + options: MatugenPreviewService.schemeOptions currentValue: SettingsData.matugenScheme - enabled: themeColorsTab.matugenPreviewsReady - opacity: themeColorsTab.matugenPreviewsReady ? 1 : Theme.pendingOpacity + enabled: MatugenPreviewService.ready + opacity: MatugenPreviewService.ready ? 1 : Theme.pendingOpacity onSelected: value => SettingsData.setMatugenScheme(value) Behavior on opacity { @@ -446,7 +310,7 @@ Item { DankSpinner { anchors.centerIn: parent - running: !themeColorsTab.matugenPreviewsReady + running: !MatugenPreviewService.ready visible: running } } diff --git a/quickshell/Modules/Settings/WallpaperColorsTab.qml b/quickshell/Modules/Settings/WallpaperColorsTab.qml index f7a5cfdcf..28d16727a 100644 --- a/quickshell/Modules/Settings/WallpaperColorsTab.qml +++ b/quickshell/Modules/Settings/WallpaperColorsTab.qml @@ -39,7 +39,40 @@ Column { "error": Theme.error, "warning": Theme.warning }) - readonly property string modeLabel: SessionData.isLightMode ? I18n.tr("Light", "adjective, wallpaper thumbnail label for light mode") : I18n.tr("Dark", "adjective, wallpaper thumbnail label for dark mode") + readonly property bool dynamicTheme: Theme.currentTheme === Theme.dynamic && Theme.currentThemeCategory !== "registry" + readonly property bool genericTheme: Theme.currentThemeCategory === "generic" && Theme.currentTheme !== Theme.dynamic && Theme.currentThemeName !== "custom" + readonly property string themesDir: Quickshell.env("HOME") + "/.config/DankMaterialShell/themes" + readonly property var installedRegistryThemes: DMSService.installedThemes + readonly property var registryOptions: { + const mode = Theme.isLightMode ? "light" : "dark"; + return installedRegistryThemes.map(theme => { + const palette = ThemePalette.pick(theme.palette?.[mode] ?? theme.palette?.dark) ?? {}; + return { + "value": theme.sourceDir || theme.id, + "label": theme.name, + "primary": palette.primary ?? Theme.primary.toString(), + "secondary": palette.secondary, + "tertiary": palette.tertiary + }; + }); + } + readonly property string activeRegistryTheme: { + if (Theme.currentThemeName !== "custom" || !SettingsData.customThemeFile) + return ""; + for (const theme of installedRegistryThemes) { + if (SettingsData.customThemeFile.endsWith((theme.sourceDir || theme.id) + "/theme.json")) + return theme.sourceDir || theme.id; + } + return ""; + } + readonly property bool registryGridShown: !dynamicTheme && !genericTheme && registryOptions.length > 0 + readonly property bool registryPending: !dynamicTheme && !genericTheme && DMSService.dmsAvailable && !DMSService.installedThemesLoaded + readonly property string matugenPreviewKey: MatugenPreviewService.key + readonly property bool matugenAvailable: Theme.matugenAvailable + onMatugenPreviewKeyChanged: refreshPreviews() + onMatugenAvailableChanged: refreshPreviews() + onDynamicThemeChanged: refreshPreviews() + onVisibleChanged: refreshPreviews() readonly property string colorModeStatus: { if (SettingsData.matugenSmartMode) return I18n.tr("Wallpaper"); @@ -58,6 +91,14 @@ Column { WallpaperCyclingService.cyclingActive; SettingsData.detectAvailableIconThemes(); SettingsData.detectAvailableCursorThemes(); + refreshPreviews(); + if (DMSService.dmsAvailable) + DMSService.listInstalledThemes(); + } + + function refreshPreviews() { + if (visible && dynamicTheme) + MatugenPreviewService.refresh(); } function firstScreenName() { @@ -164,89 +205,11 @@ Column { ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set one of:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct OR\nQT_QPA_PLATFORMTHEME=qtengine\nas environment variables, and then restart the shell.\n\nOnly qt6ct requires qt6ct-kde to be installed.", "qt theme env error body")); } - component QuickTile: Rectangle { - id: tile - - property string title: "" - property string hint: "" - default property alias leading: leadingSlot.data - - signal clicked - - width: parent.width - height: Theme.listItemTwoLineHeight - radius: Theme.cornerRadiusM - color: Theme.foregroundColor(Theme.chipSurface, true) - - activeFocusOnTab: true - Accessible.role: Accessible.Button - Accessible.name: title - Accessible.description: hint - Accessible.onPressAction: tile.clicked() - Keys.onSpacePressed: tile.clicked() - Keys.onReturnPressed: tile.clicked() - - FocusRing {} - - Item { - id: leadingSlot - width: Theme.avatarSize - height: Theme.avatarSize - anchors.left: parent.left - anchors.leftMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - } - - Column { - anchors.left: leadingSlot.right - anchors.leftMargin: Theme.spacingM - anchors.right: chevron.left - anchors.rightMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXXS - - StyledText { - width: parent.width - text: tile.title - font.pixelSize: Theme.fontSizeMedium - font.weight: Theme.fontWeightMedium - color: Theme.surfaceText - elide: Text.ElideRight - } - - StyledText { - width: parent.width - text: tile.hint - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - elide: Text.ElideRight - } - } - - DankIcon { - id: chevron - name: "chevron_right" - size: Theme.iconSize - color: Theme.surfaceVariantText - rotation: I18n.isRtl ? 180 : 0 - anchors.right: parent.right - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - } - - StateLayer { - stateColor: Theme.surfaceText - cornerRadius: tile.radius - onClicked: tile.clicked() - } - } - SettingsCard { tab: "wallpaper" tags: ["background", "image", "picture", "light", "dark", "mode", "theme", "color", "palette"] title: I18n.tr("Appearance") settingKey: "wallpaper" - SettingsDropdownRow { tab: "wallpaper" tags: ["monitor", "display", "screen"] @@ -272,72 +235,187 @@ Column { SettingsWallpaperThumb { width: hero.thumbWidth + height: { + const natural = width * SettingsMetrics.wallpaperThumbRatio; + if (hero.stacked) + return natural; + return root.registryPending ? Math.max(natural, side.implicitHeight) : side.implicitHeight; + } path: root.currentWallpaper - badge: root.perMode ? root.modeLabel : "" onBrowse: root.openBrowser() onPickColor: root.pickColor() onClear: root.clearWallpaper() } Column { + id: side width: hero.stacked ? hero.width : hero.width - hero.thumbWidth - hero.spacing spacing: Theme.spacingM - DankButtonGroup { + Row { + id: modeRow width: parent.width - fillWidth: true - checkEnabled: false - model: [ - { - "text": I18n.tr("Light", "adjective, wallpaper thumbnail label for light mode"), - "icon": "light_mode" - }, - { - "text": I18n.tr("Dark", "adjective, wallpaper thumbnail label for dark mode"), - "icon": "dark_mode" + spacing: Theme.spacingS + + DankButtonGroup { + width: parent.width - scheduleButton.width - parent.spacing + fillWidth: true + checkEnabled: false + anchors.verticalCenter: parent.verticalCenter + model: [ + { + "text": I18n.tr("Light", "adjective, wallpaper thumbnail label for light mode"), + "icon": "light_mode" + }, + { + "text": I18n.tr("Dark", "adjective, wallpaper thumbnail label for dark mode"), + "icon": "dark_mode" + } + ] + currentIndex: SessionData.isLightMode ? 0 : 1 + selectionMode: "single" + onSelectionChanged: (index, selected) => { + if (!selected) + return; + const light = index === 0; + if (light === SessionData.isLightMode) + return; + Theme.screenTransition(); + Theme.setLightMode(light); } - ] - currentIndex: SessionData.isLightMode ? 0 : 1 - selectionMode: "single" - onSelectionChanged: (index, selected) => { - if (!selected) - return; - const light = index === 0; - if (light === SessionData.isLightMode) - return; - Theme.screenTransition(); - Theme.setLightMode(light); + } + + DankActionButton { + id: scheduleButton + iconName: "schedule" + anchors.verticalCenter: parent.verticalCenter + Accessible.name: I18n.tr("Dark mode") + tooltipText: root.colorModeStatus + onClicked: root.parentModal?.navigateTo("theme_schedule") } } - QuickTile { - title: I18n.tr("Dark mode") - hint: root.colorModeStatus - onClicked: root.parentModal?.navigateTo("theme_schedule") + SettingsSwatchGrid { + width: parent.width + visible: root.dynamicTheme + compact: true + options: MatugenPreviewService.schemeOptions + currentValue: SettingsData.matugenScheme + enabled: Theme.matugenAvailable && MatugenPreviewService.ready + opacity: MatugenPreviewService.ready ? 1 : Theme.pendingOpacity + onSelected: value => SettingsData.setMatugenScheme(value) - DankIcon { - anchors.centerIn: parent - name: "schedule" - size: Theme.iconSize - color: Theme.primary + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + } } } - QuickTile { - title: I18n.tr("Theme & colors") - hint: Theme.currentThemeLabel - onClicked: root.parentModal?.navigateTo("theme") + SettingsThemeColorDots { + width: parent.width + visible: root.genericTheme + } + + SettingsSwatchGrid { + width: parent.width + visible: root.registryGridShown + compact: true + maxHeight: hero.stacked ? 0 : hero.thumbWidth * SettingsMetrics.wallpaperThumbRatio - modeRow.height - side.spacing + options: root.registryOptions + currentValue: root.activeRegistryTheme + onSelected: value => { + SettingsData.set("customThemeFile", root.themesDir + "/" + value + "/theme.json"); + Theme.switchTheme("custom", true, true); + } + } + + Rectangle { + id: themeTile + width: parent.width + visible: !root.dynamicTheme && !root.genericTheme && !root.registryGridShown && !root.registryPending + height: hero.stacked ? Theme.listItemTwoLineHeight : Math.max(Theme.listItemTwoLineHeight, hero.thumbWidth * SettingsMetrics.wallpaperThumbRatio - modeRow.height - side.spacing) + radius: Theme.cornerRadiusM + color: Theme.foregroundColor(Theme.chipSurface, true) + + activeFocusOnTab: visible + Accessible.role: Accessible.Button + Accessible.name: I18n.tr("Theme & colors") + Accessible.description: Theme.currentThemeLabel + Accessible.onPressAction: root.parentModal?.navigateTo("theme") + Keys.onSpacePressed: root.parentModal?.navigateTo("theme") + Keys.onReturnPressed: root.parentModal?.navigateTo("theme") + + FocusRing {} DankPaletteSwatch { - anchors.fill: parent + id: themeSwatch + width: Theme.avatarSize + height: Theme.avatarSize + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter primaryColor: root.themePalette.primary secondaryColor: root.themePalette.secondary tertiaryColor: root.themePalette.tertiary } + + Column { + anchors.left: themeSwatch.right + anchors.leftMargin: Theme.spacingM + anchors.right: themeChevron.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXXS + + StyledText { + width: parent.width + text: I18n.tr("Theme & colors") + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + color: Theme.surfaceText + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: Theme.currentThemeLabel + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + } + } + + DankIcon { + id: themeChevron + name: "chevron_right" + size: Theme.iconSize + color: Theme.surfaceVariantText + rotation: I18n.isRtl ? 180 : 0 + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + } + + StateLayer { + stateColor: Theme.surfaceText + cornerRadius: themeTile.radius + onClicked: root.parentModal?.navigateTo("theme") + } } } } } + + SettingsNavRow { + tab: "wallpaper" + tags: ["theme", "color", "palette", "matugen", "dynamic", "custom", "registry"] + settingKey: "themeNav" + iconName: "format_paint" + title: I18n.tr("Theme & colors") + hint: I18n.tr("Choose and install themes", "theme and colors row hint") + onClicked: root.parentModal?.navigateTo("theme") + } } SettingsCard { diff --git a/quickshell/Modules/Settings/Widgets/SettingsSwatchGrid.qml b/quickshell/Modules/Settings/Widgets/SettingsSwatchGrid.qml index 92eb47b8d..7cb8f08a6 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsSwatchGrid.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsSwatchGrid.qml @@ -10,17 +10,22 @@ Flow { property var options: [] property string currentValue: "" - property real minTileWidth: SettingsMetrics.swatchTileMinWidth + property bool compact: false + property real maxHeight: 0 + property real minTileWidth: compact ? Theme.minimumTouchTargetSize + Theme.spacingM : SettingsMetrics.swatchTileMinWidth signal selected(string value) readonly property int columns: Math.max(1, Math.floor((width + spacing) / (minTileWidth + spacing))) readonly property real tileWidth: Math.floor((width - spacing * (columns - 1)) / columns) + readonly property real compactTileHeight: Theme.minimumTouchTargetSize + Theme.spacingS * 2 + readonly property int maxRows: maxHeight > 0 && compact ? Math.max(1, Math.floor((maxHeight + spacing) / (compactTileHeight + spacing))) : 0 + readonly property var shownOptions: maxRows > 0 ? options.slice(0, columns * maxRows) : options width: parent?.width ?? 0 spacing: Theme.spacingS Repeater { - model: root.options + model: root.shownOptions Rectangle { id: tile @@ -29,7 +34,7 @@ Flow { readonly property bool isActive: root.currentValue === modelData.value width: root.tileWidth - height: swatch.height + label.implicitHeight + Theme.spacingS * 3 + height: root.compact ? root.compactTileHeight : swatch.height + label.implicitHeight + Theme.spacingS * 3 radius: Theme.cornerRadiusM color: Theme.floatingWindowNestedSurface border.width: isActive ? Theme.outlineWidthFocused : Theme.outlineWidth @@ -64,8 +69,15 @@ Flow { tertiaryColor: tile.modelData.tertiary ?? tile.modelData.secondary ?? tile.modelData.primary } + DankTooltipHost { + text: root.compact ? tile.modelData.label : null + target: tile + hoverArea: tileMouse + } + StyledText { id: label + visible: !root.compact anchors.top: swatch.bottom anchors.topMargin: Theme.spacingS anchors.left: parent.left @@ -74,7 +86,7 @@ Flow { anchors.rightMargin: Theme.spacingXS text: tile.modelData.label font.pixelSize: Theme.fontSizeSmall - font.weight: tile.isActive ? Theme.fontWeightMedium : Theme.fontWeightNormal + font.weight: tile.isActive ? Theme.fontWeightMedium : Theme.fontWeight color: tile.isActive ? Theme.primary : Theme.surfaceText horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap diff --git a/quickshell/Modules/Settings/Widgets/SettingsThemeColorDots.qml b/quickshell/Modules/Settings/Widgets/SettingsThemeColorDots.qml new file mode 100644 index 000000000..19b5d3aab --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/SettingsThemeColorDots.qml @@ -0,0 +1,73 @@ +import QtQuick +import qs.Common +import qs.Widgets +import "../../../Common/ThemePalette.js" as ThemePalette + +Item { + id: root + + readonly property var colorList: ["blue", "purple", "green", "orange", "red", "cyan", "pink", "amber", "coral", "monochrome"] + readonly property int dotSize: Theme.minimumTouchTargetSize + + implicitHeight: grid.implicitHeight + Math.ceil(dotSize * 0.05) + + Grid { + id: grid + columns: Math.ceil(root.colorList.length / 2) + rowSpacing: Theme.spacingS + columnSpacing: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + + Repeater { + model: root.colorList + + Rectangle { + required property string modelData + readonly property var colors: Theme.getThemeColors(modelData) + readonly property var palette: ThemePalette.pick(colors) + readonly property bool isActive: Theme.currentThemeName === modelData && Theme.currentTheme !== Theme.dynamic + width: root.dotSize + height: root.dotSize + radius: width / 2 + color: "transparent" + scale: isActive ? 1.1 : 1 + + DankPaletteSwatch { + anchors.fill: parent + primaryColor: parent.palette.primary + secondaryColor: parent.palette.secondary + tertiaryColor: parent.palette.tertiary + } + + DankIcon { + anchors.centerIn: parent + name: "check" + size: Theme.iconSizeMedium + color: Theme.isLightColor(parent.colors.primary) ? Theme.contrastDark : Theme.contrastLight + visible: parent.isActive + } + + DankTooltipHost { + text: parent.colors.name + target: parent + hoverArea: mouseArea + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Theme.switchTheme(parent.modelData) + } + + Behavior on scale { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/Widgets/SettingsWallpaperThumb.qml b/quickshell/Modules/Settings/Widgets/SettingsWallpaperThumb.qml index 8ba471e17..4be970880 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsWallpaperThumb.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsWallpaperThumb.qml @@ -6,7 +6,6 @@ import qs.Widgets Rectangle { id: root - property string badge: "" property string path: "" property string placeholderIcon: "image" property string emptyText: I18n.tr("Not set", "wallpaper not set label") @@ -111,20 +110,28 @@ Rectangle { Rectangle { id: chip + anchors.left: parent.left anchors.bottom: parent.bottom anchors.margins: Theme.spacingS - width: Math.min(chipText.implicitWidth + Theme.spacingS * 2, parent.width - Theme.spacingS * 2) + // sized from unelided metrics: the elided text's own implicitWidth shrinks with the chip and collapses it + width: Math.min(chipMetrics.advanceWidth + Theme.spacingS * 2, parent.width - Theme.spacingS * 2) height: chipText.implicitHeight + Theme.spacingXS * 2 radius: Theme.cornerRadiusS color: Theme.withAlpha(Theme.scrimColor, Theme.scrimAlpha) + TextMetrics { + id: chipMetrics + font: chipText.font + text: chipMetrics.text + } + StyledText { id: chipText anchors.fill: parent anchors.leftMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS - text: root.badge !== "" ? root.badge + " ยท " + root.fileName : root.fileName + text: chipMetrics.text font.pixelSize: Theme.fontSizeSmall color: Theme.contrastLight elide: Text.ElideMiddle diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 1756c74d8..bbeda1862 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -27,6 +27,7 @@ Singleton { property var registries: [] property var availableThemes: [] property var installedThemes: [] + property bool installedThemesLoaded: false property bool isConnected: false readonly property bool isConnecting: requestSocket.connected && !requestSocket.linkUp property bool subscribeConnected: false @@ -672,6 +673,7 @@ Singleton { sendRequest("themes.listInstalled", null, response => { if (response.result) { installedThemes = response.result; + installedThemesLoaded = true; installedThemesReceived(response.result); } if (callback) { diff --git a/quickshell/Services/MatugenPreviewService.qml b/quickshell/Services/MatugenPreviewService.qml new file mode 100644 index 000000000..2b4c91078 --- /dev/null +++ b/quickshell/Services/MatugenPreviewService.qml @@ -0,0 +1,75 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Singleton { + id: root + + property var previews: CacheData.matugenPreviews.previews ?? ({}) + property string requestKey: "" + property string loadedKey: CacheData.matugenPreviews.key ?? "" + property bool failed: false + + readonly property string source: SettingsData.matugenSeedColor || Theme.getMatugenColor("source_color", Theme.primary).toString() + readonly property string image: (!SettingsData.matugenSeedColor && Theme.rawWallpaperPath && !Theme.rawWallpaperPath.startsWith("#")) ? Theme.rawWallpaperPath : "" + readonly property string key: source + "|" + (SettingsData.matugenContrast ?? 0) + "|" + image + "|" + SettingsData.matugenSpec + readonly property bool ready: loadedKey === key || failed || !Theme.matugenAvailable + readonly property var schemeOptions: { + const mode = SessionData.isLightMode ? "light" : "dark"; + const options = []; + for (const option of Theme.availableMatugenSchemes) { + if (option.value === "scheme-smart" && !DMSService.matugenSmartSupported) + continue; + const colors = (previews[option.value] ?? previews["scheme-tonal-spot"])?.[mode]; + // a dms binary older than the tri-color preview returns the primary hex as a plain string + const primary = typeof colors === "string" ? colors : (colors?.primary ?? Theme.primary.toString()); + options.push({ + "value": option.value, + "label": option.label, + "primary": primary, + "secondary": colors?.secondary ?? primary, + "tertiary": colors?.tertiary ?? primary + }); + } + return options; + } + + function refresh() { + if (!Theme.matugenAvailable) + return; + const wanted = key; + if (wanted === loadedKey || wanted === requestKey) + return; + requestKey = wanted; + failed = false; + + const args = [Proc.dmsBin, "matugen", "preview", "--source-color", source, "--contrast", String(SettingsData.matugenContrast ?? 0)]; + if (image) + args.push("--image", image); + if (SettingsData.matugenSpec === "2025") + args.push("--spec", "2025"); + Proc.runCommand("", args, (output, exitCode) => { + if (wanted !== root.requestKey) + return; + root.requestKey = ""; + if (exitCode !== 0) { + root.failed = true; + return; + } + try { + root.previews = JSON.parse(output.trim()); + root.loadedKey = wanted; + CacheData.matugenPreviews = { + "key": wanted, + "previews": root.previews + }; + CacheData.saveCache(); + } catch (e) { + root.failed = true; + } + }); + } +} diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 066071759..96da7736c 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -130,7 +130,8 @@ "tint", "wallpaper" ], - "page": "personalization" + "page": "personalization", + "icon": "schedule" }, { "section": "_hub_applications", @@ -712,34 +713,6 @@ ], "page": "personalization" }, - { - "section": "colorMode", - "label": "Dark mode", - "tabIndex": -1, - "category": "Wallpaper & colors", - "keywords": [ - "background", - "bg", - "color", - "colors", - "colour", - "colours", - "dark", - "dark mode", - "desktop", - "hue", - "image", - "light", - "mode", - "night", - "palette", - "picture", - "theme", - "tint", - "wallpaper" - ], - "page": "personalization" - }, { "section": "iconThemeDark", "label": "Dark mode theme", @@ -2131,6 +2104,67 @@ ], "icon": "memory" }, + { + "section": "colorMode", + "label": "Theme & colors", + "tabIndex": -1, + "category": "Wallpaper & colors", + "keywords": [ + "appearance", + "background", + "bg", + "color", + "colors", + "colour", + "colours", + "dark", + "desktop", + "hue", + "image", + "light", + "look", + "mode", + "palette", + "picture", + "style", + "theme", + "tint", + "wallpaper" + ], + "page": "personalization", + "icon": "schedule" + }, + { + "section": "themeNav", + "label": "Theme & colors", + "tabIndex": -1, + "category": "Wallpaper & colors", + "keywords": [ + "appearance", + "background", + "bg", + "color", + "colors", + "colour", + "colours", + "custom", + "desktop", + "dynamic", + "hue", + "image", + "look", + "matugen", + "palette", + "picture", + "registry", + "style", + "theme", + "tint", + "wallpaper" + ], + "page": "personalization", + "icon": "format_paint" + }, { "section": "focusRingWidth", "label": "Thickness",