diff --git a/core/internal/server/themes/list.go b/core/internal/server/themes/list.go index 66f9ce2a..2f60b5cd 100644 --- a/core/internal/server/themes/list.go +++ b/core/internal/server/themes/list.go @@ -31,7 +31,7 @@ func HandleList(conn net.Conn, req models.Request) { result := make([]ThemeInfo, len(themeList)) for i, t := range themeList { installed, _ := manager.IsInstalled(t) - result[i] = ThemeInfo{ + info := ThemeInfo{ ID: t.ID, Name: t.Name, Version: t.Version, @@ -42,6 +42,17 @@ func HandleList(conn net.Conn, req models.Request) { Installed: installed, FirstParty: isFirstParty(t.Author), } + if t.Variants != nil && len(t.Variants.Options) > 0 { + info.HasVariants = true + info.Variants = &VariantsInfo{ + Default: t.Variants.Default, + Options: make([]VariantInfo, len(t.Variants.Options)), + } + for j, v := range t.Variants.Options { + info.Variants.Options[j] = VariantInfo{ID: v.ID, Name: v.Name} + } + } + result[i] = info } models.Respond(conn, req.ID, result) diff --git a/core/internal/server/themes/list_installed.go b/core/internal/server/themes/list_installed.go index e9ccc882..aee480b7 100644 --- a/core/internal/server/themes/list_installed.go +++ b/core/internal/server/themes/list_installed.go @@ -8,6 +8,20 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" ) +func addVariantsInfo(info *ThemeInfo, variants *themes.ThemeVariants) { + if variants == nil || len(variants.Options) == 0 { + return + } + info.HasVariants = true + info.Variants = &VariantsInfo{ + Default: variants.Default, + Options: make([]VariantInfo, len(variants.Options)), + } + for i, v := range variants.Options { + info.Variants.Options[i] = VariantInfo{ID: v.ID, Name: v.Name} + } +} + func HandleListInstalled(conn net.Conn, req models.Request) { manager, err := themes.NewManager() if err != nil { @@ -46,7 +60,7 @@ func HandleListInstalled(conn net.Conn, req models.Request) { hasUpdate = hasUpdates } - result = append(result, ThemeInfo{ + info := ThemeInfo{ ID: theme.ID, Name: theme.Name, Version: theme.Version, @@ -55,7 +69,9 @@ func HandleListInstalled(conn net.Conn, req models.Request) { SourceDir: id, FirstParty: isFirstParty(theme.Author), HasUpdate: hasUpdate, - }) + } + addVariantsInfo(&info, theme.Variants) + result = append(result, info) } else { installed, err := manager.GetInstalledTheme(id) if err != nil { @@ -66,7 +82,7 @@ func HandleListInstalled(conn net.Conn, req models.Request) { }) continue } - result = append(result, ThemeInfo{ + info := ThemeInfo{ ID: installed.ID, Name: installed.Name, Version: installed.Version, @@ -74,7 +90,9 @@ func HandleListInstalled(conn net.Conn, req models.Request) { Description: installed.Description, SourceDir: id, FirstParty: isFirstParty(installed.Author), - }) + } + addVariantsInfo(&info, installed.Variants) + result = append(result, info) } } diff --git a/core/internal/server/themes/types.go b/core/internal/server/themes/types.go index a2e1dd80..04f8960c 100644 --- a/core/internal/server/themes/types.go +++ b/core/internal/server/themes/types.go @@ -1,14 +1,26 @@ package themes -type ThemeInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Author string `json:"author,omitempty"` - Description string `json:"description,omitempty"` - PreviewPath string `json:"previewPath,omitempty"` - SourceDir string `json:"sourceDir,omitempty"` - Installed bool `json:"installed,omitempty"` - FirstParty bool `json:"firstParty,omitempty"` - HasUpdate bool `json:"hasUpdate,omitempty"` +type VariantInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type VariantsInfo struct { + Default string `json:"default,omitempty"` + Options []VariantInfo `json:"options,omitempty"` +} + +type ThemeInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + PreviewPath string `json:"previewPath,omitempty"` + SourceDir string `json:"sourceDir,omitempty"` + Installed bool `json:"installed,omitempty"` + FirstParty bool `json:"firstParty,omitempty"` + HasUpdate bool `json:"hasUpdate,omitempty"` + HasVariants bool `json:"hasVariants,omitempty"` + Variants *VariantsInfo `json:"variants,omitempty"` } diff --git a/core/internal/themes/manager.go b/core/internal/themes/manager.go index 3cabc43e..63357aec 100644 --- a/core/internal/themes/manager.go +++ b/core/internal/themes/manager.go @@ -80,21 +80,35 @@ func (m *Manager) Install(theme Theme, registryThemeDir string) error { return fmt.Errorf("failed to write theme file: %w", err) } - for _, preview := range []string{"preview-dark.svg", "preview-light.svg"} { - srcPath := filepath.Join(registryThemeDir, preview) - exists, _ := afero.Exists(m.fs, srcPath) - if !exists { + m.copyPreviewFiles(registryThemeDir, themeDir, theme) + return nil +} + +func (m *Manager) copyPreviewFiles(srcDir, dstDir string, theme Theme) { + previews := []string{"preview-dark.svg", "preview-light.svg"} + + if theme.Variants != nil { + for _, v := range theme.Variants.Options { + previews = append(previews, + fmt.Sprintf("preview-%s.svg", v.ID), + fmt.Sprintf("preview-%s-dark.svg", v.ID), + fmt.Sprintf("preview-%s-light.svg", v.ID), + ) + } + } + + for _, preview := range previews { + srcPath := filepath.Join(srcDir, preview) + if exists, _ := afero.Exists(m.fs, srcPath); !exists { continue } data, err := afero.ReadFile(m.fs, srcPath) if err != nil { continue } - dstPath := filepath.Join(themeDir, preview) + dstPath := filepath.Join(dstDir, preview) _ = afero.WriteFile(m.fs, dstPath, data, 0644) } - - return nil } func (m *Manager) InstallFromRegistry(registry *Registry, themeID string) error { diff --git a/core/internal/themes/registry.go b/core/internal/themes/registry.go index 9e0f1b15..4a32ba3c 100644 --- a/core/internal/themes/registry.go +++ b/core/internal/themes/registry.go @@ -13,35 +13,49 @@ import ( const registryRepo = "https://github.com/AvengeMedia/dms-plugin-registry.git" type ColorScheme struct { - Primary string `json:"primary"` - PrimaryText string `json:"primaryText"` - PrimaryContainer string `json:"primaryContainer"` - Secondary string `json:"secondary"` - Surface string `json:"surface"` - SurfaceText string `json:"surfaceText"` - SurfaceVariant string `json:"surfaceVariant"` - SurfaceVariantText string `json:"surfaceVariantText"` - SurfaceTint string `json:"surfaceTint"` - Background string `json:"background"` - BackgroundText string `json:"backgroundText"` - Outline string `json:"outline"` - SurfaceContainer string `json:"surfaceContainer"` - SurfaceContainerHigh string `json:"surfaceContainerHigh"` - Error string `json:"error"` - Warning string `json:"warning"` - Info string `json:"info"` + Primary string `json:"primary,omitempty"` + PrimaryText string `json:"primaryText,omitempty"` + PrimaryContainer string `json:"primaryContainer,omitempty"` + Secondary string `json:"secondary,omitempty"` + Surface string `json:"surface,omitempty"` + SurfaceText string `json:"surfaceText,omitempty"` + SurfaceVariant string `json:"surfaceVariant,omitempty"` + SurfaceVariantText string `json:"surfaceVariantText,omitempty"` + SurfaceTint string `json:"surfaceTint,omitempty"` + Background string `json:"background,omitempty"` + BackgroundText string `json:"backgroundText,omitempty"` + Outline string `json:"outline,omitempty"` + SurfaceContainer string `json:"surfaceContainer,omitempty"` + SurfaceContainerHigh string `json:"surfaceContainerHigh,omitempty"` + SurfaceContainerHighest string `json:"surfaceContainerHighest,omitempty"` + Error string `json:"error,omitempty"` + Warning string `json:"warning,omitempty"` + Info string `json:"info,omitempty"` +} + +type ThemeVariant struct { + ID string `json:"id"` + Name string `json:"name"` + Dark ColorScheme `json:"dark,omitempty"` + Light ColorScheme `json:"light,omitempty"` +} + +type ThemeVariants struct { + Default string `json:"default,omitempty"` + Options []ThemeVariant `json:"options,omitempty"` } type Theme struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Author string `json:"author"` - Description string `json:"description"` - Dark ColorScheme `json:"dark"` - Light ColorScheme `json:"light"` - PreviewPath string `json:"-"` - SourceDir string `json:"sourceDir,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Dark ColorScheme `json:"dark"` + Light ColorScheme `json:"light"` + Variants *ThemeVariants `json:"variants,omitempty"` + PreviewPath string `json:"-"` + SourceDir string `json:"sourceDir,omitempty"` } type GitClient interface { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 20f102b9..d4e43913 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -65,6 +65,7 @@ Singleton { property string currentThemeName: "blue" property string currentThemeCategory: "generic" property string customThemeFile: "" + property var registryThemeVariants: ({}) property string matugenScheme: "scheme-tonal-spot" property bool runUserMatugenTemplates: true property string matugenTargetMonitor: "" @@ -1556,6 +1557,19 @@ Singleton { return workspaceNameIcons[workspaceName] || null; } + function getRegistryThemeVariant(themeId, defaultVariant) { + return registryThemeVariants[themeId] || defaultVariant || ""; + } + + function setRegistryThemeVariant(themeId, variantId) { + var variants = JSON.parse(JSON.stringify(registryThemeVariants)); + variants[themeId] = variantId; + registryThemeVariants = variants; + saveSettings(); + if (typeof Theme !== "undefined") + Theme.reloadCustomThemeVariant(); + } + function toggleDankBarVisible() { const defaultBar = barConfigs[0] || getBarConfig("default"); if (defaultBar) { diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 55bfdbcf..8dffbc55 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -93,6 +93,8 @@ Singleton { property var _pendingGenerateParams: null property var customThemeData: null property var customThemeRawData: null + readonly property var currentThemeVariants: customThemeRawData?.variants || null + readonly property string currentThemeId: customThemeRawData?.id || "" Component.onCompleted: { Quickshell.execDetached(["mkdir", "-p", stateDir]); @@ -604,21 +606,60 @@ Singleton { function loadCustomTheme(themeData) { customThemeRawData = themeData; + const colorMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark"; + + var baseColors = {}; if (themeData.dark || themeData.light) { - const colorMode = (typeof SessionData !== "undefined" && SessionData.isLightMode) ? "light" : "dark"; - const selectedTheme = themeData[colorMode] || themeData.dark || themeData.light; - customThemeData = selectedTheme; + baseColors = themeData[colorMode] || themeData.dark || themeData.light || {}; } else { - customThemeData = themeData; + baseColors = themeData; } + if (themeData.variants && themeData.variants.options && themeData.variants.options.length > 0) { + const themeId = themeData.id || ""; + const selectedVariantId = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeVariant(themeId, themeData.variants.default) : themeData.variants.default; + const variant = findVariant(themeData.variants.options, selectedVariantId); + if (variant) { + const variantColors = variant[colorMode] || variant.dark || variant.light || {}; + customThemeData = mergeColors(baseColors, variantColors); + generateSystemThemesFromCurrentTheme(); + return; + } + } + + customThemeData = baseColors; generateSystemThemesFromCurrentTheme(); } + function findVariant(options, variantId) { + if (!variantId || !options) + return null; + for (var i = 0; i < options.length; i++) { + if (options[i].id === variantId) + return options[i]; + } + return options[0] || null; + } + + function mergeColors(base, overlay) { + var result = JSON.parse(JSON.stringify(base)); + for (var key in overlay) { + if (overlay[key]) + result[key] = overlay[key]; + } + return result; + } + function loadCustomThemeFromFile(filePath) { customThemeFileView.path = filePath; } + function reloadCustomThemeVariant() { + if (currentTheme !== "custom" || !customThemeRawData) + return; + loadCustomTheme(customThemeRawData); + } + property alias availableThemeNames: root._availableThemeNames readonly property var _availableThemeNames: StockThemes.getAllThemeNames() property string currentThemeName: currentTheme @@ -912,6 +953,16 @@ Singleton { if (customThemeRawData && (customThemeRawData.dark || customThemeRawData.light)) { darkTheme = customThemeRawData.dark || customThemeRawData.light; lightTheme = customThemeRawData.light || customThemeRawData.dark; + + if (customThemeRawData.variants && customThemeRawData.variants.options) { + const themeId = customThemeRawData.id || ""; + const selectedVariantId = typeof SettingsData !== "undefined" ? SettingsData.getRegistryThemeVariant(themeId, customThemeRawData.variants.default) : customThemeRawData.variants.default; + const variant = findVariant(customThemeRawData.variants.options, selectedVariantId); + if (variant) { + darkTheme = mergeColors(darkTheme, variant.dark || {}); + lightTheme = mergeColors(lightTheme, variant.light || {}); + } + } } else { darkTheme = customThemeData; lightTheme = customThemeData; diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 7a272c5e..2596bbd9 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -9,6 +9,7 @@ var SPEC = { currentThemeName: { def: "blue", onChange: "applyStoredTheme" }, currentThemeCategory: { def: "generic" }, customThemeFile: { def: "" }, + registryThemeVariants: { def: {} }, matugenScheme: { def: "scheme-tonal-spot", onChange: "regenSystemThemes" }, runUserMatugenTemplates: { def: true, onChange: "regenSystemThemes" }, matugenTargetMonitor: { def: "", onChange: "regenSystemThemes" }, diff --git a/quickshell/Modules/Settings/ThemeBrowser.qml b/quickshell/Modules/Settings/ThemeBrowser.qml index 72906e10..11674893 100644 --- a/quickshell/Modules/Settings/ThemeBrowser.qml +++ b/quickshell/Modules/Settings/ThemeBrowser.qml @@ -382,13 +382,23 @@ FloatingWindow { } delegate: Rectangle { + id: themeDelegate width: themeBrowserList.width height: hasPreview ? 140 : themeDelegateContent.implicitHeight + Theme.spacingM * 2 radius: Theme.cornerRadius property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex property bool isInstalled: modelData.installed || false property bool isFirstParty: modelData.firstParty || false - property string previewPath: "/tmp/dankdots-plugin-registry/themes/" + (modelData.sourceDir || modelData.id) + "/preview-" + (Theme.isLightMode ? "light" : "dark") + ".svg" + property bool hasVariants: modelData.hasVariants || false + property var variants: modelData.variants || null + property string selectedVariantId: hasVariants && variants ? (variants.default || (variants.options[0]?.id ?? "")) : "" + property string previewPath: { + const baseDir = "/tmp/dankdots-plugin-registry/themes/" + (modelData.sourceDir || modelData.id); + const mode = Theme.isLightMode ? "light" : "dark"; + if (hasVariants && selectedVariantId) + return baseDir + "/preview-" + selectedVariantId + "-" + mode + ".svg"; + return baseDir + "/preview-" + mode + ".svg"; + } property bool hasPreview: previewImage.status === Image.Ready color: isSelected ? Theme.primarySelected : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) border.color: isSelected ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) @@ -479,6 +489,26 @@ FloatingWindow { font.weight: Font.Medium } } + + Rectangle { + height: 18 + width: variantsText.implicitWidth + Theme.spacingS + radius: 9 + color: Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.15) + border.color: Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.4) + border.width: 1 + visible: themeDelegate.hasVariants + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: variantsText + anchors.centerIn: parent + text: I18n.tr("%1 variants").arg(themeDelegate.variants?.options?.length ?? 0) + font.pixelSize: Theme.fontSizeSmall + color: Theme.secondary + font.weight: Font.Medium + } + } } StyledText { @@ -495,10 +525,44 @@ FloatingWindow { color: Theme.surfaceVariantText width: parent.width wrapMode: Text.WordWrap - maximumLineCount: 3 + maximumLineCount: themeDelegate.hasVariants ? 2 : 3 elide: Text.ElideRight visible: modelData.description && modelData.description.length > 0 } + + Flow { + width: parent.width + spacing: Theme.spacingXS + visible: themeDelegate.hasVariants + + Repeater { + model: themeDelegate.variants?.options ?? [] + + Rectangle { + property bool isActive: themeDelegate.selectedVariantId === modelData.id + height: 22 + width: variantChipText.implicitWidth + Theme.spacingS * 2 + radius: 11 + color: isActive ? Theme.primary : Theme.surfaceContainerHigh + border.color: isActive ? Theme.primary : Theme.outline + border.width: 1 + + StyledText { + id: variantChipText + anchors.centerIn: parent + text: modelData.name + font.pixelSize: Theme.fontSizeSmall + color: isActive ? Theme.primaryText : Theme.surfaceText + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: themeDelegate.selectedVariantId = modelData.id + } + } + } + } } Rectangle { diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index c834758a..b41facc7 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -108,317 +108,184 @@ Item { } Column { + id: themeCategoryColumn spacing: Theme.spacingM anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - DankButtonGroup { - id: themeCategoryGroup - property bool isRegistryTheme: Theme.currentThemeCategory === "registry" - property int currentThemeIndex: { - if (isRegistryTheme) - return 4; - if (Theme.currentTheme === Theme.dynamic) - return 2; - if (Theme.currentThemeName === "custom") - return 3; - if (Theme.currentThemeCategory === "catppuccin") - return 1; - return 0; - } - property int pendingThemeIndex: -1 + Item { + width: parent.width + height: themeCategoryGroup.implicitHeight + clip: true - model: DMSService.dmsAvailable ? ["Generic", "Catppuccin", "Auto", "Custom", "Registry"] : ["Generic", "Catppuccin", "Auto", "Custom"] - currentIndex: currentThemeIndex - selectionMode: "single" - anchors.horizontalCenter: parent.horizontalCenter - onSelectionChanged: (index, selected) => { - if (!selected) - return; - pendingThemeIndex = index; - } - onAnimationCompleted: { - if (pendingThemeIndex === -1) - return; - switch (pendingThemeIndex) { - case 0: - Theme.switchThemeCategory("generic", "blue"); - break; - case 1: - Theme.switchThemeCategory("catppuccin", "cat-mauve"); - break; - case 2: - if (ToastService.wallpaperErrorStatus === "matugen_missing") - ToastService.showError(I18n.tr("matugen not found - install matugen package for dynamic theming", "matugen error")); - else if (ToastService.wallpaperErrorStatus === "error") - ToastService.showError(I18n.tr("Wallpaper processing failed - check wallpaper path", "wallpaper error")); - else - Theme.switchThemeCategory("dynamic", Theme.dynamic); - break; - case 3: - Theme.switchThemeCategory("custom", "custom"); - break; - case 4: - Theme.switchThemeCategory("registry", ""); - break; + DankButtonGroup { + id: themeCategoryGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 420 ? Theme.spacingS : Theme.spacingL + minButtonWidth: parent.width < 420 ? 44 : 64 + textSize: parent.width < 420 ? Theme.fontSizeSmall : Theme.fontSizeMedium + property bool isRegistryTheme: Theme.currentThemeCategory === "registry" + property int currentThemeIndex: { + if (isRegistryTheme) + return 4; + if (Theme.currentTheme === Theme.dynamic) + return 2; + if (Theme.currentThemeName === "custom") + return 3; + if (Theme.currentThemeCategory === "catppuccin") + return 1; + return 0; + } + property int pendingThemeIndex: -1 + + model: DMSService.dmsAvailable ? ["Generic", "Catppuccin", "Auto", "Custom", "Registry"] : ["Generic", "Catppuccin", "Auto", "Custom"] + currentIndex: currentThemeIndex + selectionMode: "single" + onSelectionChanged: (index, selected) => { + if (!selected) + return; + pendingThemeIndex = index; + } + onAnimationCompleted: { + if (pendingThemeIndex === -1) + return; + switch (pendingThemeIndex) { + case 0: + Theme.switchThemeCategory("generic", "blue"); + break; + case 1: + Theme.switchThemeCategory("catppuccin", "cat-mauve"); + break; + case 2: + if (ToastService.wallpaperErrorStatus === "matugen_missing") + ToastService.showError(I18n.tr("matugen not found - install matugen package for dynamic theming", "matugen error")); + else if (ToastService.wallpaperErrorStatus === "error") + ToastService.showError(I18n.tr("Wallpaper processing failed - check wallpaper path", "wallpaper error")); + else + Theme.switchThemeCategory("dynamic", Theme.dynamic); + break; + case 3: + Theme.switchThemeCategory("custom", "custom"); + break; + case 4: + Theme.switchThemeCategory("registry", ""); + break; + } + pendingThemeIndex = -1; } - pendingThemeIndex = -1; } } - Column { + Flow { + id: genericColorFlow spacing: Theme.spacingS - anchors.horizontalCenter: parent.horizontalCenter + width: parent.width visible: Theme.currentThemeCategory === "generic" && Theme.currentTheme !== Theme.dynamic && Theme.currentThemeName !== "custom" + property int dotSize: width < 300 ? 28 : 32 - Row { - spacing: Theme.spacingM - anchors.horizontalCenter: parent.horizontalCenter + Repeater { + model: ["blue", "purple", "green", "orange", "red", "cyan", "pink", "amber", "coral", "monochrome"] - Repeater { - model: ["blue", "purple", "green", "orange", "red"] + Rectangle { + required property string modelData + property string themeName: modelData + width: genericColorFlow.dotSize + height: genericColorFlow.dotSize + radius: width / 2 + color: Theme.getThemeColors(themeName).primary + border.color: Theme.outline + border.width: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 2 : 1 + scale: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1 Rectangle { - required property string modelData - property string themeName: modelData - width: 32 - height: 32 - radius: 16 - color: Theme.getThemeColors(themeName).primary - border.color: Theme.outline - border.width: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 2 : 1 - scale: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1 + width: nameText.contentWidth + Theme.spacingS * 2 + height: nameText.contentHeight + Theme.spacingXS * 2 + color: Theme.surfaceContainer + radius: Theme.cornerRadius + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingXS + anchors.horizontalCenter: parent.horizontalCenter + visible: mouseArea.containsMouse - Rectangle { - width: nameText.contentWidth + Theme.spacingS * 2 - height: nameText.contentHeight + Theme.spacingXS * 2 - color: Theme.surfaceContainer - radius: Theme.cornerRadius - anchors.bottom: parent.top - anchors.bottomMargin: Theme.spacingXS - anchors.horizontalCenter: parent.horizontalCenter - visible: mouseArea.containsMouse - - StyledText { - id: nameText - text: Theme.getThemeColors(parent.parent.themeName).name - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.centerIn: parent - } - } - - 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 - } - } - - Behavior on border.width { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } + StyledText { + id: nameText + text: Theme.getThemeColors(parent.parent.themeName).name + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.centerIn: parent } } - } - } - Row { - spacing: Theme.spacingM - anchors.horizontalCenter: parent.horizontalCenter + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Theme.switchTheme(parent.themeName) + } - Repeater { - model: ["cyan", "pink", "amber", "coral", "monochrome"] - - Rectangle { - required property string modelData - property string themeName: modelData - width: 32 - height: 32 - radius: 16 - color: Theme.getThemeColors(themeName).primary - border.color: Theme.outline - border.width: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 2 : 1 - scale: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1 - - Rectangle { - width: nameText2.contentWidth + Theme.spacingS * 2 - height: nameText2.contentHeight + Theme.spacingXS * 2 - color: Theme.surfaceContainer - radius: Theme.cornerRadius - anchors.bottom: parent.top - anchors.bottomMargin: Theme.spacingXS - anchors.horizontalCenter: parent.horizontalCenter - visible: mouseArea2.containsMouse - - StyledText { - id: nameText2 - text: Theme.getThemeColors(parent.parent.themeName).name - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.centerIn: parent - } - } - - MouseArea { - id: mouseArea2 - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.switchTheme(parent.themeName) - } - - Behavior on scale { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } - } - - Behavior on border.width { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } + Behavior on scale { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing } } } } } - Column { + Flow { + id: catColorFlow spacing: Theme.spacingS - anchors.horizontalCenter: parent.horizontalCenter + width: parent.width visible: Theme.currentThemeCategory === "catppuccin" && Theme.currentTheme !== Theme.dynamic && Theme.currentThemeName !== "custom" + property int dotSize: width < 300 ? 28 : 32 - Row { - spacing: Theme.spacingM - anchors.horizontalCenter: parent.horizontalCenter + Repeater { + model: ["cat-rosewater", "cat-flamingo", "cat-pink", "cat-mauve", "cat-red", "cat-maroon", "cat-peach", "cat-yellow", "cat-green", "cat-teal", "cat-sky", "cat-sapphire", "cat-blue", "cat-lavender"] - Repeater { - model: ["cat-rosewater", "cat-flamingo", "cat-pink", "cat-mauve", "cat-red", "cat-maroon", "cat-peach"] + Rectangle { + required property string modelData + property string themeName: modelData + width: catColorFlow.dotSize + height: catColorFlow.dotSize + radius: width / 2 + color: Theme.getCatppuccinColor(themeName) + border.color: Theme.outline + border.width: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 2 : 1 + scale: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1 Rectangle { - required property string modelData - property string themeName: modelData - width: 32 - height: 32 - radius: 16 - color: Theme.getCatppuccinColor(themeName) - border.color: Theme.outline - border.width: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 2 : 1 - scale: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1 + width: nameTextCat.contentWidth + Theme.spacingS * 2 + height: nameTextCat.contentHeight + Theme.spacingXS * 2 + color: Theme.surfaceContainer + radius: Theme.cornerRadius + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingXS + anchors.horizontalCenter: parent.horizontalCenter + visible: mouseAreaCat.containsMouse - Rectangle { - width: nameTextCat.contentWidth + Theme.spacingS * 2 - height: nameTextCat.contentHeight + Theme.spacingXS * 2 - color: Theme.surfaceContainer - radius: Theme.cornerRadius - anchors.bottom: parent.top - anchors.bottomMargin: Theme.spacingXS - anchors.horizontalCenter: parent.horizontalCenter - visible: mouseAreaCat.containsMouse - - StyledText { - id: nameTextCat - text: Theme.getCatppuccinVariantName(parent.parent.themeName) - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.centerIn: parent - } - } - - MouseArea { - id: mouseAreaCat - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.switchTheme(parent.themeName) - } - - Behavior on scale { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } - } - - Behavior on border.width { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } + StyledText { + id: nameTextCat + text: Theme.getCatppuccinVariantName(parent.parent.themeName) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.centerIn: parent } } - } - } - Row { - spacing: Theme.spacingM - anchors.horizontalCenter: parent.horizontalCenter + MouseArea { + id: mouseAreaCat + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Theme.switchTheme(parent.themeName) + } - Repeater { - model: ["cat-yellow", "cat-green", "cat-teal", "cat-sky", "cat-sapphire", "cat-blue", "cat-lavender"] - - Rectangle { - required property string modelData - property string themeName: modelData - width: 32 - height: 32 - radius: 16 - color: Theme.getCatppuccinColor(themeName) - border.color: Theme.outline - border.width: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 2 : 1 - scale: (Theme.currentThemeName === themeName && Theme.currentTheme !== Theme.dynamic) ? 1.1 : 1 - - Rectangle { - width: nameTextCat2.contentWidth + Theme.spacingS * 2 - height: nameTextCat2.contentHeight + Theme.spacingXS * 2 - color: Theme.surfaceContainer - radius: Theme.cornerRadius - anchors.bottom: parent.top - anchors.bottomMargin: Theme.spacingXS - anchors.horizontalCenter: parent.horizontalCenter - visible: mouseAreaCat2.containsMouse - - StyledText { - id: nameTextCat2 - text: Theme.getCatppuccinVariantName(parent.parent.themeName) - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - anchors.centerIn: parent - } - } - - MouseArea { - id: mouseAreaCat2 - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: Theme.switchTheme(parent.themeName) - } - - Behavior on scale { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } - } - - Behavior on border.width { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.emphasizedEasing - } + Behavior on scale { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing } } } @@ -608,7 +475,10 @@ Item { visible: Theme.currentThemeCategory === "registry" Grid { - columns: 3 + id: themeGrid + property int cardWidth: registrySection.width < 350 ? 100 : 140 + property int cardHeight: registrySection.width < 350 ? 72 : 100 + columns: Math.max(1, Math.floor((registrySection.width + spacing) / (cardWidth + spacing))) spacing: Theme.spacingS anchors.horizontalCenter: parent.horizontalCenter visible: themeColorsTab.installedRegistryThemes.length > 0 @@ -619,9 +489,18 @@ Item { Rectangle { id: themeCard property bool isActive: Theme.currentThemeCategory === "registry" && Theme.currentThemeName === "custom" && SettingsData.customThemeFile && SettingsData.customThemeFile.endsWith((modelData.sourceDir || modelData.id) + "/theme.json") - property string previewPath: Quickshell.env("HOME") + "/.config/DankMaterialShell/themes/" + (modelData.sourceDir || modelData.id) + "/preview-" + (Theme.isLightMode ? "light" : "dark") + ".svg" - width: 140 - height: 100 + property bool hasVariants: modelData.hasVariants || false + property var variants: modelData.variants || null + property string selectedVariant: hasVariants ? SettingsData.getRegistryThemeVariant(modelData.id, variants?.default || "") : "" + property string previewPath: { + const baseDir = Quickshell.env("HOME") + "/.config/DankMaterialShell/themes/" + (modelData.sourceDir || modelData.id); + const mode = Theme.isLightMode ? "light" : "dark"; + if (hasVariants && selectedVariant) + return baseDir + "/preview-" + selectedVariant + "-" + mode + ".svg"; + return baseDir + "/preview-" + mode + ".svg"; + } + width: themeGrid.cardWidth + height: themeGrid.cardHeight radius: Theme.cornerRadius color: Theme.surfaceVariant border.color: isActive ? Theme.primary : Theme.outline @@ -648,7 +527,7 @@ Item { DankIcon { anchors.centerIn: parent name: "palette" - size: 32 + size: themeGrid.cardWidth < 120 ? 24 : 32 color: Theme.primary visible: previewImage.status === Image.Error || previewImage.status === Image.Null } @@ -657,18 +536,18 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - height: 24 + height: themeGrid.cardWidth < 120 ? 18 : 22 radius: Theme.cornerRadius color: Qt.rgba(0, 0, 0, 0.6) StyledText { anchors.centerIn: parent text: modelData.name - font.pixelSize: Theme.fontSizeSmall + font.pixelSize: themeGrid.cardWidth < 120 ? Theme.fontSizeSmall - 2 : Theme.fontSizeSmall color: "white" font.weight: Font.Medium elide: Text.ElideRight - width: parent.width - Theme.spacingS * 2 + width: parent.width - Theme.spacingXS * 2 horizontalAlignment: Text.AlignHCenter } } @@ -676,21 +555,40 @@ Item { Rectangle { anchors.top: parent.top anchors.right: parent.right - anchors.margins: 4 - width: 20 - height: 20 - radius: 10 + anchors.margins: themeGrid.cardWidth < 120 ? 2 : 4 + width: themeGrid.cardWidth < 120 ? 16 : 20 + height: width + radius: width / 2 color: Theme.primary visible: themeCard.isActive DankIcon { anchors.centerIn: parent name: "check" - size: 14 + size: themeGrid.cardWidth < 120 ? 10 : 14 color: Theme.surface } } + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: themeGrid.cardWidth < 120 ? 2 : 4 + width: themeGrid.cardWidth < 120 ? 16 : 20 + height: width + radius: width / 2 + color: Theme.secondary + visible: themeCard.hasVariants && !deleteButton.visible + + StyledText { + anchors.centerIn: parent + text: themeCard.variants?.options?.length || 0 + font.pixelSize: themeGrid.cardWidth < 120 ? Theme.fontSizeSmall - 4 : Theme.fontSizeSmall - 2 + color: Theme.surface + font.weight: Font.Bold + } + } + MouseArea { id: cardMouseArea anchors.fill: parent @@ -708,10 +606,10 @@ Item { id: deleteButton anchors.top: parent.top anchors.left: parent.left - anchors.margins: 4 - width: 24 - height: 24 - radius: 12 + anchors.margins: themeGrid.cardWidth < 120 ? 2 : 4 + width: themeGrid.cardWidth < 120 ? 18 : 24 + height: width + radius: width / 2 color: deleteMouseArea.containsMouse ? Theme.error : Qt.rgba(0, 0, 0, 0.6) opacity: cardMouseArea.containsMouse || deleteMouseArea.containsMouse ? 1 : 0 visible: opacity > 0 @@ -725,7 +623,7 @@ Item { DankIcon { anchors.centerIn: parent name: "close" - size: 14 + size: themeGrid.cardWidth < 120 ? 10 : 14 color: "white" } @@ -751,6 +649,75 @@ Item { } } + Column { + id: variantSelector + width: parent.width + spacing: Theme.spacingS + visible: activeThemeVariants !== null && activeThemeVariants.options && activeThemeVariants.options.length > 0 + + property string activeThemeId: { + if (Theme.currentThemeCategory !== "registry") + return ""; + for (var i = 0; i < themeColorsTab.installedRegistryThemes.length; i++) { + var t = themeColorsTab.installedRegistryThemes[i]; + if (SettingsData.customThemeFile && SettingsData.customThemeFile.endsWith((t.sourceDir || t.id) + "/theme.json")) + return t.id; + } + return ""; + } + property var activeThemeVariants: { + if (!activeThemeId) + return null; + for (var i = 0; i < themeColorsTab.installedRegistryThemes.length; i++) { + var t = themeColorsTab.installedRegistryThemes[i]; + if (t.id === activeThemeId && t.hasVariants) + return t.variants; + } + return null; + } + property string selectedVariant: activeThemeId ? SettingsData.getRegistryThemeVariant(activeThemeId, activeThemeVariants?.default || "") : "" + property var variantNames: { + if (!activeThemeVariants?.options) + return []; + return activeThemeVariants.options.map(v => v.name); + } + property int selectedIndex: { + if (!activeThemeVariants?.options || !selectedVariant) + return 0; + for (var i = 0; i < activeThemeVariants.options.length; i++) { + if (activeThemeVariants.options[i].id === selectedVariant) + return i; + } + return 0; + } + + Item { + width: parent.width + height: variantButtonGroup.implicitHeight + clip: true + + DankButtonGroup { + id: variantButtonGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 400 ? Theme.spacingS : Theme.spacingL + minButtonWidth: parent.width < 400 ? 44 : 64 + textSize: parent.width < 400 ? Theme.fontSizeSmall : Theme.fontSizeMedium + model: variantSelector.variantNames + currentIndex: variantSelector.selectedIndex + selectionMode: "single" + onAnimationCompleted: { + if (currentIndex < 0 || !variantSelector.activeThemeVariants?.options) + return; + const variantId = variantSelector.activeThemeVariants.options[currentIndex]?.id; + if (!variantId || variantId === variantSelector.selectedVariant) + return; + Theme.screenTransition(); + SettingsData.setRegistryThemeVariant(variantSelector.activeThemeId, variantId); + } + } + } + } + StyledText { text: I18n.tr("No themes installed. Browse themes to install from the registry.", "no registry themes installed hint") font.pixelSize: Theme.fontSizeSmall