diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/matugen')
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 +} |