feat(theme): add a selectable source color mode for wallpaper palettes (#3020)

* feat(theme): add a selectable source color mode for wallpaper palettes

DMS always seeds the palette from matugen's most dominant candidate
(--source-color-index 0, hardcoded at both call sites). When a large
muted region outvotes a smaller vivid one, a visibly warm wallpaper
produces a cold palette: one image here goes to #8ad0ee where the warm
subject is at hue 46.

Adds a Source Color setting with seven values. Dominant is the default
and is byte-identical to today. Colorful is computed in Go before
matugen runs: quantize the image (Wu for the starting clusters, then a
weighted k-means in Lab), score the result the way caelestia-cli does,
and hand matugen the resulting hex. Its scoring walks a chroma/tone bar
down from 20 until something clears it, which biases the pick toward a
colorful, well-lit color rather than whichever muted color covers the
most pixels. The remaining five map to matugen's --prefer.

- core/internal/matugen/sourcecolor.go: extraction, scoring, and the
  one helper both matugen call sites now share
- buildOnce resolves the seed once, before the StockColors branch, so
  the dry run and the real run cannot disagree about it
- extraction failure logs a warning and falls through to matugen's own
  extraction; it never fails a theme build
- --prefer arrived in matugen 4.1, so it is gated separately from the
  existing isV4 check; on 4.0.x those five modes degrade to the
  dominant color instead of aborting on an unknown argument
- --source-mode on generate and queue, plus sourceMode on the socket
  handler
- Source Color dropdown in Theme & Colors, defaulting to Dominant

Adds github.com/Nadim147c/material/v3 (Apache-2.0, no dependencies) for
HCT conversions, Wu quantization and the dislike analyzer. Its
QuantizeCelebi is not used: it returns after one unconverged iteration
and its Lab.DistanceSquared is a dot product rather than a distance, so
it collapses most of an image into a single cluster.

* fix(theme): address review feedback on source color mode

Two points from review on #3020.

The quantize comment pointed at a FOLLOWUPS file that does not exist in
this repository. Replaced the reference with the measurement it was
standing in for, so the claim is self-contained.

setMatugenSourceMode called Theme.generateSystemThemesFromCurrentTheme()
directly on top of the regenSystemThemes onChange hook that Spec.set
already dispatches. Both feed a 100ms debounce so it was one generation
either way, but the direct call was redundant and is gone.

* fix(theme): keep {{image}} resolvable in colorful mode

Colorful resolves the seed itself and calls matugen with a hex source, which
leaves matugen's {{image}} unset: the builtin pywalfox template and any user
template using the keyword render the literal string "Null".

Pass the wallpaper path alongside dank16 in --import-json-string, as suggested
in review, so those templates keep working unedited. The path is made absolute
to match what matugen renders for {{image}} in the other modes.

* fix(theme): cut translator terms on the source color dropdown

Per-mode descriptions were seven of the fifteen new terms this branch adds,
and five of them differed by a single adjective. Drop them: the dropdown row
already describes what the setting does, and the docs cover each mode.
This commit is contained in:
Marlon Kranz authored and GitHub committed 2026-09-03 09:07:21 -04:00
1 parent 0fc4aac964
commit 4693178345
12 files changed
+748 -21

No files matched your search

+4
View File
@@ -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")
@@ -98,6 +99,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,
@@ -114,6 +116,7 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark,
SkipTemplates: skipTemplates,
SourceMode: sourceMode,
}
}
@@ -151,6 +154,7 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"sourceMode": opts.SourceMode,
"wait": wait,
},
}
+1
View File
@@ -4,6 +4,7 @@ go 1.26.5
require (
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577
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
+2
View File
@@ -6,6 +6,8 @@ github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577 h1:z7daEy56wLH6
github.com/AvengeMedia/dgop v0.2.4-0.20260819141338-085d828cf577/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=
+67 -21
View File
@@ -91,11 +91,12 @@ func (c *ColorMode) GTKTheme() string {
}
var (
matugenVersionMu sync.Mutex
matugenVersionOK bool
matugenSupportsCOE bool
matugenIsV4 bool
matugenIsV42 bool
matugenVersionMu sync.Mutex
matugenVersionOK bool
matugenSupportsCOE bool
matugenIsV4 bool
matugenIsV42 bool
matugenSupportsPrefer bool
)
type Options struct {
@@ -108,6 +109,7 @@ type Options struct {
IconTheme string
MatugenType string
Contrast float64
SourceMode string
RunUserTemplates bool
ColorsOnly bool
StockColors string
@@ -344,6 +346,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")
@@ -366,7 +390,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 {
@@ -389,8 +413,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
@@ -403,7 +426,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
}
}
@@ -466,6 +489,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")
@@ -798,9 +832,10 @@ func extractTOMLSection(content, startMarker, endMarker string) string {
}
type matugenFlags struct {
supportsCOE bool
isV4 bool
isV42 bool
supportsCOE bool
isV4 bool
isV42 bool
supportsPrefer bool
}
func detectMatugenVersion() (matugenFlags, error) {
@@ -808,7 +843,7 @@ func detectMatugenVersion() (matugenFlags, error) {
defer matugenVersionMu.Unlock()
if matugenVersionOK {
return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenIsV42}, nil
return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenIsV42, matugenSupportsPrefer}, nil
}
return detectMatugenVersionLocked()
@@ -828,7 +863,8 @@ func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
if err != nil {
return old, false
}
changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4 || flags.isV42 != old.isV42
changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4 || flags.isV42 != old.isV42 ||
flags.supportsPrefer != old.supportsPrefer
return flags, changed
}
@@ -861,6 +897,9 @@ func detectMatugenVersionLocked() (matugenFlags, error) {
matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
matugenIsV4 = major >= 4
matugenIsV42 = major > 4 || (major == 4 && minor >= 2)
// --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 {
@@ -869,28 +908,34 @@ func detectMatugenVersionLocked() (matugenFlags, error) {
if matugenIsV4 {
log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr)
}
return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenIsV42}, nil
if matugenIsV4 && !matugenSupportsPrefer {
log.Debugf("Matugen %s detected: --prefer unavailable, source modes fall back to the dominant color", versionStr)
}
return matugenFlags{matugenSupportsCOE, matugenIsV4, matugenIsV42, 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
@@ -908,7 +953,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
@@ -949,7 +994,8 @@ func execDryRun(opts *Options, flags matugenFlags) (string, error) {
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")
baseArgs = append(baseArgs, sourceSelectionArgs(opts.SourceMode, flags.supportsPrefer)...)
baseArgs = append(baseArgs, "--old-json-output")
}
cmd := exec.Command("matugen", baseArgs...)
+264
View File
@@ -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()
+303
View File
@@ -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
}
+1
View File
@@ -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)
+12
View File
@@ -168,6 +168,7 @@ Singleton {
property var registryThemeVariants: ({})
property string matugenScheme: "scheme-tonal-spot"
property bool matugenSmartMode: false
property string matugenSourceMode: "dominant"
property real matugenContrast: 0
property bool runUserMatugenTemplates: true
property string matugenTargetMonitor: ""
@@ -2811,6 +2812,17 @@ Singleton {
set("matugenSmartMode", enabled);
}
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;
+39
View File
@@ -394,6 +394,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
@@ -1620,6 +1652,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 = [];
@@ -14,6 +14,7 @@ var SPEC = {
registryThemeVariants: { def: {} },
matugenScheme: { def: "scheme-tonal-spot", onChange: "regenSystemThemes" },
matugenSmartMode: { def: false, onChange: "regenSystemThemes" },
matugenSourceMode: { def: "dominant", onChange: "regenSystemThemes" },
matugenContrast: { def: 0, onChange: "regenSystemThemes" },
runUserMatugenTemplates: { def: true, onChange: "regenSystemThemes" },
matugenTargetMonitor: { def: "", onChange: "regenSystemThemes" },
@@ -22,6 +22,7 @@ Item {
property var cachedCursorThemes: SettingsData.availableCursorThemes
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 cachedSourceModes: Theme.availableSourceModes.map(option => option.label)
property var matugenSchemePreviews: ({})
property string matugenPreviewSource: ""
property string matugenPreviewImage: ""
@@ -788,6 +789,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"]
@@ -4752,6 +4752,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",