From 79abcdfd7b30099402ce8a5a29d59c3cff132d80 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 19 Sep 2026 00:54:53 -0400 Subject: [PATCH] feat(theme): get expressive ~ new bold color palette options --- core/cmd/dms/commands_matugen.go | 12 +- core/internal/matugen/matugen.go | 107 ++++++++-- core/internal/matugen/matugen_test.go | 6 +- core/internal/matugen/spec.go | 122 +++++++++++ core/internal/matugen/spec_test.go | 190 ++++++++++++++++++ core/internal/server/matugen_handler.go | 2 + quickshell/Common/SettingsData.qml | 18 ++ quickshell/Common/Theme.qml | 8 +- quickshell/Common/settings/SettingsSpec.js | 8 + .../Modules/Settings/ThemeColorsTab.qml | 93 +++++++-- .../Settings/Widgets/SettingsSliderRow.qml | 6 +- .../translations/settings_search_index.json | 67 ++++++ 12 files changed, 597 insertions(+), 42 deletions(-) create mode 100644 core/internal/matugen/spec.go create mode 100644 core/internal/matugen/spec_test.go diff --git a/core/cmd/dms/commands_matugen.go b/core/cmd/dms/commands_matugen.go index 17c2440df..c8d3f3643 100644 --- a/core/cmd/dms/commands_matugen.go +++ b/core/cmd/dms/commands_matugen.go @@ -73,6 +73,8 @@ func init() { cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip") cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)") cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value") + cmd.Flags().String("seed-color", "", "Hex color to build the palette from instead of extracting one from the wallpaper") + cmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default) or 2025 (Material 3 Expressive)") } matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") @@ -80,6 +82,7 @@ func init() { 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)") + matugenPreviewCmd.Flags().String("spec", "", "Material color spec: 2021 (matugen native, default) or 2025 (Material 3 Expressive)") matugenQtengineCmd.Flags().String("config-dir", "", "User config directory") matugenQtengineCmd.Flags().String("icon-theme", "", "Icon theme name") } @@ -100,6 +103,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options { skipTemplates, _ := cmd.Flags().GetString("skip-templates") contrast, _ := cmd.Flags().GetFloat64("contrast") sourceMode, _ := cmd.Flags().GetString("source-mode") + seedColor, _ := cmd.Flags().GetString("seed-color") + spec, _ := cmd.Flags().GetString("spec") return matugen.Options{ StateDir: stateDir, @@ -117,6 +122,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options { TerminalsAlwaysDark: terminalsAlwaysDark, SkipTemplates: skipTemplates, SourceMode: sourceMode, + SeedColor: seedColor, + Spec: spec, } } @@ -155,6 +162,8 @@ func runMatugenQueue(cmd *cobra.Command, args []string) { "skipTemplates": opts.SkipTemplates, "contrast": opts.Contrast, "sourceMode": opts.SourceMode, + "seedColor": opts.SeedColor, + "spec": opts.Spec, "wait": wait, }, } @@ -228,7 +237,8 @@ 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, imagePath) + spec, _ := cmd.Flags().GetString("spec") + previews, err := matugen.PreviewSchemes(sourceColor, contrast, imagePath, spec) if err != nil { log.Fatalf("Failed to generate Matugen previews: %v", err) } diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index c5b01de14..6e0088909 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -110,6 +110,8 @@ type Options struct { MatugenType string Contrast float64 SourceMode string + SeedColor string + Spec string RunUserTemplates bool ColorsOnly bool StockColors string @@ -145,6 +147,14 @@ func schemeColors(output, mode string) SchemeColors { } } +func nestedSchemeColors(colors, mode string) SchemeColors { + return SchemeColors{ + Primary: extractNestedColor(colors, "primary", mode), + Secondary: extractNestedColor(colors, "secondary", mode), + Tertiary: extractNestedColor(colors, "tertiary", mode), + } +} + var previewSchemeTypes = []string{ "scheme-tonal-spot", "scheme-vibrant", @@ -157,13 +167,21 @@ var previewSchemeTypes = []string{ "scheme-rainbow", } -func PreviewSchemes(sourceColor string, contrast float64, imagePath string) (map[string]SchemePreview, error) { +func PreviewSchemes(sourceColor string, contrast float64, imagePath, spec string) (map[string]SchemePreview, error) { if sourceColor == "" { return nil, fmt.Errorf("source color is required") } previews := make(map[string]SchemePreview, len(previewSchemeTypes)+1) for _, schemeType := range previewSchemeTypes { + if spec == Spec2025 { + colors, err := GenerateSpecColors(sourceColor, schemeType, contrast, ColorModeDark, Spec2025) + if err != nil { + return nil, fmt.Errorf("preview %s: %w", schemeType, err) + } + previews[schemeType] = SchemePreview{Dark: nestedSchemeColors(colors, "dark"), Light: nestedSchemeColors(colors, "light")} + continue + } output, err := runMatugenDryRun(&Options{ Kind: "hex", Value: sourceColor, @@ -183,25 +201,27 @@ func PreviewSchemes(sourceColor string, contrast float64, imagePath string) (map previews[schemeType] = SchemePreview{Dark: dark, Light: light} } - previews["scheme-smart"] = smartSchemePreview(previews["scheme-tonal-spot"], contrast, imagePath) + previews["scheme-smart"] = smartSchemePreview(previews["scheme-tonal-spot"], sourceColor, contrast, imagePath) return previews, nil } -func smartSchemePreview(fallback SchemePreview, contrast float64, imagePath string) SchemePreview { - if imagePath == "" { - return fallback - } +func smartSchemePreview(fallback SchemePreview, sourceColor string, contrast float64, imagePath string) SchemePreview { flags, err := detectMatugenVersion() if err != nil || !flags.isV42 { return fallback } - output, err := runMatugenDryRun(&Options{ - Kind: "image", - Value: imagePath, + opts := &Options{ + Kind: "hex", + Value: sourceColor, Mode: ColorModeDark, MatugenType: "scheme-smart", Contrast: contrast, - }) + } + if imagePath != "" { + opts.Kind = "image" + opts.Value = imagePath + } + output, err := runMatugenDryRun(opts) if err != nil { log.Warnf("Smart scheme preview failed falling back to tonal-spot: %v", err) return fallback @@ -368,6 +388,23 @@ func buildOnce(opts *Options) (bool, error) { // they cannot disagree about the seed. Extraction failure (a format // image.Decode cannot read, an unreadable file) falls through to matugen's // own extraction: this must never fail a theme build. + if opts.StockColors == "" && opts.SeedColor != "" { + seed, err := NormalizeHexColor(opts.SeedColor) + if err != nil { + return false, err + } + log.Infof("Seed color override: %s -> %s", opts.Value, seed) + if opts.Kind == "image" { + sourceImage = opts.Value + if abs, err := filepath.Abs(sourceImage); err == nil { + sourceImage = abs + } + } + opts.Kind = "hex" + opts.Value = seed + opts.SourceMode = SourceModeDominant + } + if opts.StockColors == "" && opts.Kind == "image" && opts.SourceMode == SourceModeColorful { if seed, err := ExtractSourceColor(opts.Value); err != nil { log.Warnf("Colorful source extraction failed for %s, using matugen's own: %v", opts.Value, err) @@ -418,12 +455,33 @@ func buildOnce(opts *Options) (bool, error) { return false, fmt.Errorf("matugen dry-run failed: %w", err) } - primaryDark = extractMatugenColor(matJSON, "primary", "dark") - primaryLight = extractMatugenColor(matJSON, "primary", "light") - surfaceDark = extractMatugenColor(matJSON, "surface", "dark") - surfaceLight = extractMatugenColor(matJSON, "surface", "light") - containerDark = extractMatugenColor(matJSON, "primary_container", "dark") - containerLight = extractMatugenColor(matJSON, "primary_container", "light") + // The 2025 spec is generated here from the seed matugen settled on and + // overrides matugen's own roles through the import, so templates, + // {{image}} and the user's own config keep working unchanged. + var specColors string + if opts.Spec == Spec2025 && SpecSupportsScheme(opts.MatugenType) { + seed := opts.Value + if opts.Kind != "hex" { + seed = extractMatugenColor(matJSON, "source_color", "dark") + } + specColors, err = GenerateSpecColors(seed, opts.MatugenType, opts.Contrast, opts.Mode, Spec2025) + if err != nil { + return false, fmt.Errorf("spec 2025 palette failed: %w", err) + } + } + + extract := func(role, mode string) string { + if specColors != "" { + return extractNestedColor(specColors, role, mode) + } + return extractMatugenColor(matJSON, role, mode) + } + primaryDark = extract("primary", "dark") + primaryLight = extract("primary", "light") + surfaceDark = extract("surface", "dark") + surfaceLight = extract("surface", "light") + containerDark = extract("primary_container", "dark") + containerLight = extract("primary_container", "light") if primaryDark == "" { return false, fmt.Errorf("failed to extract primary color") @@ -433,7 +491,7 @@ func buildOnce(opts *Options) (bool, error) { } dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode) - importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)} + importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage, specColors)} log.Infof("Running matugen %s with dank16 injection", opts.Kind) var args []string @@ -512,12 +570,17 @@ func appendContrastArg(args []string, contrast float64) []string { // buildImportData is the JSON passed to matugen's --import-json-string. image is // set only when the source was rewritten from a wallpaper to a hex color, where // matugen leaves {{image}} unset and templates using it would render "Null". -func buildImportData(dank16JSON, image string) string { - if image == "" { - return fmt.Sprintf(`{"dank16": %s}`, dank16JSON) +// colors, when set, is a full role map that replaces matugen's own palette. +func buildImportData(dank16JSON, image, colors string) string { + fields := []string{fmt.Sprintf(`"dank16": %s`, dank16JSON)} + if image != "" { + path, _ := json.Marshal(image) + fields = append(fields, fmt.Sprintf(`"image": %s`, path)) } - path, _ := json.Marshal(image) - return fmt.Sprintf(`{"dank16": %s, "image": %s}`, dank16JSON, path) + if colors != "" { + fields = append(fields, fmt.Sprintf(`"colors": %s`, colors)) + } + return "{" + strings.Join(fields, ", ") + "}" } func userConfigSection(opts *Options) string { diff --git a/core/internal/matugen/matugen_test.go b/core/internal/matugen/matugen_test.go index 4acf43f51..52f7ee5d0 100644 --- a/core/internal/matugen/matugen_test.go +++ b/core/internal/matugen/matugen_test.go @@ -416,12 +416,12 @@ func TestBuildMatugenArgsDefaultPreservesExistingBehavior(t *testing.T) { func TestBuildImportData(t *testing.T) { const dank16 = `{"color0":"#000000"}` - assert.Equal(t, `{"dank16": {"color0":"#000000"}}`, buildImportData(dank16, ""), + assert.Equal(t, `{"dank16": {"color0":"#000000"}}`, buildImportData(dank16, "", ""), "no image must produce byte-identical import data to pre-feature behavior") assert.Equal(t, `{"dank16": {"color0":"#000000"}, "image": "/home/u/My Wallpaper.png"}`, - buildImportData(dank16, "/home/u/My Wallpaper.png")) + buildImportData(dank16, "/home/u/My Wallpaper.png", "")) assert.Equal(t, `{"dank16": {"color0":"#000000"}, "image": "/home/u/a\"b\\c.png"}`, - buildImportData(dank16, `/home/u/a"b\c.png`), "paths must be escaped, not interpolated raw") + buildImportData(dank16, `/home/u/a"b\c.png`, ""), "paths must be escaped, not interpolated raw") } // writeTestPNG encodes img as a PNG at path, failing the test on any error. diff --git a/core/internal/matugen/spec.go b/core/internal/matugen/spec.go new file mode 100644 index 000000000..ea7bf9034 --- /dev/null +++ b/core/internal/matugen/spec.go @@ -0,0 +1,122 @@ +package matugen + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/Nadim147c/material/v3/color" + "github.com/Nadim147c/material/v3/dynamic" +) + +// Palette spec versions. Spec2021 is matugen's own output and the historical +// default. Spec2025 is the Material 3 Expressive color spec, which matugen 4.x +// cannot produce: the palette is generated in-process and handed to matugen +// through --import-json-string, the same override path stock themes use. +const ( + Spec2021 = "2021" + Spec2025 = "2025" +) + +var hexColorPattern = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`) + +// NormalizeHexColor returns "#rrggbb" or an error. matugen and the shell both +// compare generated colors as strings, so casing must not vary between runs. +func NormalizeHexColor(value string) (string, error) { + value = strings.TrimSpace(value) + if !hexColorPattern.MatchString(value) { + return "", fmt.Errorf("invalid hex color %q", value) + } + return "#" + strings.ToLower(strings.TrimPrefix(value, "#")), nil +} + +var schemeVariants = map[string]dynamic.Variant{ + "scheme-tonal-spot": dynamic.VariantTonalSpot, + "scheme-vibrant": dynamic.VariantVibrant, + "scheme-content": dynamic.VariantContent, + "scheme-expressive": dynamic.VariantExpressive, + "scheme-fidelity": dynamic.VariantFidelity, + "scheme-fruit-salad": dynamic.VariantFruitSalad, + "scheme-monochrome": dynamic.VariantMonochrome, + "scheme-neutral": dynamic.VariantNeutral, + "scheme-rainbow": dynamic.VariantRainbow, +} + +// SpecSupportsScheme reports whether the in-process generator can build the +// scheme. scheme-smart is chosen by matugen at run time and is not exposed in +// its output, so it stays on matugen's native palette. +func SpecSupportsScheme(schemeType string) bool { + _, ok := schemeVariants[schemeType] + return ok +} + +// GenerateSpecColors builds the full Material color map for a seed under the +// given spec version and returns it in matugen's {"role": {"dark","light", +// "default": {"color"}}} shape. defaultMode picks which side "default" mirrors. +func GenerateSpecColors(seedHex, schemeType string, contrast float64, defaultMode ColorMode, version string) (string, error) { + variant, ok := schemeVariants[schemeType] + if !ok { + return "", fmt.Errorf("scheme %q is not supported by the in-process generator", schemeType) + } + seed, err := NormalizeHexColor(seedHex) + if err != nil { + return "", err + } + specVersion := dynamic.Version2021 + if version == Spec2025 { + specVersion = dynamic.Version2025 + // The 2025 spec has no reduced-contrast schemes. + contrast = max(contrast, 0) + } + + seedArgb, err := color.ARGBFromHex(seed) + if err != nil { + return "", err + } + // Colors.Map() in material/v3 v3.1.1 is backed by a field nothing fills, + // so the roles are read off the scheme directly. + generate := func(dark bool) map[string]string { + scheme := dynamic.NewDynamicScheme(seedArgb.ToHct(), variant, contrast, dark, dynamic.PlatformPhone, specVersion) + roles := scheme.ToColorMap() + out := make(map[string]string, len(roles)+1) + for name, role := range roles { + if role == nil { + continue + } + out[name] = strings.ToLower(role.GetArgb(scheme).HexRGB()) + } + out["source_color"] = seed + return out + } + + dark := generate(true) + light := generate(false) + + type shade struct { + Color string `json:"color"` + } + type role struct { + Dark shade `json:"dark"` + Light shade `json:"light"` + Default shade `json:"default"` + } + roles := make(map[string]role, len(dark)) + for name, darkHex := range dark { + lightHex, ok := light[name] + if !ok { + continue + } + def := darkHex + if defaultMode == ColorModeLight { + def = lightHex + } + roles[name] = role{Dark: shade{darkHex}, Light: shade{lightHex}, Default: shade{def}} + } + + data, err := json.Marshal(roles) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/core/internal/matugen/spec_test.go b/core/internal/matugen/spec_test.go new file mode 100644 index 000000000..e1f854fc6 --- /dev/null +++ b/core/internal/matugen/spec_test.go @@ -0,0 +1,190 @@ +package matugen + +import ( + "encoding/json" + "image" + "image/png" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + "github.com/stretchr/testify/require" +) + +func TestNormalizeHexColor(t *testing.T) { + for _, in := range []string{"#FF3D00", "ff3d00", " #ff3d00 "} { + got, err := NormalizeHexColor(in) + require.NoError(t, err, in) + require.Equal(t, "#ff3d00", got, in) + } + for _, in := range []string{"", "#fff", "#ff3d00aa", "red", "#gg3d00", "#ff3d00; rm -rf"} { + _, err := NormalizeHexColor(in) + require.Error(t, err, in) + } +} + +func TestGenerateSpecColorsRejectsSmart(t *testing.T) { + _, err := GenerateSpecColors("#1e88e5", "scheme-smart", 0, ColorModeDark, Spec2025) + require.Error(t, err) + require.False(t, SpecSupportsScheme("scheme-smart")) + require.True(t, SpecSupportsScheme("scheme-vibrant")) +} + +func TestGenerateSpecColors2025IsBolderThan2021(t *testing.T) { + colors2021 := decodeSpecColors(t, "#ff3d00", "scheme-vibrant", Spec2021) + colors2025 := decodeSpecColors(t, "#ff3d00", "scheme-vibrant", Spec2025) + require.Equal(t, "#ffb4a2", colors2021["primary"].Dark.Color) + require.Equal(t, "#ff7c5b", colors2025["primary"].Dark.Color) + require.Equal(t, colors2025["primary"].Dark.Color, colors2025["primary"].Default.Color) + require.Equal(t, "#ff3d00", colors2025["source_color"].Dark.Color) +} + +func TestGenerateSpecColorsDefaultFollowsMode(t *testing.T) { + colors := decodeSpecColorsMode(t, "#1e88e5", "scheme-tonal-spot", Spec2021, ColorModeLight) + require.Equal(t, colors["primary"].Light.Color, colors["primary"].Default.Color) + require.NotEqual(t, colors["primary"].Dark.Color, colors["primary"].Default.Color) +} + +func TestPreviewSchemesSmartMatchesNativeHex(t *testing.T) { + if !SupportsSmart() { + t.Skip("matugen 4.2+ required") + } + for _, seed := range []string{"#ff3d00", "#1e88e5"} { + out, err := runMatugenDryRun(&Options{Kind: "hex", Value: seed, Mode: ColorModeDark, MatugenType: "scheme-smart"}) + require.NoError(t, err) + expected := SchemePreview{Dark: schemeColors(out, "dark"), Light: schemeColors(out, "light")} + require.NotEmpty(t, expected.Dark.Primary) + for _, spec := range []string{Spec2021, Spec2025} { + t.Run(seed+"/"+spec, func(t *testing.T) { + previews, err := PreviewSchemes(seed, 0, "", spec) + require.NoError(t, err) + require.Equal(t, expected, previews["scheme-smart"]) + }) + } + } +} + +// The Go port and matugen's Rust port round differently in the last bit, and +// matugen still uses the pre-2023 on_*_container tones, so exact parity is not +// expected. The accent and surface roles the shell builds everything from must +// agree to within two channel steps or the in-process path is not the same +// palette users see today. +func TestGenerateSpecColors2021MatchesMatugen(t *testing.T) { + if _, err := exec.LookPath("matugen"); err != nil { + t.Skip("matugen not installed") + } + for _, seed := range []string{"#1e88e5", "#ff3d00", "#8bc34a"} { + out, err := exec.Command("matugen", "color", "hex", seed, "-t", "scheme-vibrant", "-m", "dark", "--json", "hex", "--dry-run", "-q").Output() + require.NoError(t, err) + var matugenOut struct { + Colors map[string]specRole `json:"colors"` + } + require.NoError(t, json.Unmarshal(out, &matugenOut)) + + ours := decodeSpecColors(t, seed, "scheme-vibrant", Spec2021) + for name := range matugenOut.Colors { + require.Contains(t, ours, name, "role %s missing from generator", name) + } + for _, name := range []string{"primary", "primary_container", "secondary", "tertiary", "surface", "surface_container", "on_surface"} { + theirs, mine := matugenOut.Colors[name], ours[name] + require.LessOrEqual(t, maxChannelDelta(t, theirs.Dark.Color, mine.Dark.Color), 2, "%s %s dark %s vs %s", seed, name, theirs.Dark.Color, mine.Dark.Color) + require.LessOrEqual(t, maxChannelDelta(t, theirs.Light.Color, mine.Light.Color), 2, "%s %s light %s vs %s", seed, name, theirs.Light.Color, mine.Light.Color) + } + } +} + +func maxChannelDelta(t *testing.T, a, b string) int { + t.Helper() + require.Len(t, a, 7) + require.Len(t, b, 7) + delta := 0 + for i := 1; i < 7; i += 2 { + x, err := strconv.ParseInt(a[i:i+2], 16, 0) + require.NoError(t, err) + y, err := strconv.ParseInt(b[i:i+2], 16, 0) + require.NoError(t, err) + if d := int(x - y); d > delta { + delta = d + } else if -d > delta { + delta = -d + } + } + return delta +} + +type specShade struct { + Color string `json:"color"` +} + +type specRole struct { + Dark specShade `json:"dark"` + Light specShade `json:"light"` + Default specShade `json:"default"` +} + +func decodeSpecColors(t *testing.T, seed, scheme, version string) map[string]specRole { + return decodeSpecColorsMode(t, seed, scheme, version, ColorModeDark) +} + +func decodeSpecColorsMode(t *testing.T, seed, scheme, version string, mode ColorMode) map[string]specRole { + t.Helper() + raw, err := GenerateSpecColors(seed, scheme, 0, mode, version) + require.NoError(t, err) + var roles map[string]specRole + require.NoError(t, json.Unmarshal([]byte(raw), &roles)) + return roles +} + +// Runs the real matugen against the repo's dank.json template, colors only, +// into a temp state dir: the shell must read back the seed and the 2025 primary +// from dms-colors.json. +func TestBuildOnceSeedColorAndSpec2025(t *testing.T) { + if _, err := exec.LookPath("matugen"); err != nil { + t.Skip("matugen not installed") + } + shellDir, err := filepath.Abs(filepath.Join("..", "..", "..", "quickshell")) + require.NoError(t, err) + if _, err := os.Stat(filepath.Join(shellDir, "matugen", "templates", "dank.json")); err != nil { + t.Skip("quickshell dir not available") + } + wallpaper := filepath.Join(t.TempDir(), "wall.png") + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for i := range img.Pix { + img.Pix[i] = 0x40 + } + f, err := os.Create(wallpaper) + require.NoError(t, err) + require.NoError(t, png.Encode(f, img)) + require.NoError(t, f.Close()) + + opts := &Options{ + StateDir: t.TempDir(), + ShellDir: shellDir, + ConfigDir: t.TempDir(), + Kind: "image", + Value: wallpaper, + Mode: ColorModeDark, + MatugenType: "scheme-vibrant", + SeedColor: "#FF3D00", + Spec: Spec2025, + ColorsOnly: true, + AppChecker: utils.DefaultAppChecker{}, + } + changed, err := buildOnce(opts) + require.NoError(t, err) + require.True(t, changed) + + raw, err := os.ReadFile(opts.ColorsOutput()) + require.NoError(t, err) + var out struct { + Colors struct { + Dark map[string]string `json:"dark"` + } `json:"colors"` + } + require.NoError(t, json.Unmarshal(raw, &out)) + require.Equal(t, "#ff3d00", out.Colors.Dark["source_color"]) + require.Equal(t, "#ff7c5b", out.Colors.Dark["primary"]) +} diff --git a/core/internal/server/matugen_handler.go b/core/internal/server/matugen_handler.go index a11de6327..04d4740cd 100644 --- a/core/internal/server/matugen_handler.go +++ b/core/internal/server/matugen_handler.go @@ -31,6 +31,8 @@ func handleMatugenQueue(conn *ipc.ConnWriter, req ipc.Request) { SkipTemplates: models.GetOr(req, "skipTemplates", ""), Contrast: models.GetOr(req, "contrast", 0.0), SourceMode: models.GetOr(req, "sourceMode", ""), + SeedColor: models.GetOr(req, "seedColor", ""), + Spec: models.GetOr(req, "spec", ""), } wait := models.GetOr(req, "wait", true) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index f7b856aac..5c760f11b 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -168,6 +168,8 @@ Singleton { property bool matugenSmartMode: Spec.SPEC.matugenSmartMode.def property string matugenSourceMode: Spec.SPEC.matugenSourceMode.def property real matugenContrast: Spec.SPEC.matugenContrast.def + property string matugenSeedColor: Spec.SPEC.matugenSeedColor.def + property string matugenSpec: Spec.SPEC.matugenSpec.def property bool runUserMatugenTemplates: Spec.SPEC.runUserMatugenTemplates.def property string matugenTargetMonitor: Spec.SPEC.matugenTargetMonitor.def property real popupTransparency: Spec.SPEC.popupTransparency.def @@ -2506,6 +2508,22 @@ Singleton { set("matugenContrast", value); } + function setMatugenSeedColor(hex) { + var normalized = /^#[0-9a-f]{6}$/i.test(hex || "") ? hex.toLowerCase() : ""; + if (matugenSeedColor === normalized) + return; + set("matugenSeedColor", normalized); + } + + function setMatugenSpec(spec) { + var normalized = spec === "2025" ? "2025" : "2021"; + if (matugenSpec === normalized) + return; + if (normalized === "2025" && matugenContrast < 0) + set("matugenContrast", 0); + set("matugenSpec", normalized); + } + function setMatugenTargetMonitor(monitorName) { if (matugenTargetMonitor === monitorName) return; diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index b252b3b80..f33a85e19 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -1777,9 +1777,15 @@ Singleton { // binary is a supported setup (DMS_SHELL_DIR / -c), and an older binary // exits with "unknown flag: --source-mode" rather than ignoring it, so // the default must not put the flag on the command line at all. - if (typeof SettingsData !== "undefined" && SettingsData.matugenSourceMode && SettingsData.matugenSourceMode !== "dominant") { + const seedColor = (typeof SettingsData !== "undefined" && !stockColors) ? SettingsData.matugenSeedColor : ""; + if (seedColor) { + args.push("--seed-color", seedColor); + } else if (typeof SettingsData !== "undefined" && SettingsData.matugenSourceMode && SettingsData.matugenSourceMode !== "dominant") { args.push("--source-mode", SettingsData.matugenSourceMode); } + if (typeof SettingsData !== "undefined" && !stockColors && SettingsData.matugenSpec === "2025") { + args.push("--spec", "2025"); + } if (typeof SettingsData !== "undefined") { const skipTemplates = []; diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index f2326202a..b587adb46 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -25,6 +25,14 @@ var LOCAL_SPEC = { def: 0, onChange: "regenSystemThemes" }, + matugenSeedColor: { + def: "", + onChange: "regenSystemThemes" + }, + matugenSpec: { + def: "2021", + onChange: "regenSystemThemes" + }, runUserMatugenTemplates: { def: true, onChange: "regenSystemThemes" diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index f1ad077d9..1146bbb4e 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -18,9 +18,9 @@ Item { property var matugenSchemePreviews: ({}) property string matugenPreviewRequestKey: "" property string matugenPreviewLoadedKey: "" - readonly property string matugenPreviewSource: Theme.getMatugenColor("source_color", Theme.primary).toString() - readonly property string matugenPreviewImage: (Theme.rawWallpaperPath && !Theme.rawWallpaperPath.startsWith("#")) ? Theme.rawWallpaperPath : "" - readonly property string matugenPreviewKey: matugenPreviewSource + "|" + (SettingsData.matugenContrast ?? 0) + "|" + matugenPreviewImage + 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 property bool matugenGenerating: false readonly property var currentPalette: ThemePalette.pick({ "primary": Theme.primary, @@ -67,6 +67,8 @@ Item { 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; @@ -431,21 +433,29 @@ Item { enabled: Theme.matugenAvailable body: Item { width: parent.width - height: themeColorsTab.matugenPreviewsReady ? schemeGrid.implicitHeight : Theme.minimumTouchTargetSize + // the grid keeps its height while previews regenerate so the page does not jump + height: schemeGrid.implicitHeight + + SettingsSwatchGrid { + id: schemeGrid + options: themeColorsTab.matugenSchemeOptions + currentValue: SettingsData.matugenScheme + enabled: themeColorsTab.matugenPreviewsReady + opacity: themeColorsTab.matugenPreviewsReady ? 1 : Theme.pendingOpacity + onSelected: value => SettingsData.setMatugenScheme(value) + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + } + } + } DankSpinner { anchors.centerIn: parent running: !themeColorsTab.matugenPreviewsReady visible: running } - - SettingsSwatchGrid { - id: schemeGrid - visible: themeColorsTab.matugenPreviewsReady - options: themeColorsTab.matugenSchemeOptions - currentValue: SettingsData.matugenScheme - onSelected: value => SettingsData.setMatugenScheme(value) - } } } @@ -470,7 +480,7 @@ Item { text: I18n.tr("Source color") options: cachedSourceModes currentValue: Theme.getSourceMode(SettingsData.matugenSourceMode).label - enabled: Theme.matugenAvailable + enabled: Theme.matugenAvailable && !SettingsData.matugenSeedColor onValueChanged: value => { for (var i = 0; i < Theme.availableSourceModes.length; i++) { var option = Theme.availableSourceModes[i]; @@ -482,7 +492,57 @@ Item { } } + ColorDropdownRow { + visible: Theme.currentTheme === Theme.dynamic && Theme.currentThemeCategory !== "registry" + tab: "theme" + tags: ["matugen", "seed", "pick", "eyedropper", "dynamic"] + settingKey: "matugenSeedColor" + text: I18n.tr("Derived color") + enabled: Theme.matugenAvailable + options: [ + { + "value": "default", + "previewColor": Theme.getMatugenColor("source_color", Theme.primary), + "label": I18n.tr("From wallpaper", "matugen seed color option") + }, + { + "value": "custom", + "label": I18n.tr("Custom") + } + ] + currentMode: SettingsData.matugenSeedColor ? "custom" : "default" + customColor: SettingsData.matugenSeedColor || Theme.getMatugenColor("source_color", Theme.primary) + pickerTitle: I18n.tr("Seed color") + onModeSelected: mode => { + if (mode !== "custom") { + SettingsData.setMatugenSeedColor(""); + return; + } + if (SettingsData.matugenSeedColor) + return; + SettingsData.setMatugenSeedColor(Theme.getMatugenColor("source_color", Theme.primary).toString()); + } + onCustomColorSelected: selectedColor => SettingsData.setMatugenSeedColor(Theme.withAlpha(selectedColor, 1).toString()) + } + + SettingsButtonGroupRow { + visible: Theme.currentTheme === Theme.dynamic && Theme.currentThemeCategory !== "registry" + tab: "theme" + tags: ["matugen", "spec", "expressive", "vivid", "saturated", "bold", "dynamic"] + settingKey: "matugenSpec" + text: I18n.tr("Material palette") + enabled: Theme.matugenAvailable + model: [I18n.tr("Standard", "adjective, panel motion option and bar layout mode option"), I18n.tr("Expressive", "matugen color scheme option")] + currentIndex: SettingsData.matugenSpec === "2025" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.setMatugenSpec(index === 1 ? "2025" : "2021"); + } + } + SettingsSliderRow { + id: contrastRow visible: Theme.currentTheme === Theme.dynamic && Theme.currentThemeCategory !== "registry" tab: "theme" tags: ["matugen", "contrast", "dynamic"] @@ -492,7 +552,12 @@ Item { minimum: -100 maximum: 100 enabled: Theme.matugenAvailable - onSliderDragFinished: finalValue => SettingsData.setMatugenContrast(finalValue / 100) + onSliderDragFinished: finalValue => { + const clamped = SettingsData.matugenSpec === "2025" ? Math.max(0, finalValue) : finalValue; + SettingsData.setMatugenContrast(clamped / 100); + if (clamped !== finalValue) + contrastRow.resync(); + } } Column { diff --git a/quickshell/Modules/Settings/Widgets/SettingsSliderRow.qml b/quickshell/Modules/Settings/Widgets/SettingsSliderRow.qml index 10d19e48e..de6ac42a3 100644 --- a/quickshell/Modules/Settings/Widgets/SettingsSliderRow.qml +++ b/quickshell/Modules/Settings/Widgets/SettingsSliderRow.qml @@ -33,9 +33,13 @@ SettingsRow { sliderDragFinished(next); } + function resync() { + slider.value = Math.round(value); + } + title: text subtitle: description - onValueChanged: slider.value = Math.round(value) + onValueChanged: resync() StyledText { text: root.minimumLabel diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 17bd4e0bb..3884bfbc0 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -5051,6 +5051,73 @@ ], "parentLabel": "Wallpaper & colors" }, + { + "section": "matugenSeedColor", + "label": "Derived color", + "tabIndex": 10, + "category": "Theme & colors", + "keywords": [ + "appearance", + "background", + "bg", + "color", + "colors", + "colour", + "colours", + "derived", + "desktop", + "dynamic", + "eyedropper", + "hue", + "image", + "look", + "matugen", + "palette", + "pick", + "picture", + "scheme", + "seed", + "style", + "theme", + "tint", + "wallpaper" + ], + "parentLabel": "Wallpaper & colors" + }, + { + "section": "matugenSpec", + "label": "Material palette", + "tabIndex": 10, + "category": "Theme & colors", + "keywords": [ + "appearance", + "background", + "bg", + "bold", + "colors", + "colour", + "colours", + "desktop", + "dynamic", + "expressive", + "hue", + "image", + "look", + "material", + "matugen", + "palette", + "picture", + "saturated", + "scheme", + "spec", + "style", + "theme", + "tint", + "vivid", + "wallpaper" + ], + "parentLabel": "Wallpaper & colors" + }, { "section": "matugenScheme", "label": "Matugen palette",