1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 21:02:06 -04:00
Files
DankMaterialShell/core/internal/matugen/matugen.go
2026-03-19 14:44:14 -04:00

979 lines
27 KiB
Go

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
}