summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen.go978
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go443
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/queue.go143
3 files changed, 1564 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen.go
new file mode 100644
index 0000000..a29d324
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen.go
@@ -0,0 +1,978 @@
+package matugen
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/dank16"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+ "github.com/lucasb-eyer/go-colorful"
+)
+
+var ErrNoChanges = errors.New("no color changes")
+
+type ColorMode string
+
+const (
+ ColorModeDark ColorMode = "dark"
+ ColorModeLight ColorMode = "light"
+)
+
+type TemplateKind int
+
+const (
+ TemplateKindNormal TemplateKind = iota
+ TemplateKindTerminal
+ TemplateKindGTK
+ TemplateKindVSCode
+ TemplateKindEmacs
+)
+
+type TemplateDef struct {
+ ID string
+ Commands []string
+ Flatpaks []string
+ ConfigFile string
+ Kind TemplateKind
+ RunUnconditionally bool
+}
+
+var templateRegistry = []TemplateDef{
+ {ID: "gtk", Kind: TemplateKindGTK, RunUnconditionally: true},
+ {ID: "niri", Commands: []string{"niri"}, ConfigFile: "niri.toml"},
+ {ID: "hyprland", Commands: []string{"Hyprland"}, ConfigFile: "hyprland.toml"},
+ {ID: "mangowc", Commands: []string{"mango"}, ConfigFile: "mangowc.toml"},
+ {ID: "qt5ct", Commands: []string{"qt5ct"}, ConfigFile: "qt5ct.toml"},
+ {ID: "qt6ct", Commands: []string{"qt6ct"}, ConfigFile: "qt6ct.toml"},
+ {ID: "firefox", Commands: []string{"firefox"}, ConfigFile: "firefox.toml"},
+ {ID: "pywalfox", Commands: []string{"pywalfox"}, ConfigFile: "pywalfox.toml"},
+ {ID: "zenbrowser", Commands: []string{"zen", "zen-browser", "zen-beta", "zen-twilight"}, Flatpaks: []string{"app.zen_browser.zen"}, ConfigFile: "zenbrowser.toml"},
+ {ID: "vesktop", Commands: []string{"vesktop"}, Flatpaks: []string{"dev.vencord.Vesktop"}, ConfigFile: "vesktop.toml"},
+ {ID: "equibop", Commands: []string{"equibop"}, ConfigFile: "equibop.toml"},
+ {ID: "ghostty", Commands: []string{"ghostty"}, ConfigFile: "ghostty.toml", Kind: TemplateKindTerminal},
+ {ID: "kitty", Commands: []string{"kitty"}, ConfigFile: "kitty.toml", Kind: TemplateKindTerminal},
+ {ID: "foot", Commands: []string{"foot"}, ConfigFile: "foot.toml", Kind: TemplateKindTerminal},
+ {ID: "alacritty", Commands: []string{"alacritty"}, ConfigFile: "alacritty.toml", Kind: TemplateKindTerminal},
+ {ID: "wezterm", Commands: []string{"wezterm"}, ConfigFile: "wezterm.toml", Kind: TemplateKindTerminal},
+ {ID: "nvim", Commands: []string{"nvim"}, ConfigFile: "neovim.toml", Kind: TemplateKindTerminal},
+ {ID: "dgop", Commands: []string{"dgop"}, ConfigFile: "dgop.toml"},
+ {ID: "kcolorscheme", ConfigFile: "kcolorscheme.toml", RunUnconditionally: true},
+ {ID: "vscode", Kind: TemplateKindVSCode},
+ {ID: "emacs", Commands: []string{"emacs"}, ConfigFile: "emacs.toml", Kind: TemplateKindEmacs},
+ {ID: "zed", Commands: []string{"zed", "zeditor", "zedit"}, ConfigFile: "zed.toml"},
+}
+
+func (c *ColorMode) GTKTheme() string {
+ switch *c {
+ case ColorModeDark:
+ return "adw-gtk3-dark"
+ default:
+ return "adw-gtk3"
+ }
+}
+
+var (
+ matugenVersionMu sync.Mutex
+ matugenVersionOK bool
+ matugenSupportsCOE bool
+ matugenIsV4 bool
+)
+
+type Options struct {
+ StateDir string
+ ShellDir string
+ ConfigDir string
+ Kind string
+ Value string
+ Mode ColorMode
+ IconTheme string
+ MatugenType string
+ Contrast float64
+ RunUserTemplates bool
+ ColorsOnly bool
+ StockColors string
+ SyncModeWithPortal bool
+ TerminalsAlwaysDark bool
+ SkipTemplates string
+ AppChecker utils.AppChecker
+}
+
+type ColorsOutput struct {
+ Colors struct {
+ Dark map[string]string `json:"dark"`
+ Light map[string]string `json:"light"`
+ } `json:"colors"`
+}
+
+func (o *Options) ColorsOutput() string {
+ return filepath.Join(o.StateDir, "dms-colors.json")
+}
+
+func (o *Options) ShouldSkipTemplate(name string) bool {
+ if o.SkipTemplates == "" {
+ return false
+ }
+ for _, skip := range strings.Split(o.SkipTemplates, ",") {
+ if strings.TrimSpace(skip) == name {
+ return true
+ }
+ }
+ return false
+}
+
+func Run(opts Options) error {
+ if opts.StateDir == "" {
+ return fmt.Errorf("state-dir is required")
+ }
+ if opts.ShellDir == "" {
+ return fmt.Errorf("shell-dir is required")
+ }
+ if opts.ConfigDir == "" {
+ return fmt.Errorf("config-dir is required")
+ }
+ if opts.Kind == "" {
+ return fmt.Errorf("kind is required")
+ }
+ if opts.Value == "" {
+ return fmt.Errorf("value is required")
+ }
+ if opts.Mode == "" {
+ opts.Mode = ColorModeDark
+ }
+ if opts.MatugenType == "" {
+ opts.MatugenType = "scheme-tonal-spot"
+ }
+ if opts.IconTheme == "" {
+ opts.IconTheme = "System Default"
+ }
+ if opts.AppChecker == nil {
+ opts.AppChecker = utils.DefaultAppChecker{}
+ }
+
+ if err := os.MkdirAll(opts.StateDir, 0o755); err != nil {
+ return fmt.Errorf("failed to create state dir: %w", err)
+ }
+
+ log.Infof("Building theme: %s %s (%s)", opts.Kind, opts.Value, opts.Mode)
+
+ changed, buildErr := buildOnce(&opts)
+ if buildErr != nil {
+ return buildErr
+ }
+
+ if !changed {
+ log.Info("No color changes detected, skipping refresh")
+ return ErrNoChanges
+ }
+
+ if opts.SyncModeWithPortal {
+ syncColorScheme(opts.Mode)
+ }
+
+ log.Info("Done")
+ return nil
+}
+
+func buildOnce(opts *Options) (bool, error) {
+ cfgFile, err := os.CreateTemp("", "matugen-config-*.toml")
+ if err != nil {
+ return false, fmt.Errorf("failed to create temp config: %w", err)
+ }
+ defer os.Remove(cfgFile.Name())
+ defer cfgFile.Close()
+
+ tmpDir, err := os.MkdirTemp("", "matugen-templates-*")
+ if err != nil {
+ return false, fmt.Errorf("failed to create temp dir: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ if err := buildMergedConfig(opts, cfgFile, tmpDir); err != nil {
+ return false, fmt.Errorf("failed to build config: %w", err)
+ }
+ cfgFile.Close()
+
+ oldColors, _ := os.ReadFile(opts.ColorsOutput())
+
+ var primaryDark, primaryLight, surface string
+ var dank16JSON string
+ var importArgs []string
+
+ if opts.StockColors != "" {
+ log.Info("Using stock/custom theme colors with matugen base")
+ primaryDark = extractNestedColor(opts.StockColors, "primary", "dark")
+ primaryLight = extractNestedColor(opts.StockColors, "primary", "light")
+ surface = extractNestedColor(opts.StockColors, "surface", "dark")
+
+ if primaryDark == "" {
+ return false, fmt.Errorf("failed to extract primary dark from stock colors")
+ }
+ if primaryLight == "" {
+ primaryLight = primaryDark
+ }
+
+ dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
+ importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
+ importArgs = []string{"--import-json-string", importData}
+
+ log.Info("Running matugen color hex with stock color overrides")
+ 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 {
+ return false, err
+ }
+ } else {
+ log.Infof("Using dynamic theme from %s: %s", opts.Kind, opts.Value)
+
+ matJSON, err := runMatugenDryRun(opts)
+ if err != nil {
+ return false, fmt.Errorf("matugen dry-run failed: %w", err)
+ }
+
+ primaryDark = extractMatugenColor(matJSON, "primary", "dark")
+ primaryLight = extractMatugenColor(matJSON, "primary", "light")
+ surface = extractMatugenColor(matJSON, "surface", "dark")
+
+ if primaryDark == "" {
+ return false, fmt.Errorf("failed to extract primary color")
+ }
+ if primaryLight == "" {
+ primaryLight = primaryDark
+ }
+
+ dank16JSON = generateDank16Variants(primaryDark, primaryLight, surface, opts.Mode)
+ importData := fmt.Sprintf(`{"dank16": %s}`, dank16JSON)
+ importArgs = []string{"--import-json-string", importData}
+
+ log.Infof("Running matugen %s with dank16 injection", opts.Kind)
+ var args []string
+ switch opts.Kind {
+ case "hex":
+ args = []string{"color", "hex", opts.Value}
+ default:
+ args = []string{opts.Kind, opts.Value}
+ }
+ 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 {
+ return false, err
+ }
+ }
+
+ newColors, _ := os.ReadFile(opts.ColorsOutput())
+ if bytes.Equal(oldColors, newColors) && len(oldColors) > 0 {
+ return false, nil
+ }
+
+ if opts.ColorsOnly {
+ return true, nil
+ }
+
+ if isDMSGTKActive(opts.ConfigDir) {
+ switch opts.Mode {
+ case ColorModeLight:
+ syncAccentColor(primaryLight)
+ default:
+ syncAccentColor(primaryDark)
+ }
+ refreshGTK(opts.Mode)
+ refreshGTK4()
+ }
+
+ if !opts.ShouldSkipTemplate("qt6ct") && appExists(opts.AppChecker, []string{"qt6ct"}, nil) {
+ refreshQt6ct()
+ }
+
+ signalTerminals(opts)
+
+ return true, nil
+}
+
+func appendContrastArg(args []string, contrast float64) []string {
+ if contrast == 0 {
+ return args
+ }
+ return append(args, "--contrast", strconv.FormatFloat(contrast, 'f', -1, 64))
+}
+
+func buildMergedConfig(opts *Options, cfgFile *os.File, tmpDir string) error {
+ userConfigPath := filepath.Join(opts.ConfigDir, "matugen", "config.toml")
+
+ wroteConfig := false
+ if opts.RunUserTemplates {
+ if data, err := os.ReadFile(userConfigPath); err == nil {
+ configSection := extractTOMLSection(string(data), "[config]", "[templates]")
+ if configSection != "" {
+ cfgFile.WriteString(configSection)
+ cfgFile.WriteString("\n")
+ wroteConfig = true
+ }
+ }
+ }
+ if !wroteConfig {
+ cfgFile.WriteString("[config]\n\n")
+ }
+
+ baseConfigPath := filepath.Join(opts.ShellDir, "matugen", "configs", "base.toml")
+ if data, err := os.ReadFile(baseConfigPath); err == nil {
+ content := string(data)
+ lines := strings.Split(content, "\n")
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "[config]" {
+ continue
+ }
+ cfgFile.WriteString(substituteVars(line, opts.ShellDir) + "\n")
+ }
+ cfgFile.WriteString("\n")
+ }
+
+ fmt.Fprintf(cfgFile, `[templates.dank]
+input_path = '%s/matugen/templates/dank.json'
+output_path = '%s'
+
+`, opts.ShellDir, opts.ColorsOutput())
+
+ if opts.ColorsOnly {
+ return nil
+ }
+
+ homeDir, _ := os.UserHomeDir()
+ for _, tmpl := range templateRegistry {
+ if opts.ShouldSkipTemplate(tmpl.ID) {
+ continue
+ }
+
+ switch tmpl.Kind {
+ case TemplateKindGTK:
+ switch opts.Mode {
+ case ColorModeLight:
+ appendConfig(opts, cfgFile, nil, nil, "gtk3-light.toml")
+ default:
+ appendConfig(opts, cfgFile, nil, nil, "gtk3-dark.toml")
+ }
+ case TemplateKindTerminal:
+ appendTerminalConfig(opts, cfgFile, tmpDir, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
+ case TemplateKindVSCode:
+ appendVSCodeConfig(cfgFile, "vscode", filepath.Join(homeDir, ".vscode/extensions"), opts.ShellDir)
+ appendVSCodeConfig(cfgFile, "codium", filepath.Join(homeDir, ".vscode-oss/extensions"), opts.ShellDir)
+ appendVSCodeConfig(cfgFile, "codeoss", filepath.Join(homeDir, ".config/Code - OSS/extensions"), opts.ShellDir)
+ appendVSCodeConfig(cfgFile, "cursor", filepath.Join(homeDir, ".cursor/extensions"), opts.ShellDir)
+ appendVSCodeConfig(cfgFile, "windsurf", filepath.Join(homeDir, ".windsurf/extensions"), opts.ShellDir)
+ appendVSCodeConfig(cfgFile, "vscode-insiders", filepath.Join(homeDir, ".vscode-insiders/extensions"), opts.ShellDir)
+ case TemplateKindEmacs:
+ if utils.EmacsConfigDir() != "" {
+ appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
+ }
+ default:
+ appendConfig(opts, cfgFile, tmpl.Commands, tmpl.Flatpaks, tmpl.ConfigFile)
+ }
+ }
+
+ if opts.RunUserTemplates {
+ if data, err := os.ReadFile(userConfigPath); err == nil {
+ templatesSection := extractTOMLSection(string(data), "[templates]", "")
+ if templatesSection != "" {
+ cfgFile.WriteString(templatesSection)
+ cfgFile.WriteString("\n")
+ }
+ }
+ }
+
+ userPluginConfigDir := filepath.Join(opts.ConfigDir, "matugen", "dms", "configs")
+ if entries, err := os.ReadDir(userPluginConfigDir); err == nil {
+ for _, entry := range entries {
+ if !strings.HasSuffix(entry.Name(), ".toml") {
+ continue
+ }
+ if data, err := os.ReadFile(filepath.Join(userPluginConfigDir, entry.Name())); err == nil {
+ cfgFile.WriteString(string(data))
+ cfgFile.WriteString("\n")
+ }
+ }
+ }
+
+ return nil
+}
+
+func appendConfig(
+ opts *Options,
+ cfgFile *os.File,
+ checkCmd []string,
+ checkFlatpaks []string,
+ fileName string,
+) {
+ configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
+ if _, err := os.Stat(configPath); err != nil {
+ return
+ }
+ if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
+ return
+ }
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return
+ }
+ cfgFile.WriteString(substituteVars(string(data), opts.ShellDir))
+ cfgFile.WriteString("\n")
+}
+
+func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkCmd []string, checkFlatpaks []string, fileName string) {
+ configPath := filepath.Join(opts.ShellDir, "matugen", "configs", fileName)
+ if _, err := os.Stat(configPath); err != nil {
+ return
+ }
+ if !appExists(opts.AppChecker, checkCmd, checkFlatpaks) {
+ return
+ }
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return
+ }
+
+ content := string(data)
+
+ if !opts.TerminalsAlwaysDark {
+ cfgFile.WriteString(substituteVars(content, opts.ShellDir))
+ cfgFile.WriteString("\n")
+ return
+ }
+
+ lines := strings.Split(content, "\n")
+ for _, line := range lines {
+ if !strings.Contains(line, "input_path") || !strings.Contains(line, "SHELL_DIR/matugen/templates/") {
+ continue
+ }
+
+ start := strings.Index(line, "'SHELL_DIR/matugen/templates/")
+ if start == -1 {
+ continue
+ }
+ end := strings.Index(line[start+1:], "'")
+ if end == -1 {
+ continue
+ }
+ templateName := line[start+len("'SHELL_DIR/matugen/templates/") : start+1+end]
+ origPath := filepath.Join(opts.ShellDir, "matugen", "templates", templateName)
+
+ origData, err := os.ReadFile(origPath)
+ if err != nil {
+ continue
+ }
+
+ modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
+ tmpPath := filepath.Join(tmpDir, templateName)
+ if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil {
+ continue
+ }
+
+ content = strings.ReplaceAll(content,
+ fmt.Sprintf("'SHELL_DIR/matugen/templates/%s'", templateName),
+ fmt.Sprintf("'%s'", tmpPath))
+ }
+
+ cfgFile.WriteString(substituteVars(content, opts.ShellDir))
+ cfgFile.WriteString("\n")
+}
+
+func appExists(checker utils.AppChecker, checkCmd []string, checkFlatpaks []string) bool {
+ // Both nil is treated as "skip check" / unconditionally run
+ if checkCmd == nil && checkFlatpaks == nil {
+ return true
+ }
+ if checkCmd != nil && checker.AnyCommandExists(checkCmd...) {
+ return true
+ }
+ if checkFlatpaks != nil && checker.AnyFlatpakExists(checkFlatpaks...) {
+ return true
+ }
+ return false
+}
+
+func appendVSCodeConfig(cfgFile *os.File, name, extBaseDir, shellDir string) {
+ pattern := filepath.Join(extBaseDir, "danklinux.dms-theme-*")
+ matches, err := filepath.Glob(pattern)
+ if err != nil || len(matches) == 0 {
+ return
+ }
+
+ extDir := matches[0]
+ templateDir := filepath.Join(shellDir, "matugen", "templates")
+ fmt.Fprintf(cfgFile, `[templates.dms%sdefault]
+input_path = '%s/vscode-color-theme-default.json'
+output_path = '%s/themes/dankshell-default.json'
+
+[templates.dms%sdark]
+input_path = '%s/vscode-color-theme-dark.json'
+output_path = '%s/themes/dankshell-dark.json'
+
+[templates.dms%slight]
+input_path = '%s/vscode-color-theme-light.json'
+output_path = '%s/themes/dankshell-light.json'
+
+`, name, templateDir, extDir,
+ name, templateDir, extDir,
+ name, templateDir, extDir)
+ log.Infof("Added %s theme config (extension found at %s)", name, extDir)
+}
+
+func substituteVars(content, shellDir string) string {
+ result := strings.ReplaceAll(content, "'SHELL_DIR/", "'"+shellDir+"/")
+ result = strings.ReplaceAll(result, "'CONFIG_DIR/", "'"+utils.XDGConfigHome()+"/")
+ result = strings.ReplaceAll(result, "'DATA_DIR/", "'"+utils.XDGDataHome()+"/")
+ result = strings.ReplaceAll(result, "'CACHE_DIR/", "'"+utils.XDGCacheHome()+"/")
+ if emacsDir := utils.EmacsConfigDir(); emacsDir != "" {
+ result = strings.ReplaceAll(result, "'EMACS_DIR/", "'"+emacsDir+"/")
+ }
+ return result
+}
+
+func extractTOMLSection(content, startMarker, endMarker string) string {
+ startIdx := strings.Index(content, startMarker)
+ if startIdx == -1 {
+ return ""
+ }
+
+ if endMarker == "" {
+ return content[startIdx:]
+ }
+
+ endIdx := strings.Index(content[startIdx:], endMarker)
+ if endIdx == -1 {
+ return content[startIdx:]
+ }
+ return content[startIdx : startIdx+endIdx]
+}
+
+type matugenFlags struct {
+ supportsCOE bool
+ isV4 bool
+}
+
+func detectMatugenVersion() (matugenFlags, error) {
+ matugenVersionMu.Lock()
+ defer matugenVersionMu.Unlock()
+
+ if matugenVersionOK {
+ return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
+ }
+
+ return detectMatugenVersionLocked()
+}
+
+func redetectMatugenVersion(old matugenFlags) (matugenFlags, bool) {
+ matugenVersionMu.Lock()
+ defer matugenVersionMu.Unlock()
+
+ matugenVersionOK = false
+ flags, err := detectMatugenVersionLocked()
+ if err != nil {
+ return old, false
+ }
+ changed := flags.supportsCOE != old.supportsCOE || flags.isV4 != old.isV4
+ return flags, changed
+}
+
+func detectMatugenVersionLocked() (matugenFlags, error) {
+ cmd := exec.Command("matugen", "--version")
+ output, err := cmd.Output()
+ if err != nil {
+ return matugenFlags{}, fmt.Errorf("failed to get matugen version: %w", err)
+ }
+
+ versionStr := strings.TrimSpace(string(output))
+ versionStr = strings.TrimPrefix(versionStr, "matugen ")
+
+ parts := strings.Split(versionStr, ".")
+ if len(parts) < 2 {
+ return matugenFlags{}, fmt.Errorf("unexpected matugen version format: %q", versionStr)
+ }
+
+ major, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return matugenFlags{}, fmt.Errorf("failed to parse matugen major version %q: %w", parts[0], err)
+ }
+
+ minor, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return matugenFlags{}, fmt.Errorf("failed to parse matugen minor version %q: %w", parts[1], err)
+ }
+
+ matugenSupportsCOE = major > 3 || (major == 3 && minor >= 1)
+ matugenIsV4 = major >= 4
+ matugenVersionOK = true
+
+ if matugenSupportsCOE {
+ log.Debugf("Matugen %s detected: continue-on-error support enabled", versionStr)
+ }
+ if matugenIsV4 {
+ log.Debugf("Matugen %s detected: using v4 compatibility flags", versionStr)
+ }
+ return matugenFlags{matugenSupportsCOE, matugenIsV4}, nil
+}
+
+func buildMatugenArgs(baseArgs []string, flags matugenFlags) []string {
+ args := make([]string, 0, len(baseArgs)+4)
+ if flags.supportsCOE {
+ args = append(args, "--continue-on-error")
+ }
+ args = append(args, baseArgs...)
+ if flags.isV4 {
+ args = append(args, "--source-color-index", "0")
+ }
+ return args
+}
+
+func runMatugen(baseArgs []string) error {
+ flags, err := detectMatugenVersion()
+ if err != nil {
+ return err
+ }
+
+ args := buildMatugenArgs(baseArgs, flags)
+ cmd := exec.Command("matugen", args...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ runErr := cmd.Run()
+ if runErr == nil {
+ return nil
+ }
+
+ log.Warnf("Matugen failed (v4=%v): %v", flags.isV4, runErr)
+
+ newFlags, changed := redetectMatugenVersion(flags)
+ if !changed {
+ return runErr
+ }
+
+ log.Warnf("Matugen version changed (v4: %v -> %v), retrying", flags.isV4, newFlags.isV4)
+ args = buildMatugenArgs(baseArgs, newFlags)
+ retryCmd := exec.Command("matugen", args...)
+ retryCmd.Stdout = os.Stdout
+ retryCmd.Stderr = os.Stderr
+ return retryCmd.Run()
+}
+
+func runMatugenDryRun(opts *Options) (string, error) {
+ flags, err := detectMatugenVersion()
+ if err != nil {
+ return "", err
+ }
+
+ output, dryErr := execDryRun(opts, flags)
+ if dryErr == nil {
+ return output, nil
+ }
+
+ log.Warnf("Matugen dry-run failed (v4=%v): %v", flags.isV4, dryErr)
+
+ newFlags, changed := redetectMatugenVersion(flags)
+ if !changed {
+ return "", dryErr
+ }
+
+ log.Warnf("Matugen version changed (v4: %v -> %v), retrying dry-run", flags.isV4, newFlags.isV4)
+ return execDryRun(opts, newFlags)
+}
+
+func execDryRun(opts *Options, flags matugenFlags) (string, error) {
+ var baseArgs []string
+ switch opts.Kind {
+ case "hex":
+ baseArgs = []string{"color", "hex", opts.Value}
+ default:
+ baseArgs = []string{opts.Kind, opts.Value}
+ }
+ 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")
+ }
+
+ cmd := exec.Command("matugen", baseArgs...)
+ var stderr strings.Builder
+ cmd.Stderr = &stderr
+ output, err := cmd.Output()
+ if err != nil {
+ if stderr.Len() > 0 {
+ return "", fmt.Errorf("matugen %v failed (v4=%v): %s", baseArgs, flags.isV4, strings.TrimSpace(stderr.String()))
+ }
+ return "", fmt.Errorf("matugen %v failed (v4=%v): %w", baseArgs, flags.isV4, err)
+ }
+ return strings.ReplaceAll(string(output), "\n", ""), nil
+}
+
+func extractMatugenColor(jsonStr, colorName, variant string) string {
+ var data map[string]any
+ if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+ return ""
+ }
+
+ colors, ok := data["colors"].(map[string]any)
+ if !ok {
+ return ""
+ }
+
+ colorData, ok := colors[colorName].(map[string]any)
+ if !ok {
+ return ""
+ }
+
+ variantData, ok := colorData[variant].(string)
+ if !ok {
+ return ""
+ }
+
+ return variantData
+}
+
+func extractNestedColor(jsonStr, colorName, variant string) string {
+ var data map[string]any
+ if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+ return ""
+ }
+
+ colorData, ok := data[colorName].(map[string]any)
+ if !ok {
+ return ""
+ }
+
+ variantData, ok := colorData[variant].(map[string]any)
+ if !ok {
+ return ""
+ }
+
+ color, ok := variantData["color"].(string)
+ if !ok {
+ return ""
+ }
+
+ return color
+}
+
+func generateDank16Variants(primaryDark, primaryLight, surface string, mode ColorMode) string {
+ variantOpts := dank16.VariantOptions{
+ PrimaryDark: primaryDark,
+ PrimaryLight: primaryLight,
+ Background: surface,
+ UseDPS: true,
+ IsLightMode: mode == ColorModeLight,
+ }
+ variantColors := dank16.GenerateVariantPalette(variantOpts)
+ return dank16.GenerateVariantJSON(variantColors)
+}
+
+func isDMSGTKActive(configDir string) bool {
+ gtkCSS := filepath.Join(configDir, "gtk-3.0", "gtk.css")
+
+ info, err := os.Lstat(gtkCSS)
+ if err != nil {
+ return false
+ }
+
+ if info.Mode()&os.ModeSymlink != 0 {
+ target, err := os.Readlink(gtkCSS)
+ return err == nil && strings.Contains(target, "dank-colors.css")
+ }
+
+ data, err := os.ReadFile(gtkCSS)
+ return err == nil && strings.Contains(string(data), "dank-colors.css")
+}
+
+func refreshGTK(mode ColorMode) {
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", ""); err != nil {
+ log.Warnf("Failed to reset gtk-theme: %v", err)
+ }
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "gtk-theme", mode.GTKTheme()); err != nil {
+ log.Warnf("Failed to set gtk-theme: %v", err)
+ }
+}
+
+func refreshGTK4() {
+ output, err := utils.GsettingsGet("org.gnome.desktop.interface", "color-scheme")
+ if err != nil {
+ return
+ }
+ current := strings.Trim(output, "'")
+
+ var toggle string
+ if current == "prefer-dark" {
+ toggle = "default"
+ } else {
+ toggle = "prefer-dark"
+ }
+
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", toggle); err != nil {
+ log.Warnf("Failed to toggle color-scheme for GTK4 refresh: %v", err)
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", current); err != nil {
+ log.Warnf("Failed to restore color-scheme for GTK4 refresh: %v", err)
+ }
+}
+
+func refreshQt6ct() {
+ confPath := filepath.Join(utils.XDGConfigHome(), "qt6ct", "qt6ct.conf")
+ now := time.Now()
+ if err := os.Chtimes(confPath, now, now); err != nil {
+ log.Warnf("Failed to touch qt6ct.conf: %v", err)
+ }
+}
+
+func signalTerminals(opts *Options) {
+ if !opts.ShouldSkipTemplate("kitty") && appExists(opts.AppChecker, []string{"kitty"}, nil) {
+ signalByName("kitty", syscall.SIGUSR1)
+ signalByName(".kitty-wrapped", syscall.SIGUSR1)
+ }
+ if !opts.ShouldSkipTemplate("ghostty") && appExists(opts.AppChecker, []string{"ghostty"}, nil) {
+ signalByName("ghostty", syscall.SIGUSR2)
+ signalByName(".ghostty-wrappe", syscall.SIGUSR2)
+ }
+}
+
+func signalByName(name string, sig syscall.Signal) {
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return
+ }
+ for _, entry := range entries {
+ pid, err := strconv.Atoi(entry.Name())
+ if err != nil {
+ continue
+ }
+ comm, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "comm"))
+ if err != nil {
+ continue
+ }
+ if strings.TrimSpace(string(comm)) == name {
+ syscall.Kill(pid, sig)
+ }
+ }
+}
+
+func syncColorScheme(mode ColorMode) {
+ scheme := "prefer-dark"
+ if mode == ColorModeLight {
+ scheme = "default"
+ }
+
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "color-scheme", scheme); err != nil {
+ log.Warnf("Failed to sync color-scheme: %v", err)
+ }
+}
+
+var adwaitaAccents = []struct {
+ name string
+ colors []colorful.Color
+}{
+ {"blue", hexColors("#3f8ae5", "#438de6", "#a4caee")},
+ {"green", hexColors("#26a269", "#39ac76", "#81d5ad")},
+ {"orange", hexColors("#f17738", "#ff7800", "#ffc994")},
+ {"pink", hexColors("#e4358a", "#e64392", "#f9b3d5")},
+ {"purple", hexColors("#954ab5", "#9c46b9", "#d099d6")},
+ {"red", hexColors("#e84053", "#e01b24", "#f2a1a5")},
+ {"slate", hexColors("#557b9f", "#6a8daf", "#b4c6d6")},
+ {"teal", hexColors("#129eb0", "#2190a4", "#7bdff4")},
+ {"yellow", hexColors("#cbac10", "#d4b411", "#f5c211")},
+}
+
+func hexColors(hexes ...string) []colorful.Color {
+ out := make([]colorful.Color, len(hexes))
+ for i, h := range hexes {
+ out[i], _ = colorful.Hex(h)
+ }
+ return out
+}
+
+func closestAdwaitaAccent(primaryHex string) string {
+ c, err := colorful.Hex(primaryHex)
+ if err != nil {
+ return "blue"
+ }
+
+ best := "blue"
+ bestDist := math.MaxFloat64
+ for _, a := range adwaitaAccents {
+ for _, ref := range a.colors {
+ d := c.DistanceCIEDE2000(ref)
+ if d < bestDist {
+ bestDist = d
+ best = a.name
+ }
+ }
+ }
+ return best
+}
+
+func syncAccentColor(primaryHex string) {
+ accent := closestAdwaitaAccent(primaryHex)
+ log.Infof("Setting GNOME accent color: %s", accent)
+ if err := utils.GsettingsSet("org.gnome.desktop.interface", "accent-color", accent); err != nil {
+ log.Warnf("Failed to set accent-color: %v", err)
+ }
+}
+
+type TemplateCheck struct {
+ ID string `json:"id"`
+ Detected bool `json:"detected"`
+}
+
+func CheckTemplates(checker utils.AppChecker) []TemplateCheck {
+ if checker == nil {
+ checker = utils.DefaultAppChecker{}
+ }
+
+ homeDir, _ := os.UserHomeDir()
+ checks := make([]TemplateCheck, 0, len(templateRegistry))
+
+ for _, tmpl := range templateRegistry {
+ detected := false
+
+ switch {
+ case tmpl.RunUnconditionally:
+ detected = true
+ case tmpl.Kind == TemplateKindVSCode:
+ detected = checkVSCodeExtension(homeDir)
+ case tmpl.Kind == TemplateKindEmacs:
+ detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks) && utils.EmacsConfigDir() != ""
+ default:
+ detected = appExists(checker, tmpl.Commands, tmpl.Flatpaks)
+ }
+
+ checks = append(checks, TemplateCheck{ID: tmpl.ID, Detected: detected})
+ }
+
+ return checks
+}
+
+func checkVSCodeExtension(homeDir string) bool {
+ extDirs := []string{
+ filepath.Join(homeDir, ".vscode/extensions"),
+ filepath.Join(homeDir, ".vscode-oss/extensions"),
+ filepath.Join(homeDir, ".config/Code - OSS/extensions"),
+ filepath.Join(homeDir, ".cursor/extensions"),
+ filepath.Join(homeDir, ".windsurf/extensions"),
+ }
+
+ for _, extDir := range extDirs {
+ pattern := filepath.Join(extDir, "danklinux.dms-theme-*")
+ if matches, err := filepath.Glob(pattern); err == nil && len(matches) > 0 {
+ return true
+ }
+ }
+ return false
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go
new file mode 100644
index 0000000..48880df
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/matugen_test.go
@@ -0,0 +1,443 @@
+package matugen
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ mocks_utils "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/utils"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAppendConfigBinaryExists(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "test config content"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ mockChecker := mocks_utils.NewMockAppChecker(t)
+ mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
+
+ opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
+
+ appendConfig(opts, cfgFile, []string{"sh"}, nil, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) == 0 {
+ t.Errorf("expected config to be written when binary exists")
+ }
+ if string(output) != testConfig+"\n" {
+ t.Errorf("expected %q, got %q", testConfig+"\n", string(output))
+ }
+}
+
+func TestAppendConfigBinaryDoesNotExist(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "test config content"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ mockChecker := mocks_utils.NewMockAppChecker(t)
+ mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
+ mockChecker.EXPECT().AnyFlatpakExists().Return(false)
+
+ opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
+
+ appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{}, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) != 0 {
+ t.Errorf("expected no config when binary doesn't exist, got: %q", string(output))
+ }
+}
+
+func TestAppendConfigFlatpakExists(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "zen config content"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ mockChecker := mocks_utils.NewMockAppChecker(t)
+ mockChecker.EXPECT().AnyFlatpakExists("app.zen_browser.zen").Return(true)
+
+ opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
+
+ appendConfig(opts, cfgFile, nil, []string{"app.zen_browser.zen"}, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) == 0 {
+ t.Errorf("expected config to be written when flatpak exists")
+ }
+}
+
+func TestAppendConfigFlatpakDoesNotExist(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "test config content"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ mockChecker := mocks_utils.NewMockAppChecker(t)
+ mockChecker.EXPECT().AnyCommandExists().Return(false)
+ mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
+
+ opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
+
+ appendConfig(opts, cfgFile, []string{}, []string{"com.nonexistent.flatpak"}, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) != 0 {
+ t.Errorf("expected no config when flatpak doesn't exist, got: %q", string(output))
+ }
+}
+
+func TestAppendConfigBothExist(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "zen config content"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ mockChecker := mocks_utils.NewMockAppChecker(t)
+ mockChecker.EXPECT().AnyCommandExists("sh").Return(true)
+
+ opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
+
+ appendConfig(opts, cfgFile, []string{"sh"}, []string{"app.zen_browser.zen"}, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) == 0 {
+ t.Errorf("expected config to be written when both binary and flatpak exist")
+ }
+}
+
+func TestAppendConfigNeitherExists(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "test config content"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ mockChecker := mocks_utils.NewMockAppChecker(t)
+ mockChecker.EXPECT().AnyCommandExists("nonexistent-binary-12345").Return(false)
+ mockChecker.EXPECT().AnyFlatpakExists("com.nonexistent.flatpak").Return(false)
+
+ opts := &Options{ShellDir: shellDir, AppChecker: mockChecker}
+
+ appendConfig(opts, cfgFile, []string{"nonexistent-binary-12345"}, []string{"com.nonexistent.flatpak"}, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) != 0 {
+ t.Errorf("expected no config when neither exists, got: %q", string(output))
+ }
+}
+
+func TestAppendConfigNoChecks(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ testConfig := "always include"
+ configPath := filepath.Join(configsDir, "test.toml")
+ if err := os.WriteFile(configPath, []byte(testConfig), 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ opts := &Options{ShellDir: shellDir}
+
+ appendConfig(opts, cfgFile, nil, nil, "test.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) == 0 {
+ t.Errorf("expected config to be written when no checks specified")
+ }
+}
+
+func TestAppendConfigFileDoesNotExist(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ outFile := filepath.Join(tempDir, "output.toml")
+ cfgFile, err := os.Create(outFile)
+ if err != nil {
+ t.Fatalf("failed to create output file: %v", err)
+ }
+ defer cfgFile.Close()
+
+ opts := &Options{ShellDir: shellDir}
+
+ appendConfig(opts, cfgFile, nil, nil, "nonexistent.toml")
+
+ cfgFile.Close()
+ output, err := os.ReadFile(outFile)
+ if err != nil {
+ t.Fatalf("failed to read output: %v", err)
+ }
+
+ if len(output) != 0 {
+ t.Errorf("expected no config when file doesn't exist, got: %q", string(output))
+ }
+}
+
+func TestSubstituteVars(t *testing.T) {
+ configDir := utils.XDGConfigHome()
+ dataDir := utils.XDGDataHome()
+ cacheDir := utils.XDGCacheHome()
+
+ tests := []struct {
+ name string
+ input string
+ shellDir string
+ expected string
+ }{
+ {
+ name: "substitutes SHELL_DIR",
+ input: "input_path = 'SHELL_DIR/matugen/templates/foo.conf'",
+ shellDir: "/home/user/shell",
+ expected: "input_path = '/home/user/shell/matugen/templates/foo.conf'",
+ },
+ {
+ name: "substitutes CONFIG_DIR",
+ input: "output_path = 'CONFIG_DIR/kitty/theme.conf'",
+ shellDir: "/home/user/shell",
+ expected: "output_path = '" + configDir + "/kitty/theme.conf'",
+ },
+ {
+ name: "substitutes DATA_DIR",
+ input: "output_path = 'DATA_DIR/color-schemes/theme.colors'",
+ shellDir: "/home/user/shell",
+ expected: "output_path = '" + dataDir + "/color-schemes/theme.colors'",
+ },
+ {
+ name: "substitutes CACHE_DIR",
+ input: "output_path = 'CACHE_DIR/wal/colors.json'",
+ shellDir: "/home/user/shell",
+ expected: "output_path = '" + cacheDir + "/wal/colors.json'",
+ },
+ {
+ name: "substitutes all dir types",
+ input: "'SHELL_DIR/a' 'CONFIG_DIR/b' 'DATA_DIR/c' 'CACHE_DIR/d'",
+ shellDir: "/shell",
+ expected: "'/shell/a' '" + configDir + "/b' '" + dataDir + "/c' '" + cacheDir + "/d'",
+ },
+ {
+ name: "no substitution when no placeholders",
+ input: "input_path = '/absolute/path/foo.conf'",
+ shellDir: "/home/user/shell",
+ expected: "input_path = '/absolute/path/foo.conf'",
+ },
+ {
+ name: "multiple SHELL_DIR occurrences",
+ input: "'SHELL_DIR/a' and 'SHELL_DIR/b'",
+ shellDir: "/shell",
+ expected: "'/shell/a' and '/shell/b'",
+ },
+ {
+ name: "only substitutes quoted paths",
+ input: "SHELL_DIR/unquoted and 'SHELL_DIR/quoted'",
+ shellDir: "/shell",
+ expected: "SHELL_DIR/unquoted and '/shell/quoted'",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ result := substituteVars(tc.input, tc.shellDir)
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestBuildMergedConfigColorsOnly(t *testing.T) {
+ tempDir := t.TempDir()
+
+ shellDir := filepath.Join(tempDir, "shell")
+ configsDir := filepath.Join(shellDir, "matugen", "configs")
+ if err := os.MkdirAll(configsDir, 0o755); err != nil {
+ t.Fatalf("failed to create configs dir: %v", err)
+ }
+
+ baseConfig := "[config]\ncustom_keywords = []\n"
+ if err := os.WriteFile(filepath.Join(configsDir, "base.toml"), []byte(baseConfig), 0o644); err != nil {
+ t.Fatalf("failed to write base config: %v", err)
+ }
+
+ cfgFile, err := os.CreateTemp(tempDir, "merged-*.toml")
+ if err != nil {
+ t.Fatalf("failed to create temp config: %v", err)
+ }
+ defer os.Remove(cfgFile.Name())
+ defer cfgFile.Close()
+
+ opts := &Options{
+ ShellDir: shellDir,
+ ConfigDir: filepath.Join(tempDir, "config"),
+ StateDir: filepath.Join(tempDir, "state"),
+ ColorsOnly: true,
+ }
+
+ if err := buildMergedConfig(opts, cfgFile, filepath.Join(tempDir, "templates")); err != nil {
+ t.Fatalf("buildMergedConfig failed: %v", err)
+ }
+
+ if err := cfgFile.Close(); err != nil {
+ t.Fatalf("failed to close merged config: %v", err)
+ }
+
+ output, err := os.ReadFile(cfgFile.Name())
+ if err != nil {
+ t.Fatalf("failed to read merged config: %v", err)
+ }
+
+ content := string(output)
+ assert.Contains(t, content, "[templates.dank]")
+ assert.Contains(t, content, "output_path = '"+filepath.Join(opts.StateDir, "dms-colors.json")+"'")
+ assert.NotContains(t, content, "[templates.gtk]")
+ assert.False(t, strings.Contains(content, "output_path = 'CONFIG_DIR/"), "colors-only config should not emit app template outputs")
+}
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/queue.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/queue.go
new file mode 100644
index 0000000..38cf72a
--- /dev/null
+++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen/queue.go
@@ -0,0 +1,143 @@
+package matugen
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+)
+
+type Result struct {
+ Success bool
+ Error error
+}
+
+type QueuedJob struct {
+ Options Options
+ Done chan Result
+ Ctx context.Context
+ Cancel context.CancelFunc
+}
+
+type Queue struct {
+ mu sync.Mutex
+ current *QueuedJob
+ pending *QueuedJob
+ jobDone chan struct{}
+}
+
+var globalQueue *Queue
+var queueOnce sync.Once
+
+func GetQueue() *Queue {
+ queueOnce.Do(func() {
+ globalQueue = &Queue{
+ jobDone: make(chan struct{}, 1),
+ }
+ })
+ return globalQueue
+}
+
+func (q *Queue) Submit(opts Options) <-chan Result {
+ result := make(chan Result, 1)
+ ctx, cancel := context.WithCancel(context.Background())
+
+ job := &QueuedJob{
+ Options: opts,
+ Done: result,
+ Ctx: ctx,
+ Cancel: cancel,
+ }
+
+ q.mu.Lock()
+
+ if q.pending != nil {
+ log.Info("Cancelling pending theme request")
+ q.pending.Cancel()
+ q.pending.Done <- Result{Success: false, Error: context.Canceled}
+ close(q.pending.Done)
+ }
+
+ if q.current != nil {
+ q.pending = job
+ q.mu.Unlock()
+ log.Info("Theme request queued (worker running)")
+ return result
+ }
+
+ q.current = job
+ q.mu.Unlock()
+
+ go q.runWorker()
+ return result
+}
+
+func (q *Queue) runWorker() {
+ for {
+ q.mu.Lock()
+ job := q.current
+ if job == nil {
+ q.mu.Unlock()
+ return
+ }
+ q.mu.Unlock()
+
+ select {
+ case <-job.Ctx.Done():
+ q.finishJob(Result{Success: false, Error: context.Canceled})
+ continue
+ default:
+ }
+
+ log.Infof("Processing theme: %s %s (%s)", job.Options.Kind, job.Options.Value, job.Options.Mode)
+ err := Run(job.Options)
+
+ var result Result
+ switch {
+ case err == nil:
+ result = Result{Success: true}
+ case errors.Is(err, ErrNoChanges):
+ result = Result{Success: true}
+ default:
+ result = Result{Success: false, Error: err}
+ }
+
+ q.finishJob(result)
+ }
+}
+
+func (q *Queue) finishJob(result Result) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if q.current != nil {
+ select {
+ case q.current.Done <- result:
+ default:
+ }
+ close(q.current.Done)
+ }
+
+ q.current = q.pending
+ q.pending = nil
+
+ if q.current == nil {
+ select {
+ case q.jobDone <- struct{}{}:
+ default:
+ }
+ }
+}
+
+func (q *Queue) IsRunning() bool {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ return q.current != nil
+}
+
+func (q *Queue) HasPending() bool {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ return q.pending != nil
+}