mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-26 04:57:47 -04:00
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:
1 parent
0fc4aac964
commit
4693178345
12 files changed
+748
-21
No files matched your search
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user