diff --git a/core/cmd/dms/commands_matugen.go b/core/cmd/dms/commands_matugen.go index 8aeaef520..a693b56a6 100644 --- a/core/cmd/dms/commands_matugen.go +++ b/core/cmd/dms/commands_matugen.go @@ -72,6 +72,7 @@ func init() { cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant") 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") } matugenQueueCmd.Flags().Bool("wait", true, "Wait for completion") @@ -96,6 +97,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options { terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark") skipTemplates, _ := cmd.Flags().GetString("skip-templates") contrast, _ := cmd.Flags().GetFloat64("contrast") + sourceMode, _ := cmd.Flags().GetString("source-mode") return matugen.Options{ StateDir: stateDir, @@ -112,6 +114,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options { SyncModeWithPortal: syncModeWithPortal, TerminalsAlwaysDark: terminalsAlwaysDark, SkipTemplates: skipTemplates, + SourceMode: sourceMode, } } @@ -149,6 +152,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) { "terminalsAlwaysDark": opts.TerminalsAlwaysDark, "skipTemplates": opts.SkipTemplates, "contrast": opts.Contrast, + "sourceMode": opts.SourceMode, "wait": wait, }, } diff --git a/core/go.mod b/core/go.mod index 613faf45b..a1369d407 100644 --- a/core/go.mod +++ b/core/go.mod @@ -4,6 +4,7 @@ go 1.26.4 require ( github.com/AvengeMedia/dgop v0.2.4-0.20260808141551-f559f2f31d2a + github.com/Nadim147c/material/v3 v3.1.1 github.com/Wifx/gonetworkmanager/v2 v2.2.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/charmbracelet/bubbles v1.0.0 diff --git a/core/go.sum b/core/go.sum index 28ce5269c..2f5a178fb 100644 --- a/core/go.sum +++ b/core/go.sum @@ -6,6 +6,8 @@ github.com/AvengeMedia/dgop v0.2.4-0.20260808141551-f559f2f31d2a h1:bGwK4G22ixHU github.com/AvengeMedia/dgop v0.2.4-0.20260808141551-f559f2f31d2a/go.mod h1:2kt1u9YgHwi2npqB5ago8kwZdFrHlPGXv3PM/vwRHfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nadim147c/material/v3 v3.1.1 h1:3ZuTPWBJWUpzzWW1rtqe28RMZXhA4f0/mg/Jk3JG94Y= +github.com/Nadim147c/material/v3 v3.1.1/go.mod h1:ThN+dRdC+CDN2qXSGMn32KqtcXwYtb/trDM82cdZI6g= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index 637a34540..624fd42bd 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -90,10 +90,11 @@ func (c *ColorMode) GTKTheme() string { } var ( - matugenVersionMu sync.Mutex - matugenVersionOK bool - matugenSupportsCOE bool - matugenIsV4 bool + matugenVersionMu sync.Mutex + matugenVersionOK bool + matugenSupportsCOE bool + matugenIsV4 bool + matugenSupportsPrefer bool ) type Options struct { @@ -106,6 +107,7 @@ type Options struct { IconTheme string MatugenType string Contrast float64 + SourceMode string RunUserTemplates bool ColorsOnly bool StockColors string @@ -303,6 +305,28 @@ func buildOnce(opts *Options) (bool, error) { var primaryDark, primaryLight, surface string var dank16JSON string var importArgs []string + var sourceImage string + + // Colorful mode resolves the seed here, before matugen is invoked at all, + // by rewriting the source to the extracted hex. Both the dry-run and the + // real run below read opts.Kind/opts.Value, so one rewrite covers both and + // 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.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) + } else { + log.Infof("Colorful source color: %s -> %s", opts.Value, seed) + // matugen resolves {{image}} to an absolute path, so match it. + sourceImage = opts.Value + if abs, err := filepath.Abs(sourceImage); err == nil { + sourceImage = abs + } + opts.Kind = "hex" + opts.Value = seed + } + } if opts.StockColors != "" { log.Info("Using stock/custom theme colors with matugen base") @@ -325,7 +349,7 @@ func buildOnce(opts *Options) (bool, error) { args := []string{"color", "hex", primaryDark, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()} args = appendContrastArg(args, opts.Contrast) args = append(args, importArgs...) - if err := runMatugen(args); err != nil { + if err := runMatugen(args, opts.SourceMode); err != nil { return false, err } } else { @@ -348,8 +372,7 @@ func buildOnce(opts *Options) (bool, error) { } dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode) - importData := fmt.Sprintf(`{"dank16": %s}`, dank16JSON) - importArgs = []string{"--import-json-string", importData} + importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage)} log.Infof("Running matugen %s with dank16 injection", opts.Kind) var args []string @@ -362,7 +385,7 @@ func buildOnce(opts *Options) (bool, error) { args = append(args, "-m", string(opts.Mode), "-t", opts.MatugenType, "-c", cfgFile.Name()) args = appendContrastArg(args, opts.Contrast) args = append(args, importArgs...) - if err := runMatugen(args); err != nil { + if err := runMatugen(args, opts.SourceMode); err != nil { return false, err } } @@ -425,6 +448,17 @@ func appendContrastArg(args []string, contrast float64) []string { return append(args, "--contrast", strconv.FormatFloat(contrast, 'f', -1, 64)) } +// 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) + } + path, _ := json.Marshal(image) + return fmt.Sprintf(`{"dank16": %s, "image": %s}`, dank16JSON, path) +} + func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error { userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml") @@ -755,8 +789,9 @@ func extractTOMLSection(content, startMarker, endMarker string) string { } type matugenFlags struct { - supportsCOE bool - isV4 bool + supportsCOE bool + isV4 bool + supportsPrefer bool } func detectMatugenVersion() (matugenFlags, error) { @@ -764,7 +799,7 @@ func detectMatugenVersion() (matugenFlags, error) { defer matugenVersionMu.Unlock() if matugenVersionOK { - return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil + return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenSupportsPrefer}, nil } return detectMatugenVersionLocked() @@ -779,7 +814,8 @@ 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.supportsPrefer != old.supportsPrefer return flags, changed } @@ -811,6 +847,9 @@ func detectMatugenVersionLocked() (matugenFlags, error) { matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1) matugenIsV4 = major >= 4 + // --prefer landed in 4.1; 4.0.x has --source-color-index but not --prefer, + // and clap aborts on an unknown argument rather than ignoring it. + matugenSupportsPrefer = major > 4 || (major == 4 && minor >= 1) matugenVersionOK = true if matugenSupportsCOE { @@ -819,28 +858,34 @@ func detectMatugenVersionLocked() (matugenFlags, error) { if matugenIsV4 { log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr) } - return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil + if matugenIsV4 && !matugenSupportsPrefer { + log.Debugf("Matugen %s detected: --prefer unavailable, source modes fall back to the dominant color", versionStr) + } + return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenSupportsPrefer}, nil } -func buildMatugenArgs(baseArgs []string, flags matugenFlags) []string { +func buildMatugenArgs(baseArgs []string, flags matugenFlags, sourceMode string) []string { args := make([]string, 0, len(baseArgs)+4) if flags.supportsCOE { args = append(args, "--continue-on-error") } args = append(args, baseArgs...) + // matugen 3 has neither flag. matugen 4's --source-color-index help notes + // "In earlier versions the default was 0", so omitting both on v3 gives the + // same seed the flag would have asked for. if flags.isV4 { - args = append(args, "--source-color-index", "0") + args = append(args, sourceSelectionArgs(sourceMode, flags.supportsPrefer)...) } return args } -func runMatugen(baseArgs []string) error { +func runMatugen(baseArgs []string, sourceMode string) error { flags, err := detectMatugenVersion() if err != nil { return err } - args := buildMatugenArgs(baseArgs, flags) + args := buildMatugenArgs(baseArgs, flags, sourceMode) cmd := exec.Command("matugen", args...) cmd.Env = utils.EnvWithUserBinPath(nil) cmd.Stdout = os.Stdout @@ -858,7 +903,7 @@ func runMatugen(baseArgs []string) error { } log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4) - args = buildMatugenArgs(baseArgs, newFlags) + args = buildMatugenArgs(baseArgs, newFlags, sourceMode) retryCmd := exec.Command("matugen", args...) retryCmd.Env = utils.EnvWithUserBinPath(nil) retryCmd.Stdout = os.Stdout @@ -899,7 +944,8 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) { baseArgs = append(baseArgs, "-m", "dark", "-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") + baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...) + baseArgs = append(baseArgs, "--old-json-output") } cmd := exec.Command("matugen", baseArgs...) diff --git a/core/internal/matugen/matugen_test.go b/core/internal/matugen/matugen_test.go index 7e4dc24cc..6b28a3e09 100644 --- a/core/internal/matugen/matugen_test.go +++ b/core/internal/matugen/matugen_test.go @@ -2,6 +2,11 @@ package matugen import ( "encoding/json" + "fmt" + "image" + stdcolor "image/color" + "image/png" + "math" "os" "path/filepath" "strings" @@ -10,6 +15,7 @@ import ( mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" + matcolor "github.com/Nadim147c/material/v3/color" "github.com/stretchr/testify/assert" ) @@ -327,6 +333,264 @@ func TestAppendConfigFileDoesNotExist(t *testing.T) { } } +func TestSourceSelectionArgs(t *testing.T) { + tests := []struct { + name string + sourceMode string + supportsPrefer bool + expected []string + }{ + {"darkness", "darkness", true, []string{"--prefer", "darkness"}}, + {"lightness", "lightness", true, []string{"--prefer", "lightness"}}, + {"saturation", "saturation", true, []string{"--prefer", "saturation"}}, + {"less-saturation", "less-saturation", true, []string{"--prefer", "less-saturation"}}, + {"value", "value", true, []string{"--prefer", "value"}}, + {"empty falls back to dominant", "", true, []string{"--source-color-index", "0"}}, + {"dominant falls back", "dominant", true, []string{"--source-color-index", "0"}}, + {"colorful falls back (resolved to hex before this is reached)", "colorful", true, []string{"--source-color-index", "0"}}, + {"typo falls back", "bogus", true, []string{"--source-color-index", "0"}}, + {"wrong case is not allowlisted", "DARKNESS", true, []string{"--source-color-index", "0"}}, + {"closest-to-fallback is deliberately excluded", "closest-to-fallback", true, []string{"--source-color-index", "0"}}, + {"injection-shaped value cannot escape the allowlist", "value; --exec touch /tmp/pwned", true, []string{"--source-color-index", "0"}}, + // matugen 4.0.x has --source-color-index but not --prefer, and aborts on + // an unknown argument. Every prefer mode must degrade, not fail. + {"4.0.x: darkness degrades", "darkness", false, []string{"--source-color-index", "0"}}, + {"4.0.x: lightness degrades", "lightness", false, []string{"--source-color-index", "0"}}, + {"4.0.x: saturation degrades", "saturation", false, []string{"--source-color-index", "0"}}, + {"4.0.x: less-saturation degrades", "less-saturation", false, []string{"--source-color-index", "0"}}, + {"4.0.x: value degrades", "value", false, []string{"--source-color-index", "0"}}, + {"4.0.x: colorful is unaffected", "colorful", false, []string{"--source-color-index", "0"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := sourceSelectionArgs(tc.sourceMode, tc.supportsPrefer) + assert.Equal(t, tc.expected, result) + if !tc.supportsPrefer { + assert.NotContains(t, result, "--prefer", "matugen 4.0.x must never see --prefer") + } + }) + } +} + +func TestBuildMatugenArgsSourceModes(t *testing.T) { + baseArgs := []string{"-c", "/tmp/merged.toml", "-t", "scheme-tonal-spot"} + modes := []string{"dominant", "colorful", "darkness", "lightness", "saturation", "less-saturation", "value"} + + for _, mode := range modes { + t.Run("v4/"+mode, func(t *testing.T) { + args := buildMatugenArgs(baseArgs, matugenFlags{isV4: true, supportsPrefer: true}, mode) + expected := append(append([]string{}, baseArgs...), sourceSelectionArgs(mode, true)...) + assert.Equal(t, expected, args) + }) + + t.Run("v4.0/"+mode, func(t *testing.T) { + // matugen 4.0.x: --source-color-index exists, --prefer does not. + args := buildMatugenArgs(baseArgs, matugenFlags{isV4: true, supportsPrefer: false}, mode) + expected := append(append([]string{}, baseArgs...), "--source-color-index", "0") + assert.Equal(t, expected, args) + assert.NotContains(t, args, "--prefer") + }) + + t.Run("v3/"+mode, func(t *testing.T) { + // matugen 3 has neither --prefer nor --source-color-index. Adding + // either would break every user still on it, so under isV4=false + // no source-selection args may appear for any mode. + args := buildMatugenArgs(baseArgs, matugenFlags{isV4: false}, mode) + assert.Equal(t, baseArgs, args) + assert.NotContains(t, args, "--prefer") + assert.NotContains(t, args, "--source-color-index") + }) + } +} + +func TestBuildMatugenArgsDefaultPreservesExistingBehavior(t *testing.T) { + baseArgs := []string{"-c", "/tmp/merged.toml", "-t", "scheme-tonal-spot"} + + args := buildMatugenArgs(baseArgs, matugenFlags{isV4: true, supportsPrefer: true}, "") + + expected := append(append([]string{}, baseArgs...), "--source-color-index", "0") + assert.Equal(t, expected, args, "empty source mode must produce byte-identical args to pre-feature behavior") +} + +func TestBuildImportData(t *testing.T) { + const dank16 = `{"color0":"#000000"}` + + 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")) + 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") +} + +// writeTestPNG encodes img as a PNG at path, failing the test on any error. +func writeTestPNG(t *testing.T, path string, img image.Image) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("failed to create test image: %v", err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatalf("failed to encode test image: %v", err) + } +} + +func TestExtractSourceColorPrefersVividOverDull(t *testing.T) { + dir := t.TempDir() + + const w, h = 50, 50 + img := image.NewRGBA(image.Rect(0, 0, w, h)) + dull := stdcolor.RGBA{R: 190, G: 200, B: 210, A: 255} // pale grey-blue, low chroma + vivid := stdcolor.RGBA{R: 255, G: 140, B: 0, A: 255} // strong warm orange + + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, dull) + } + } + // ~19% of the image, in a corner, well clear of 80/20. + const vividSize = 22 + for y := 0; y < vividSize; y++ { + for x := 0; x < vividSize; x++ { + img.Set(x, y, vivid) + } + } + + path := filepath.Join(dir, "mixed.png") + writeTestPNG(t, path, img) + + seed, err := ExtractSourceColor(path) + if err != nil { + t.Fatalf("ExtractSourceColor returned an error: %v", err) + } + + var r, g, b uint8 + if _, err := fmt.Sscanf(seed, "#%02x%02x%02x", &r, &g, &b); err != nil { + t.Fatalf("seed %q is not a #RRGGBB hex string: %v", seed, err) + } + hct := matcolor.NewARGB(255, r, g, b).ToHct() + + dullHue := matcolor.NewARGB(255, dull.R, dull.G, dull.B).ToHct().Hue + + // The failure this feature targets is landing on the large dull region. + // Assert the hue family, not an exact value: it must be nowhere near the + // dull blue-grey (~240) and it must sit in the warm orange/yellow band + // the vivid region occupies (~58, with generous tolerance either side). + assert.InDelta(t, 58.0, hct.Hue, 40.0, "expected the seed's hue to be in the vivid orange family, got hue=%.2f from %s", hct.Hue, seed) + assert.Greater(t, hueDistance(hct.Hue, dullHue), 60.0, "seed hue %.2f should not be in the dull region's hue family (%.2f)", hct.Hue, dullHue) +} + +// hueDistance is the shortest angular distance between two hues on a 360 +// degree wheel. +func hueDistance(a, b float64) float64 { + d := math.Abs(a - b) + if d > 180 { + d = 360 - d + } + return d +} + +func TestExtractSourceColorDeterministic(t *testing.T) { + dir := t.TempDir() + + const w, h = 50, 50 + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if x < 22 && y < 22 { + img.Set(x, y, stdcolor.RGBA{R: 255, G: 140, B: 0, A: 255}) + } else { + img.Set(x, y, stdcolor.RGBA{R: 190, G: 200, B: 210, A: 255}) + } + } + } + path := filepath.Join(dir, "deterministic.png") + writeTestPNG(t, path, img) + + first, err := ExtractSourceColor(path) + if err != nil { + t.Fatalf("ExtractSourceColor returned an error: %v", err) + } + + for i := 0; i < 3; i++ { + next, err := ExtractSourceColor(path) + if err != nil { + t.Fatalf("ExtractSourceColor returned an error on repeat run %d: %v", i, err) + } + assert.Equal(t, first, next, "the same wallpaper must yield a byte-identical seed every run") + } +} + +func TestExtractSourceColorNonexistentPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist.png") + + seed, err := ExtractSourceColor(path) + + assert.Error(t, err) + assert.Empty(t, seed) + assert.Contains(t, err.Error(), "open") +} + +func TestExtractSourceColorNonImageFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "not-an-image.jpg") + if err := os.WriteFile(path, []byte("this is plain text, not an image"), 0o644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + seed, err := ExtractSourceColor(path) + + assert.Error(t, err) + assert.Empty(t, seed) + assert.Contains(t, err.Error(), "decode") +} + +func TestExtractSourceColorFullyTransparentImage(t *testing.T) { + dir := t.TempDir() + // image.NewRGBA zero-values to fully transparent black. + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + path := filepath.Join(dir, "transparent.png") + writeTestPNG(t, path, img) + + seed, err := ExtractSourceColor(path) + + assert.Error(t, err) + assert.Empty(t, seed) + assert.Contains(t, err.Error(), "no opaque pixels") +} + +func TestExtractSourceColorAllBlackImage(t *testing.T) { + dir := t.TempDir() + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := 0; y < 8; y++ { + for x := 0; x < 8; x++ { + img.Set(x, y, stdcolor.RGBA{R: 0, G: 0, B: 0, A: 255}) + } + } + path := filepath.Join(dir, "black.png") + writeTestPNG(t, path, img) + + seed, err := ExtractSourceColor(path) + + assert.Error(t, err) + assert.Empty(t, seed) + assert.Contains(t, err.Error(), "no usable color") +} + +func TestExtractSourceColorOnePixelDoesNotPanic(t *testing.T) { + dir := t.TempDir() + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, stdcolor.RGBA{R: 255, G: 0, B: 0, A: 255}) + path := filepath.Join(dir, "one-pixel.png") + writeTestPNG(t, path, img) + + assert.NotPanics(t, func() { + _, _ = ExtractSourceColor(path) + }) +} + func TestSubstituteVars(t *testing.T) { configDir := utils.XDGConfigHome() dataDir := utils.XDGDataHome() diff --git a/core/internal/matugen/sourcecolor.go b/core/internal/matugen/sourcecolor.go new file mode 100644 index 000000000..a56892a55 --- /dev/null +++ b/core/internal/matugen/sourcecolor.go @@ -0,0 +1,303 @@ +package matugen + +import ( + "cmp" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "math" + "os" + "slices" + + "github.com/Nadim147c/material/v3/color" + "github.com/Nadim147c/material/v3/dislike" + "github.com/Nadim147c/material/v3/num" + "github.com/Nadim147c/material/v3/quantizer" + _ "golang.org/x/image/bmp" + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +// Source color modes. Anything else, including the empty string, means +// "dominant": let matugen pick, which is what DMS has always done. +const ( + SourceModeDominant = "dominant" + SourceModeColorful = "colorful" +) + +// matugenPreferValues are the --prefer values DMS forwards to matugen. Kept as +// an allowlist so a stale or hand-edited setting can't inject arbitrary args. +// matugen's closest-to-fallback is left out: DMS never sets --fallback-color, +// so there is nothing meaningful for it to be close to. +var matugenPreferValues = map[string]bool{ + "darkness": true, + "lightness": true, + "saturation": true, + "less-saturation": true, + "value": true, +} + +// sourceSelectionArgs are the matugen v4 flags that decide which extracted +// color seeds the palette. Every build passes through here, including hex +// sources and stock color themes: --prefer on a hex source is a no-op that +// produces byte-identical output to --source-color-index 0, so there is +// nothing to gate on. SourceModeColorful is resolved to a hex before matugen +// runs and so lands on the --source-color-index 0 fallback like any other +// non-prefer mode. +// +// supportsPrefer is false on matugen 4.0.x, which has --source-color-index but +// not --prefer. matugen aborts on an unknown argument, so a --prefer mode there +// has to degrade to the dominant color rather than fail the theme build. +func sourceSelectionArgs(sourceMode string, supportsPrefer bool) []string { + if supportsPrefer && matugenPreferValues[sourceMode] { + return []string{"--prefer", sourceMode} + } + return []string{"--source-color-index", "0"} +} + +const ( + // Longest edge the quantizer sees. Full-resolution wallpapers cost about a + // second and the extra pixels move the result by less than the sampling + // noise already present. + sourceSampleMaxDim = 512 + // Palette size, matching Caelestia's ImageQuantizeCelebi(image, 1, 128). + sourceMaxColors = 128 + sourceMaxIters = 10 + + // Scoring weights, ported from caelestia-cli's Score class. + scoreTargetChroma = 48.0 + scoreWeightProportion = 0.7 + scoreWeightChromaAbove = 0.3 + scoreWeightChromaBelow = 0.1 +) + +// ExtractSourceColor picks a wallpaper's seed color the way caelestia-cli does: +// quantize the image, then prefer the most colorful prominent color rather than +// the most common one. matugen's own extraction tends to land on a large, dull +// region (a sky, a wall), which is why a warm image can still produce a cold +// palette. Returns a "#RRGGBB" hex to hand back to matugen as a color source. +// +// Formats are whatever image.Decode handles: jpeg, png, gif, bmp, tiff and +// webp. DMS also accepts jxl, avif, heif and exr wallpapers, which fail here +// with a decode error; callers are expected to fall back to matugen's own +// extraction rather than fail the theme build. +// +// The same file always yields the same string. DMS compares generated colors +// byte-for-byte to detect "no changes", so a seed that varied between runs +// would retheme the desktop on every wallpaper event. +func ExtractSourceColor(imagePath string) (string, error) { + pixels, err := samplePixels(imagePath) + if err != nil { + return "", err + } + if len(pixels) == 0 { + return "", fmt.Errorf("no opaque pixels in %s", imagePath) + } + + population := quantize(pixels, sourceMaxColors) + seed, ok := scoreColors(population) + if !ok { + return "", fmt.Errorf("no usable color in %s", imagePath) + } + return seed.ToARGB().HexRGB(), nil +} + +// samplePixels decodes the image and downscales it so the quantizer works on a +// bounded number of pixels regardless of wallpaper resolution. +func samplePixels(path string) ([]color.ARGB, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open wallpaper: %w", err) + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode %s: %w", path, err) + } + + bounds := img.Bounds() + if w, h := bounds.Dx(), bounds.Dy(); w > sourceSampleMaxDim || h > sourceSampleMaxDim { + scale := float64(sourceSampleMaxDim) / math.Max(float64(w), float64(h)) + scaled := image.NewRGBA(image.Rect(0, 0, max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1))) + xdraw.ApproxBiLinear.Scale(scaled, scaled.Bounds(), img, bounds, xdraw.Src, nil) + img, bounds = scaled, scaled.Bounds() + } + + pixels := make([]color.ARGB, 0, bounds.Dx()*bounds.Dy()) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := img.At(x, y).RGBA() + if a < 0xffff { + continue + } + pixels = append(pixels, color.NewARGB(255, uint8(r>>8), uint8(g>>8), uint8(b>>8))) + } + } + return pixels, nil +} + +// quantize reduces the image to at most maxColors representative colors and the +// pixel count behind each. This is Celebi's shape (Wu for the starting +// clusters, weighted k-means to refine them) but with our own k-means loop: +// material/v3's QuantizeCelebi returns after a single unconverged iteration and +// its Lab.DistanceSquared is a dot product rather than a distance, so its +// output collapses most of the image into one cluster: measured at 7,979,139 of +// 8,292,604 pixels on a 3840x2160 image. +func quantize(pixels []color.ARGB, maxColors int) map[color.ARGB]int { + counts := map[color.ARGB]int{} + for _, c := range pixels { + counts[c]++ + } + + // Sorted so cluster assignment and the float accumulation below are + // reproducible: identical wallpaper in, identical seed out. + unique := make([]color.ARGB, 0, len(counts)) + for c := range counts { + unique = append(unique, c) + } + slices.Sort(unique) + + points := make([]color.Lab, len(unique)) + weights := make([]float64, len(unique)) + for i, c := range unique { + points[i] = c.ToLab() + weights[i] = float64(counts[c]) + } + + clusters := make([]color.Lab, 0, maxColors) + for _, c := range quantizer.QuantizeWu(pixels, maxColors) { + clusters = append(clusters, c.ToLab()) + } + if len(clusters) == 0 { + return nil + } + + assigned := make([]int, len(points)) + for i := range assigned { + assigned[i] = -1 + } + sums := make([][3]float64, len(clusters)) + clusterWeights := make([]float64, len(clusters)) + + for iteration := range sourceMaxIters { + moved := 0 + for i, p := range points { + nearest, nearestDistance := 0, math.Inf(1) + for j, c := range clusters { + if d := labDistanceSquared(p, c); d < nearestDistance { + nearest, nearestDistance = j, d + } + } + if assigned[i] != nearest { + assigned[i] = nearest + moved++ + } + } + if moved == 0 && iteration > 0 { + break + } + + clear(sums) + clear(clusterWeights) + for i, p := range points { + j, w := assigned[i], weights[i] + clusterWeights[j] += w + sums[j][0] += p.L * w + sums[j][1] += p.A * w + sums[j][2] += p.B * w + } + for j := range clusters { + if clusterWeights[j] == 0 { + continue + } + clusters[j] = color.NewLab( + sums[j][0]/clusterWeights[j], + sums[j][1]/clusterWeights[j], + sums[j][2]/clusterWeights[j], + ) + } + } + + population := make(map[color.ARGB]int, len(clusters)) + for j := range clusters { + if clusterWeights[j] == 0 { + continue + } + population[clusters[j].ToARGB()] += int(clusterWeights[j]) + } + return population +} + +func labDistanceSquared(a, b color.Lab) float64 { + dl, da, db := a.L-b.L, a.A-b.A, a.B-b.B + return dl*dl + da*da + db*db +} + +// scoreColors ports caelestia-cli's Score.score. It differs from stock Material +// scoring in two ways that matter: no filtering, and the descending cutoff loop +// at the end, which walks the chroma/tone bar down until something clears it. +// That bar is what pushes the pick toward a colorful, well-lit color instead of +// whichever muted color covers the most pixels. +func scoreColors(population map[color.ARGB]int) (color.Hct, bool) { + // Sorted for the same reason quantize sorts: the stable sort below and the + // cutoff scan both resolve ties by input order, and Go map iteration order + // is randomized. + keys := make([]color.ARGB, 0, len(population)) + for argb := range population { + keys = append(keys, argb) + } + slices.Sort(keys) + + huePopulation := make([]int, 360) + total := 0 + colors := make([]color.Hct, 0, len(keys)) + for _, argb := range keys { + hct := argb.ToHct() + colors = append(colors, hct) + huePopulation[num.NormalizeDegreeInt(int(hct.Hue))] += population[argb] + total += population[argb] + } + if total == 0 { + return color.Hct{}, false + } + + // Hues with more usage in a neighboring 30 degree slice score higher. + excited := make([]float64, 360) + for hue := range 360 { + proportion := float64(huePopulation[hue]) / float64(total) + for i := hue - 14; i < hue+16; i++ { + excited[num.NormalizeDegreeInt(i)] += proportion + } + } + + type scoredColor struct { + hct color.Hct + score float64 + } + scored := make([]scoredColor, 0, len(colors)) + for _, hct := range colors { + proportion := excited[num.NormalizeDegreeInt(int(math.Round(hct.Hue)))] + chromaWeight := scoreWeightChromaAbove + if hct.Chroma < scoreTargetChroma { + chromaWeight = scoreWeightChromaBelow + } + scored = append(scored, scoredColor{ + hct: hct, + score: proportion*100.0*scoreWeightProportion + (hct.Chroma-scoreTargetChroma)*chromaWeight, + }) + } + slices.SortStableFunc(scored, func(a, b scoredColor) int { return cmp.Compare(b.score, a.score) }) + + for cutoff := 20.0; cutoff >= 0; cutoff-- { + for _, s := range scored { + if s.hct.Chroma > cutoff && s.hct.Tone > cutoff*3 { + return dislike.FixIfDisliked(s.hct), true + } + } + } + return color.Hct{}, false +} diff --git a/core/internal/server/matugen_handler.go b/core/internal/server/matugen_handler.go index 0c84cd491..08cf119c1 100644 --- a/core/internal/server/matugen_handler.go +++ b/core/internal/server/matugen_handler.go @@ -29,6 +29,7 @@ func handleMatugenQueue(conn *models.Conn, req models.Request) { TerminalsAlwaysDark: models.GetOr(req, "terminalsAlwaysDark", false), SkipTemplates: models.GetOr(req, "skipTemplates", ""), Contrast: models.GetOr(req, "contrast", 0.0), + SourceMode: models.GetOr(req, "sourceMode", ""), } wait := models.GetOr(req, "wait", true) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 22765c6e9..6f22c4b2f 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -165,6 +165,7 @@ Singleton { property string customThemeFile: "" property var registryThemeVariants: ({}) property string matugenScheme: "scheme-tonal-spot" + property string matugenSourceMode: "dominant" property real matugenContrast: 0 property bool runUserMatugenTemplates: true property string matugenTargetMonitor: "" @@ -2587,6 +2588,17 @@ Singleton { } } + function setMatugenSourceMode(mode) { + var normalized = mode || "dominant"; + if (matugenSourceMode === normalized) + return; + // Regeneration comes from the regenSystemThemes onChange hook in + // SettingsSpec.js, which set() dispatches. matugenScheme above also + // calls Theme.generateSystemThemesFromCurrentTheme() directly, which is + // redundant with its own hook. + set("matugenSourceMode", normalized); + } + function setMatugenContrast(value) { if (matugenContrast === value) return; diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index c7c513d77..f6881fe63 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -372,6 +372,38 @@ Singleton { return schemes[0]; } + readonly property var availableSourceModes: [({ + "value": "dominant", + "label": I18n.tr("Dominant", "matugen source color option") + }), ({ + "value": "colorful", + "label": I18n.tr("Colorful", "matugen source color option") + }), ({ + "value": "darkness", + "label": I18n.tr("Darkest", "matugen source color option") + }), ({ + "value": "lightness", + "label": I18n.tr("Lightest", "matugen source color option") + }), ({ + "value": "saturation", + "label": I18n.tr("Most Saturated", "matugen source color option") + }), ({ + "value": "less-saturation", + "label": I18n.tr("Least Saturated", "matugen source color option") + }), ({ + "value": "value", + "label": I18n.tr("Most Vivid", "matugen source color option") + })] + + function getSourceMode(value) { + const modes = availableSourceModes; + for (var i = 0; i < modes.length; i++) { + if (modes[i].value === value) + return modes[i]; + } + return modes[0]; + } + property color primary: currentThemeData.primary property color primaryText: currentThemeData.primaryText property color secondary: currentThemeData.secondary @@ -1521,6 +1553,13 @@ Singleton { if (typeof SettingsData !== "undefined" && SettingsData.matugenContrast !== 0) { args.push("--contrast", SettingsData.matugenContrast.toString()); } + // Only sent when it would change something. A shell newer than the dms + // 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") { + args.push("--source-mode", SettingsData.matugenSourceMode); + } if (typeof SettingsData !== "undefined") { const skipTemplates = []; diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index b17655a4a..036c29cf6 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -13,6 +13,7 @@ var SPEC = { customThemeFile: { def: "" }, registryThemeVariants: { def: {} }, matugenScheme: { def: "scheme-tonal-spot", onChange: "regenSystemThemes" }, + matugenSourceMode: { def: "dominant", onChange: "regenSystemThemes" }, matugenContrast: { def: 0, onChange: "regenSystemThemes" }, runUserMatugenTemplates: { def: true, onChange: "regenSystemThemes" }, matugenTargetMonitor: { def: "", onChange: "regenSystemThemes" }, diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index c57cfb956..b5c0ab612 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -21,6 +21,7 @@ Item { property var cachedIconThemes: SettingsData.availableIconThemes property var cachedCursorThemes: SettingsData.availableCursorThemes property var cachedMatugenSchemes: Theme.availableMatugenSchemes.map(option => option.label) + property var cachedSourceModes: Theme.availableSourceModes.map(option => option.label) property var matugenSchemePreviews: ({}) property string matugenPreviewSource: "" property real matugenPreviewContrast: 0 @@ -781,6 +782,27 @@ Item { x: Theme.spacingM } + SettingsDropdownRow { + tab: "theme" + tags: ["matugen", "seed", "source", "wallpaper", "dynamic"] + settingKey: "matugenSourceMode" + text: I18n.tr("Source Color") + description: I18n.tr("Select which color is extracted from the wallpaper to seed the palette") + options: cachedSourceModes + currentValue: Theme.getSourceMode(SettingsData.matugenSourceMode).label + enabled: Theme.matugenAvailable + opacity: enabled ? 1 : 0.4 + onValueChanged: value => { + for (var i = 0; i < Theme.availableSourceModes.length; i++) { + var option = Theme.availableSourceModes[i]; + if (option.label === value) { + SettingsData.setMatugenSourceMode(option.value); + break; + } + } + } + } + SettingsSliderRow { tab: "theme" tags: ["matugen", "contrast", "dynamic"] diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 4be4b577f..fe51bc743 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -4645,6 +4645,38 @@ "icon": "layers", "description": "Material inspired shadows and elevation on modals, popouts, and dialogs" }, + { + "section": "matugenSourceMode", + "label": "Source Color", + "tabIndex": 10, + "category": "Theme & Colors", + "keywords": [ + "appearance", + "background", + "bg", + "color", + "colors", + "colour", + "desktop", + "dynamic", + "extracted", + "hue", + "image", + "look", + "matugen", + "palette", + "picture", + "scheme", + "seed", + "select", + "source", + "style", + "theme", + "tint", + "wallpaper" + ], + "description": "Select which color is extracted from the wallpaper to seed the palette" + }, { "section": "blurBorderColor", "label": "Surface Border Color",