feat(theme): add support for matugen smart mode and type (#3109)

* feat(theme): add support for matugen smart mode and type

* add another dry run to get smart type preview

* fix too much keywords in search index

* revert dont extract translations

* feat gate "Auto From Wallpaper" toggle row and the available matugen scheme for the pallete drop down

* re-extract settings search index

* make portal sync no longer disables matugen smart mode

* make sure cosmic theme mode also follow change from smart mode

* add translator context for Auto (Light Mode) / Auto (Dark Mode) / Auto From Wallpaper

* fix: gate matugen smart mode checks on the dynamic theme so stock themes keep portal sync and fix tile labels in control center
This commit is contained in:
haikalllp authored and GitHub committed 2026-08-27 09:40:23 -04:00
1 parent b7034fcadb
commit 42080db61b
15 files changed
+305 -20

No files matched your search

+5 -3
View File
@@ -63,9 +63,9 @@ func init() {
cmd.Flags().String("config-dir", "", "User config directory")
cmd.Flags().String("kind", "image", "Source type: image or hex")
cmd.Flags().String("value", "", "Wallpaper path or hex color")
cmd.Flags().String("mode", "dark", "Color mode: dark or light")
cmd.Flags().String("mode", "dark", "Color mode: dark, light, or smart (smart requires matugen 4.2+ and an image source)")
cmd.Flags().String("icon-theme", "System Default", "Icon theme name")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type")
cmd.Flags().String("matugen-type", "scheme-tonal-spot", "Matugen scheme type (supports scheme-smart on matugen 4.2+)")
cmd.Flags().Bool("run-user-templates", true, "Run user matugen templates")
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
@@ -77,6 +77,7 @@ func init() {
matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion")
matugenQueueCmd.Flags().Duration("timeout", 90*time.Second, "Timeout for waiting")
matugenPreviewCmd.Flags().String("source-color", "", "Source color used to generate previews")
matugenPreviewCmd.Flags().String("image", "", "Wallpaper image used to resolve the scheme-smart preview")
matugenPreviewCmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
matugenQtengineCmd.Flags().String("config-dir", "", "User config directory")
matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name")
@@ -221,8 +222,9 @@ func runMatugenCheck(cmd *cobra.Command, args []string) {
func runMatugenPreview(cmd *cobra.Command, args []string) {
sourceColor, _ := cmd.Flags().GetString("source-color")
imagePath, _ := cmd.Flags().GetString("image")
contrast, _ := cmd.Flags().GetFloat64("contrast")
previews, err := matugen.PreviewSchemes(sourceColor, contrast)
previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath)
if err != nil {
log.Fatalf("Failed to generate Matugen previews: %v", err)
}
+92 -6
View File
@@ -29,6 +29,7 @@ type ColorMode string
const (
ColorModeDark ColorMode = "dark"
ColorModeLight ColorMode = "light"
ColorModeSmart ColorMode = "smart"
)
type TemplateKind int
@@ -94,6 +95,7 @@ var (
matugenVersionOK bool
matugenSupportsCOE bool
matugenIsV4 bool
matugenIsV42 bool
)
type Options struct {
@@ -139,16 +141,17 @@ var previewSchemeTypes = []string{
"scheme-rainbow",
}
func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePreview, error) {
func PreviewSchemes(sourceColor string, contrast float64, imagePath string) (map[string]SchemePreview, error) {
if sourceColor == "" {
return nil, fmt.Errorf("source color is required")
}
previews := make(map[string]SchemePreview, len(previewSchemeTypes))
previews := make(map[string]SchemePreview, len(previewSchemeTypes)+1)
for _, schemeType := range previewSchemeTypes {
output, err := runMatugenDryRun(&Options{
Kind: "hex",
Value: sourceColor,
Mode: ColorModeDark,
MatugenType: schemeType,
Contrast: contrast,
})
@@ -163,9 +166,39 @@ func PreviewSchemes(sourceColor string, contrast float64) (map[string]SchemePrev
}
previews[schemeType] = SchemePreview{Dark: dark, Light: light}
}
previews["scheme-smart"] = smartSchemePreview(previews["scheme-tonal-spot"], contrast, imagePath)
return previews, nil
}
func smartSchemePreview(fallback SchemePreview, contrast float64, imagePath string) SchemePreview {
if imagePath == "" {
return fallback
}
flags, err := detectMatugenVersion()
if err != nil || !flags.isV42 {
return fallback
}
output, err := runMatugenDryRun(&Options{
Kind: "image",
Value: imagePath,
Mode: ColorModeDark,
MatugenType: "scheme-smart",
Contrast: contrast,
})
if err != nil {
log.Warnf("Smart scheme preview failed falling back to tonal-spot: %v", err)
return fallback
}
dark := extractMatugenColor(output, "primary", "dark")
light := extractMatugenColor(output, "primary", "light")
if dark == "" || light == "" {
log.Warn("Smart scheme preview failed falling back to tonal-spot: primary colors missing from matugen output")
return fallback
}
return SchemePreview{Dark: dark, Light: light}
}
func (o *Options) ColorsOutput() string {
return filepath.Join(o.StateDir, "dms-colors.json")
}
@@ -280,6 +313,14 @@ func Run(opts Options) error {
func buildOnce(opts *Options) (bool, error) {
defer os.Remove(opts.colorsStaging())
flags, err := detectMatugenVersion()
if err != nil {
return false, err
}
if err := resolveSmartMode(opts, flags); err != nil {
return false, err
}
cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
if err != nil {
return false, fmt.Errorf("failed to create temp config: %w", err)
@@ -757,6 +798,7 @@ func extractTOMLSection(content, startMarker, endMarker string) string {
type matugenFlags struct {
supportsCOE bool
isV4 bool
isV42 bool
}
func detectMatugenVersion() (matugenFlags, error) {
@@ -764,12 +806,17 @@ func detectMatugenVersion() (matugenFlags, error) {
defer matugenVersionMu.Unlock()
if matugenVersionOK {
return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenIsV42}, nil
}
return detectMatugenVersionLocked()
}
func SupportsSmart() bool {
flags, err := detectMatugenVersion()
return err == nil && flags.isV42
}
func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
matugenVersionMu.Lock()
defer matugenVersionMu.Unlock()
@@ -779,7 +826,7 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
if err != nil {
return old, false
}
changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4
changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4 || flags.isV42 != old.isV42
return flags, changed
}
@@ -811,6 +858,7 @@ func detectMatugenVersionLocked() (matugenFlags, error) {
matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
matugenIsV4 = major >= 4
matugenIsV42 = major > 4 || (major == 4 && minor >= 2)
matugenVersionOK = true
if matugenSupportsCOE {
@@ -819,7 +867,7 @@ func detectMatugenVersionLocked() (matugenFlags, error) {
if matugenIsV4 {
log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr)
}
return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenIsV42}, nil
}
func buildMatugenArgs(baseArgs []string, flags matugenFlags) []string {
@@ -896,7 +944,7 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
default:
baseArgs = []string{opts.Kind, opts.Value}
}
baseArgs = append(baseArgs, "-m", "dark", "-t", opts.MatugenType, "--json", "hex", "--dry-run")
baseArgs = append(baseArgs, "-m", string(opts.Mode), "-t", opts.MatugenType, "--json", "hex", "--dry-run")
baseArgs = appendContrastArg(baseArgs, opts.Contrast)
if flags.isV4 {
baseArgs = append(baseArgs, "--source-color-index", "0", "--old-json-output")
@@ -940,6 +988,44 @@ func extractMatugenColor(jsonStr, colorName, variant string) string {
return variantData
}
func extractTopLevelString(jsonStr, key string) string {
var data map[string]any
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return ""
}
if val, ok := data[key].(string); ok {
return val
}
return ""
}
func resolveSmartMode(opts *Options, flags matugenFlags) error {
if opts.MatugenType == "scheme-smart" && !flags.isV42 {
return fmt.Errorf("scheme-smart requires matugen 4.2+")
}
if opts.Mode != ColorModeSmart {
return nil
}
if !flags.isV42 {
return fmt.Errorf("smart mode requires matugen 4.2+")
}
if opts.Kind != "image" || opts.StockColors != "" {
opts.Mode = ColorModeDark
return nil
}
output, err := runMatugenDryRun(opts)
if err != nil {
return fmt.Errorf("smart mode resolution failed: %w", err)
}
resolved := extractTopLevelString(output, "mode")
if resolved != string(ColorModeLight) && resolved != string(ColorModeDark) {
return fmt.Errorf("smart mode resolution returned unexpected mode %q", resolved)
}
log.Infof("Smart mode resolved to %s", resolved)
opts.Mode = ColorMode(resolved)
return nil
}
func extractNestedColor(jsonStr, colorName, variant string) string {
var data map[string]any
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+93
View File
@@ -970,3 +970,96 @@ func TestSyncQtengineConfigBumpsMtime(t *testing.T) {
}
assert.Equal(t, string(before), string(after))
}
func TestExtractTopLevelString(t *testing.T) {
tests := []struct {
name string
jsonStr string
key string
want string
}{
{name: "returns top-level string key", jsonStr: `{"mode":"light","colors":{}}`, key: "mode", want: "light"},
{name: "empty for missing key", jsonStr: `{"mode":"dark"}`, key: "is_dark_mode", want: ""},
{name: "empty for non-string value", jsonStr: `{"mode":42}`, key: "mode", want: ""},
{name: "empty for invalid json", jsonStr: `{mode`, key: "mode", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, extractTopLevelString(tc.jsonStr, tc.key))
})
}
}
func TestResolveSmartMode(t *testing.T) {
tests := []struct {
name string
mode ColorMode
matugenType string
kind string
stockColors string
isV42 bool
wantErr string
wantMode ColorMode
}{
{
name: "concrete mode passes through untouched",
mode: ColorModeLight,
kind: "image",
isV42: true,
wantMode: ColorModeLight,
},
{
name: "smart mode errors on old matugen",
mode: ColorModeSmart,
kind: "image",
isV42: false,
wantErr: "smart mode requires matugen 4.2+",
},
{
name: "scheme-smart errors on old matugen",
mode: ColorModeDark,
matugenType: "scheme-smart",
kind: "image",
isV42: false,
wantErr: "scheme-smart requires matugen 4.2+",
},
{
name: "smart falls back to dark for non-image kind",
mode: ColorModeSmart,
kind: "hex",
isV42: true,
wantMode: ColorModeDark,
},
{
name: "smart falls back to dark for stock colors",
mode: ColorModeSmart,
kind: "image",
stockColors: `{"primary":{}}`,
isV42: true,
wantMode: ColorModeDark,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
opts := &Options{
Mode: tc.mode,
MatugenType: tc.matugenType,
Kind: tc.kind,
StockColors: tc.stockColors,
}
if opts.MatugenType == "" {
opts.MatugenType = "scheme-tonal-spot"
}
err := resolveSmartMode(opts, matugenFlags{isV42: tc.isV42})
if tc.wantErr != "" {
assert.ErrorContains(t, err, tc.wantErr)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.wantMode, opts.Mode)
})
}
}
+3 -2
View File
@@ -72,7 +72,8 @@ func handleMatugenQueue(conn *models.Conn, req models.Request) {
func handleMatugenStatus(conn *models.Conn, req models.Request) {
queue := matugen.GetQueue()
models.Respond(conn, req.ID, map[string]bool{
"running": queue.IsRunning(),
"pending": queue.HasPending(),
"running": queue.IsRunning(),
"pending": queue.HasPending(),
"smartSupported": matugen.SupportsSmart(),
})
}
+1
View File
@@ -141,6 +141,7 @@ While the core colors above are required, you can also customize these optional
- `scheme-monochrome` - Monochromatic color schemes
- `scheme-neutral` - Neutral, subdued color schemes
- `scheme-rainbow` - Rainbow-like color schemes
- `scheme-smart` - Automatically picks the variant based on the wallpaper (requires matugen 4.2+)
- `scheme-tonal-spot` - Tonal spot color schemes (default)
## Setting Custom Theme
+3 -2
View File
@@ -611,9 +611,10 @@ Singleton {
}
}
function setLightMode(lightMode) {
function setLightMode(lightMode, skipWallpaperSync) {
isSwitchingMode = true;
syncWallpaperForCurrentMode(lightMode);
if (!skipWallpaperSync)
syncWallpaperForCurrentMode(lightMode);
isLightMode = lightMode;
saveSettings();
Qt.callLater(() => {
+7
View File
@@ -165,6 +165,7 @@ Singleton {
property string customThemeFile: ""
property var registryThemeVariants: ({})
property string matugenScheme: "scheme-tonal-spot"
property bool matugenSmartMode: false
property real matugenContrast: 0
property bool runUserMatugenTemplates: true
property string matugenTargetMonitor: ""
@@ -2732,6 +2733,12 @@ Singleton {
}
}
function setMatugenSmartMode(enabled) {
if (matugenSmartMode === enabled)
return;
set("matugenSmartMode", enabled);
}
function setMatugenContrast(value) {
if (matugenContrast === value)
return;
+17 -1
View File
@@ -361,6 +361,10 @@ Singleton {
"value": "scheme-rainbow",
"label": I18n.tr("Rainbow", "matugen color scheme option"),
"description": I18n.tr("Diverse palette spanning the full spectrum.")
}), ({
"value": "scheme-smart",
"label": I18n.tr("Smart", "matugen color scheme option"),
"description": I18n.tr("Automatically picks the scheme variant based on the wallpaper.")
})]
function getMatugenScheme(value) {
@@ -1198,6 +1202,11 @@ Singleton {
}
function setLightMode(light, savePrefs = true, enableTransition = false) {
if (typeof SettingsData !== "undefined" && SettingsData.matugenSmartMode) {
SettingsData.matugenSmartMode = false;
SettingsData.saveSettings();
}
if (enableTransition) {
screenTransition();
lightModeTransitionTimer.lightMode = light;
@@ -1525,7 +1534,7 @@ Singleton {
const desired = {
"kind": kind,
"value": value,
"mode": isLight ? "light" : "dark",
"mode": (typeof SettingsData !== "undefined" && SettingsData.matugenSmartMode && kind === "image" && !stockColors) ? "smart" : (isLight ? "light" : "dark"),
"iconTheme": iconTheme || "System Default",
"matugenType": matugenType || "scheme-tonal-spot",
"runUserTemplates": (typeof SettingsData !== "undefined") ? SettingsData.runUserMatugenTemplates : true
@@ -2110,6 +2119,13 @@ Singleton {
const colorsText = dynamicColorsFileView.text();
if (colorsText) {
root.matugenColors = JSON.parse(colorsText);
if (typeof SettingsData !== "undefined" && SettingsData.matugenSmartMode && currentTheme === dynamic && root.matugenColors && root.matugenColors.mode && typeof SessionData !== "undefined" && !SessionData.isSwitchingMode) {
const resolvedLight = root.matugenColors.mode === "light";
if (SessionData.isLightMode !== resolvedLight) {
SessionData.setLightMode(resolvedLight, true);
SettingsData.updateCosmicThemeMode(resolvedLight);
}
}
if (typeof ToastService !== "undefined") {
ToastService.clearWallpaperError();
}
@@ -13,6 +13,7 @@ var SPEC = {
customThemeFile: { def: "" },
registryThemeVariants: { def: {} },
matugenScheme: { def: "scheme-tonal-spot", onChange: "regenSystemThemes" },
matugenSmartMode: { def: false, onChange: "regenSystemThemes" },
matugenContrast: { def: 0, onChange: "regenSystemThemes" },
runUserMatugenTemplates: { def: true, onChange: "regenSystemThemes" },
matugenTargetMonitor: { def: "", onChange: "regenSystemThemes" },
@@ -743,8 +743,11 @@ Column {
switch (widgetData.id || "") {
case "nightMode":
return I18n.tr("Night Mode");
case "darkMode":
case "darkMode": {
if (SettingsData.matugenSmartMode && Theme.currentTheme === Theme.dynamic)
return SessionData.isLightMode ? I18n.tr("Auto (Light Mode)", "dark mode toggle label when matugen smart mode resolved light") : I18n.tr("Auto (Dark Mode)", "dark mode toggle label when matugen smart mode resolved dark");
return I18n.tr("Dark Mode");
}
case "idleInhibitor":
return SessionService.idleInhibited ? I18n.tr("Keeping Awake") : I18n.tr("Keep Awake");
default:
+31 -5
View File
@@ -20,9 +20,11 @@ Item {
readonly property bool frameModeActive: SettingsData.frameEnabled
property var cachedIconThemes: SettingsData.availableIconThemes
property var cachedCursorThemes: SettingsData.availableCursorThemes
property var cachedMatugenSchemes: Theme.availableMatugenSchemes.map(option => option.label)
readonly property bool matugenSmartCapable: Theme.matugenAvailable && DMSService.matugenSmartSupported
property var cachedMatugenSchemes: Theme.availableMatugenSchemes.filter(option => DMSService.matugenSmartSupported || option.value !== "scheme-smart").map(option => option.label)
property var matugenSchemePreviews: ({})
property string matugenPreviewSource: ""
property string matugenPreviewImage: ""
property real matugenPreviewContrast: 0
property string matugenPreviewRequestKey: ""
property var installedRegistryThemes: []
@@ -34,7 +36,7 @@ Item {
const mode = SessionData.isLightMode ? "light" : "dark";
for (var i = 0; i < Theme.availableMatugenSchemes.length; i++) {
const option = Theme.availableMatugenSchemes[i];
const preview = matugenSchemePreviews[option.value];
const preview = matugenSchemePreviews[option.value] || matugenSchemePreviews["scheme-tonal-spot"];
if (preview?.[mode])
map[option.label] = preview[mode];
}
@@ -284,14 +286,18 @@ Item {
return;
const sourceColor = Theme.getMatugenColor("source_color", Theme.primary).toString();
const contrast = SettingsData.matugenContrast ?? 0;
const requestKey = sourceColor + "|" + contrast;
if (sourceColor === matugenPreviewSource && contrast === matugenPreviewContrast && Object.keys(matugenSchemePreviews).length > 0)
const imagePath = (Theme.rawWallpaperPath && !Theme.rawWallpaperPath.startsWith("#")) ? Theme.rawWallpaperPath : "";
const requestKey = sourceColor + "|" + contrast + "|" + imagePath;
if (sourceColor === matugenPreviewSource && contrast === matugenPreviewContrast && imagePath === matugenPreviewImage && Object.keys(matugenSchemePreviews).length > 0)
return;
if (requestKey === matugenPreviewRequestKey)
return;
matugenPreviewRequestKey = requestKey;
Proc.runCommand("", [Proc.dmsBin, "matugen", "preview", "--source-color", sourceColor, "--contrast", contrast.toString()], (output, exitCode) => {
const args = [Proc.dmsBin, "matugen", "preview", "--source-color", sourceColor, "--contrast", contrast.toString()];
if (imagePath)
args.push("--image", imagePath);
Proc.runCommand("", args, (output, exitCode) => {
if (requestKey !== themeColorsTab.matugenPreviewRequestKey)
return;
if (exitCode !== 0) {
@@ -301,6 +307,7 @@ Item {
try {
themeColorsTab.matugenSchemePreviews = JSON.parse(output.trim());
themeColorsTab.matugenPreviewSource = sourceColor;
themeColorsTab.matugenPreviewImage = imagePath;
themeColorsTab.matugenPreviewContrast = contrast;
} catch (e) {
themeColorsTab.matugenPreviewRequestKey = "";
@@ -1313,12 +1320,31 @@ Item {
}
}
SettingsToggleRow {
tab: "theme"
tags: ["matugen", "smart", "wallpaper", "auto", "mode"]
settingKey: "matugenSmartMode"
text: I18n.tr("Auto From Wallpaper", "toggle that lets wallpaper brightness decide light/dark mode")
description: I18n.tr("Dark or light mode follows the wallpaper brightness")
checked: SettingsData.matugenSmartMode
visible: matugenSmartCapable
enabled: Theme.currentTheme === Theme.dynamic
opacity: enabled ? 1 : 0.4
onToggled: checked => {
if (checked && SessionData.themeModeAutoEnabled)
SessionData.setThemeModeAutoEnabled(false);
SettingsData.setMatugenSmartMode(checked);
}
}
DankToggle {
id: themeModeAutoToggle
width: parent.width
text: I18n.tr("Automatic Control")
checked: SessionData.themeModeAutoEnabled
onToggled: checked => {
if (checked && SettingsData.matugenSmartMode)
SettingsData.setMatugenSmartMode(false);
SessionData.setThemeModeAutoEnabled(checked);
}
+9
View File
@@ -24,6 +24,7 @@ Singleton {
property bool isConnected: false
readonly property bool isConnecting: requestSocket.connected && !requestSocket.linkUp
property bool subscribeConnected: false
property bool matugenSmartSupported: false
readonly property string socketPath: Quickshell.env("DMS_SOCKET")
@@ -150,6 +151,7 @@ Singleton {
root.isConnected = true;
root.connectionStateChanged();
subscribeSocket.connected = true;
root.refreshMatugenStatus();
return;
}
root.isConnected = false;
@@ -212,6 +214,13 @@ Singleton {
}
}
function refreshMatugenStatus() {
sendRequest("matugen.status", null, response => {
if (!response.error && response.result)
matugenSmartSupported = response.result.smartSupported === true;
});
}
function sendSubscribeRequest() {
const request = {
"method": "subscribe",
+2
View File
@@ -91,6 +91,8 @@ Singleton {
return false;
if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled)
return false;
if (SettingsData.matugenSmartMode && typeof Theme !== "undefined" && Theme.currentTheme === Theme.dynamic)
return false;
return typeof Theme !== "undefined";
}
+1
View File
@@ -17,6 +17,7 @@
"color14": {"dark": "{{dank16.color14.dark.hex}}", "light": "{{dank16.color14.light.hex}}", "default": "{{dank16.color14.default.hex}}"},
"color15": {"dark": "{{dank16.color15.dark.hex}}", "light": "{{dank16.color15.light.hex}}", "default": "{{dank16.color15.default.hex}}"}
},
"mode": "{{mode}}",
"colors": {
"dark": {
"background": "{{colors.background.dark.hex}}",
@@ -3564,6 +3564,42 @@
"icon": "apps",
"description": "Sync dark mode with settings portals for system-wide theme hints"
},
{
"section": "matugenSmartMode",
"label": "Auto From Wallpaper",
"tabIndex": 10,
"category": "Theme & Colors",
"keywords": [
"appearance",
"auto",
"background",
"backlight",
"bg",
"bright",
"brightness",
"colors",
"dark",
"dark mode",
"day",
"desktop",
"dim",
"follows",
"image",
"light",
"light mode",
"look",
"matugen",
"mode",
"night",
"picture",
"scheme",
"smart",
"style",
"theme",
"wallpaper"
],
"description": "Dark or light mode follows the wallpaper brightness"
},
{
"section": "cursorHideAfterInactive",
"label": "Auto-Hide Timeout",