feat(theme): terminal background opacity and wallpaper-tinted colors (#3662)

This commit is contained in:
Marlon Kranz
2026-10-15 11:17:29 -04:00
committed by GitHub
parent 6b40abe4b9
commit 549cbf6d08
17 changed files with 562 additions and 77 deletions
+7
View File
@@ -28,9 +28,13 @@ func init() {
dank16Cmd.Flags().Bool("wezterm", false, "Output in Wezterm terminal format")
dank16Cmd.Flags().String("background", "", "Custom background color")
dank16Cmd.Flags().String("contrast", "dps", "Contrast algorithm: dps (Delta Phi Star, default) or wcag")
dank16Cmd.Flags().String("profile", "default", "Palette: default, low, medium or high")
dank16Cmd.Flags().Bool("variants", false, "Output all variants (dark/light/default) in JSON")
dank16Cmd.Flags().String("primary-dark", "", "Primary color for dark mode (use with --variants)")
dank16Cmd.Flags().String("primary-light", "", "Primary color for light mode (use with --variants)")
_ = dank16Cmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"default", "low", "medium", "high"}, cobra.ShellCompDirectiveNoFileComp
})
_ = dank16Cmd.RegisterFlagCompletionFunc("contrast", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"dps", "wcag"}, cobra.ShellCompDirectiveNoFileComp
})
@@ -47,6 +51,7 @@ func runDank16(cmd *cobra.Command, args []string) {
isWezterm, _ := cmd.Flags().GetBool("wezterm")
background, _ := cmd.Flags().GetString("background")
contrastAlgo, _ := cmd.Flags().GetString("contrast")
profile, _ := cmd.Flags().GetString("profile")
useVariants, _ := cmd.Flags().GetBool("variants")
primaryDark, _ := cmd.Flags().GetString("primary-dark")
primaryLight, _ := cmd.Flags().GetString("primary-light")
@@ -84,6 +89,7 @@ func runDank16(cmd *cobra.Command, args []string) {
BackgroundDark: background,
UseDPS: contrastAlgo == "dps",
IsLightMode: isLight,
Profile: profile,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
fmt.Print(dank16.GenerateVariantJSON(variantColors))
@@ -102,6 +108,7 @@ func runDank16(cmd *cobra.Command, args []string) {
IsLight: isLight,
Background: background,
UseDPS: contrastAlgo == "dps",
Profile: profile,
}
colors := dank16.GeneratePalette(primaryColor, opts)
+8
View File
@@ -70,6 +70,8 @@ func init() {
cmd.Flags().String("stock-colors", "", "Stock theme colors JSON")
cmd.Flags().Bool("sync-mode-with-portal", false, "Sync color scheme with GNOME portal")
cmd.Flags().Bool("terminals-always-dark", false, "Force terminal themes to dark variant")
cmd.Flags().Int("terminal-opacity", 0, "Terminal background opacity percent, 1-99 (0 or 100 leaves the terminal's own setting)")
cmd.Flags().String("terminal-palette", "", "Terminal palette: default, low, medium or high")
cmd.Flags().String("skip-templates", "", "Comma-separated list of templates to skip")
cmd.Flags().Float64("contrast", 0, "Contrast value from -1 to 1 (0 = standard)")
cmd.Flags().String("source-mode", "", "Source color selection: dominant, colorful, darkness, lightness, saturation, less-saturation, value")
@@ -100,6 +102,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
stockColors, _ := cmd.Flags().GetString("stock-colors")
syncModeWithPortal, _ := cmd.Flags().GetBool("sync-mode-with-portal")
terminalsAlwaysDark, _ := cmd.Flags().GetBool("terminals-always-dark")
terminalOpacity, _ := cmd.Flags().GetInt("terminal-opacity")
terminalPalette, _ := cmd.Flags().GetString("terminal-palette")
skipTemplates, _ := cmd.Flags().GetString("skip-templates")
contrast, _ := cmd.Flags().GetFloat64("contrast")
sourceMode, _ := cmd.Flags().GetString("source-mode")
@@ -120,6 +124,8 @@ func buildMatugenOptions(cmd *cobra.Command) matugen.Options {
StockColors: stockColors,
SyncModeWithPortal: syncModeWithPortal,
TerminalsAlwaysDark: terminalsAlwaysDark,
TerminalOpacity: terminalOpacity,
TerminalPalette: terminalPalette,
SkipTemplates: skipTemplates,
SourceMode: sourceMode,
SeedColor: seedColor,
@@ -159,6 +165,8 @@ func runMatugenQueue(cmd *cobra.Command, args []string) {
"stockColors": opts.StockColors,
"syncModeWithPortal": opts.SyncModeWithPortal,
"terminalsAlwaysDark": opts.TerminalsAlwaysDark,
"terminalOpacity": opts.TerminalOpacity,
"terminalPalette": opts.TerminalPalette,
"skipTemplates": opts.SkipTemplates,
"contrast": opts.Contrast,
"sourceMode": opts.SourceMode,
+61 -2
View File
@@ -4,7 +4,10 @@ import (
"fmt"
"math"
"strconv"
"strings"
"github.com/AvengeMedia/dankgo/material/color"
"github.com/AvengeMedia/dankgo/material/num"
"github.com/lucasb-eyer/go-colorful"
)
@@ -452,11 +455,64 @@ func EnsureContrastDPSBidirectional(hexColor, hexBg string, minLc float64, isLig
return hexColor
}
const (
ProfileLow = "low"
ProfileMedium = "medium"
ProfileHigh = "high"
)
var profileStrength = map[string]float64{ProfileLow: 0.25, ProfileMedium: 0.5, ProfileHigh: 0.85}
type PaletteOptions struct {
IsLight bool
Background string
Container string
UseDPS bool
Profile string
}
func rotateTowardPrimary(hex string, primary color.Hct, strength float64) string {
argb, err := color.ARGBFromHex(hex)
if err != nil {
return hex
}
c := argb.ToHct()
rot := math.Min(num.DifferenceDegrees(c.Hue, primary.Hue)*0.8, 100) * strength
hue := num.NormalizeDegree(c.Hue + rot*num.RotationDirection(c.Hue, primary.Hue))
return strings.ToLower(color.NewHct(hue, c.Chroma, c.Tone).ToARGB().HexRGB())
}
func applyProfile(p *Palette, primaryColor, bg string, normalTarget, accentTarget float64, opts PaletteOptions) {
strength := profileStrength[opts.Profile]
if strength == 0 {
return
}
argb, err := color.ARGBFromHex(primaryColor)
if err != nil {
return
}
primary := argb.ToHct()
// A near-grey primary has no usable hue to rotate toward.
if primary.Chroma < 5 {
return
}
normal := func(h string) string { return ensureContrastAuto(h, bg, normalTarget, opts) }
accent := func(h string) string { return ensureContrastBidirectional(h, bg, accentTarget, opts) }
none := func(h string) string { return h }
// Same per-slot contrast guards GeneratePalette applies.
slots := []struct {
c *ColorInfo
guard func(string) string
}{
{&p.Color1, normal}, {&p.Color2, normal}, {&p.Color3, normal}, {&p.Color4, normal},
{&p.Color9, accent}, {&p.Color10, accent}, {&p.Color11, accent}, {&p.Color12, accent}, {&p.Color13, none}, {&p.Color14, none},
}
if opts.IsLight {
slots[8].guard = accent
}
for _, s := range slots {
*s.c = NewColorInfo(s.guard(rotateTowardPrimary(s.c.Hex, primary, strength)))
}
}
func ensureContrastAuto(hexColor, hexBg string, target float64, opts PaletteOptions) string {
@@ -690,6 +746,8 @@ func GeneratePalette(primaryColor string, opts PaletteOptions) Palette {
palette.Color15 = NewColorInfo(ensureContrastAuto(RGBToHex(HSVToRGB(HSV{H: hsv.H, S: white15S, V: white15V})), bgColor, normalTextTarget, opts))
}
applyProfile(&palette, primaryColor, bgColor, normalTextTarget, accentTarget, opts)
return palette
}
@@ -702,6 +760,7 @@ type VariantOptions struct {
ContainerLight string
UseDPS bool
IsLightMode bool
Profile string
}
func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
@@ -718,8 +777,8 @@ func mergeColorInfo(dark, light ColorInfo, isLightMode bool) VariantColorInfo {
}
func GenerateVariantPalette(opts VariantOptions) VariantPalette {
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS}
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS}
darkOpts := PaletteOptions{IsLight: false, Background: opts.BackgroundDark, Container: opts.ContainerDark, UseDPS: opts.UseDPS, Profile: opts.Profile}
lightOpts := PaletteOptions{IsLight: true, Background: opts.BackgroundLight, Container: opts.ContainerLight, UseDPS: opts.UseDPS, Profile: opts.Profile}
dark := GeneratePalette(opts.PrimaryDark, darkOpts)
light := GeneratePalette(opts.PrimaryLight, lightOpts)
+140
View File
@@ -4,7 +4,11 @@ import (
"bytes"
"encoding/json"
"math"
"strings"
"testing"
"github.com/AvengeMedia/dankgo/material/color"
"github.com/AvengeMedia/dankgo/material/num"
)
func TestHexToRGB(t *testing.T) {
@@ -786,3 +790,139 @@ func TestGeneratePaletteFallsBackToDerivedContainer(t *testing.T) {
t.Errorf("Color5 = %s with no container supplied, expected the derived %s", palette.Color5.Hex, want)
}
}
func paletteHexes(p Palette) [16]string {
return [16]string{
p.Color0.Hex, p.Color1.Hex, p.Color2.Hex, p.Color3.Hex, p.Color4.Hex, p.Color5.Hex, p.Color6.Hex, p.Color7.Hex,
p.Color8.Hex, p.Color9.Hex, p.Color10.Hex, p.Color11.Hex, p.Color12.Hex, p.Color13.Hex, p.Color14.Hex, p.Color15.Hex,
}
}
func hctHue(hex string) float64 {
return color.ARGBFromHexMust(hex).ToHct().Hue
}
func TestDefaultProfileUnchanged(t *testing.T) {
tests := []struct {
name string
light bool
want [16]string
}{
{"dark", false, [16]string{"#1a1a1a", "#ff72a0", "#77ee84", "#fff672", "#70bfe2", "#19546e", "#8ad0ee", "#eaf5f9", "#444b4d", "#ff9fbe", "#a5ffaf", "#fff9a5", "#a3e3ff", "#b3e8ff", "#cef0ff", "#f8fdff"}},
{"light", true, [16]string{"#f8f8f8", "#c23c6c", "#00811d", "#727300", "#1a7895", "#dcf4ff", "#8ad0ee", "#3f4142", "#b8bbbc", "#dc608a", "#009f29", "#8f8e00", "#3993b4", "#5c90a6", "#c9eeff", "#f9fdff"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, profile := range []string{"", "default", "bogus"} {
got := paletteHexes(GeneratePalette("#8ad0ee", PaletteOptions{UseDPS: true, IsLight: tt.light, Profile: profile}))
if got != tt.want {
t.Errorf("profile %q drifted:\n got %v\nwant %v", profile, got, tt.want)
}
}
})
}
}
func TestProfileGoldens(t *testing.T) {
tests := []struct {
profile string
light bool
want string
}{
{ProfileLow, false, "#1a1a1a #ea77d9 #31f2a9 #d6ffa0 #70bfe2 #19546e #8ad0ee #eaf5f9 #444b4d #eca4e1 #98ffce #e4ffbf #a3e3ff #b3e8ff #cef0ff #f8fdff"},
{ProfileMedium, false, "#1a1a1a #c684ff #00f0ce #c8ffd2 #6fbfe1 #19546e #8ad0ee #eaf5f9 #444b4d #d2abf8 #89ffe6 #daffdf #a3e3ff #b4e8ff #cff0ff #f8fdff"},
{ProfileHigh, false, "#1a1a1a #879bff #00ecf6 #b8fff6 #6fbfe1 #19546e #8ad0ee #eaf5f9 #444b4d #a7b8ff #a1f9ff #d0fff8 #a3e3ff #b4e8ff #cff0ff #f8fdff"},
{ProfileLow, true, "#f8f8f8 #b240a4 #007f51 #507a2b #1a7895 #dcf4ff #8ad0ee #3f4142 #b8bbbc #c964bb #009d65 #669737 #3993b4 #5c90a6 #c9eeff #f9fdff"},
{ProfileMedium, true, "#f8f8f8 #944bcf #007d68 #1d7f4c #1a7895 #dcf4ff #8ad0ee #3f4142 #b8bbbc #ab6fe0 #009b82 #2a9c5f #3993b4 #5c90a6 #c9eeff #f9fdff"},
{ProfileHigh, true, "#f8f8f8 #4562e9 #007b7f #007d76 #1b7895 #dcf4ff #8ad0ee #3f4142 #b8bbbc #6882f3 #00989d #009a91 #3993b4 #5c90a6 #caeeff #f9fdff"},
}
for _, tt := range tests {
got := paletteHexes(GeneratePalette("#8ad0ee", PaletteOptions{UseDPS: true, IsLight: tt.light, Profile: tt.profile}))
if s := strings.Join(got[:], " "); s != tt.want {
t.Errorf("%s light=%v:\n got %s\nwant %s", tt.profile, tt.light, s, tt.want)
}
}
}
func TestProfileMalformedPrimaryDoesNotPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("panicked: %v", r)
}
}()
GeneratePalette("not-a-color", PaletteOptions{UseDPS: true, Profile: ProfileHigh})
GeneratePalette("#12345", PaletteOptions{UseDPS: true, Profile: ProfileHigh})
GeneratePalette("#8ad0ee", PaletteOptions{UseDPS: true, Container: "steelblue", Profile: ProfileLow})
GeneratePalette("#8ad0ee", PaletteOptions{UseDPS: true, Container: "#12345", Profile: ProfileLow})
}
var profiles = []string{ProfileLow, ProfileMedium, ProfileHigh}
func TestProfileInvariants(t *testing.T) {
for _, primary := range []string{"#e91e63", "#f59e0b", "#22c55e", "#06b6d4", "#8b5cf6", "#8ad0ee"} {
for _, light := range []bool{false, true} {
def := PaletteOptions{UseDPS: true, IsLight: light}
dh := paletteHexes(GeneratePalette(primary, def))
for _, profile := range profiles {
opts := def
opts.Profile = profile
hh := paletteHexes(GeneratePalette(primary, opts))
for _, i := range []int{0, 5, 6, 7, 8, 15} {
if dh[i] != hh[i] {
t.Errorf("%s %s light=%v slot %d: %s != default %s", primary, profile, light, i, hh[i], dh[i])
}
}
for i := 1; i <= 4; i++ {
if lc := DeltaPhiStarContrast(hh[i], hh[0], light); lc < 30 {
t.Errorf("%s %s light=%v slot %d Lc %.1f < 30", primary, profile, light, i, lc)
}
}
}
}
}
}
func TestProfileHueApproachesPrimaryWithStrength(t *testing.T) {
const primary = "#1e66f5"
ph := hctHue(primary)
for _, light := range []bool{false, true} {
prev := math.Inf(1)
for _, profile := range append([]string{""}, profiles...) {
red := GeneratePalette(primary, PaletteOptions{UseDPS: true, IsLight: light, Profile: profile}).Color1.Hex
d := num.DifferenceDegrees(hctHue(red), ph)
if d >= prev {
t.Errorf("light=%v %q: red hue distance %.1f not below previous %.1f", light, profile, d, prev)
}
prev = d
}
}
}
func TestHighRotatesByCappedAmount(t *testing.T) {
def := hctHue(GeneratePalette("#8ad0ee", PaletteOptions{UseDPS: true}).Color1.Hex)
got := hctHue(GeneratePalette("#8ad0ee", PaletteOptions{UseDPS: true, Profile: ProfileHigh}).Color1.Hex)
if d := num.DifferenceDegrees(def, got); math.Abs(d-85) > 3 {
t.Errorf("slot 1 rotated %.1f degrees, want 85 (the 100 degree cap at high strength)", d)
}
}
func TestProfileGreyPrimaryKeepsDefault(t *testing.T) {
def := paletteHexes(GeneratePalette("#808080", PaletteOptions{UseDPS: true}))
high := paletteHexes(GeneratePalette("#808080", PaletteOptions{UseDPS: true, Profile: ProfileHigh}))
if def != high {
t.Errorf("grey primary changed by high profile:\n got %v\nwant %v", high, def)
}
}
func TestGenerateVariantPaletteProfile(t *testing.T) {
opts := VariantOptions{PrimaryDark: "#8ad0ee", PrimaryLight: "#1c5070", UseDPS: true}
def := GenerateVariantPalette(opts)
opts.Profile = ProfileHigh
high := GenerateVariantPalette(opts)
if def.Color1.Dark.Hex == high.Color1.Dark.Hex {
t.Error("dark color1 unchanged by high profile")
}
if def.Color1.Light.Hex == high.Color1.Light.Hex {
t.Error("light color1 unchanged by high profile")
}
}
+45 -9
View File
@@ -136,6 +136,8 @@ type Options struct {
StockColors string
SyncModeWithPortal bool
TerminalsAlwaysDark bool
TerminalOpacity int
TerminalPalette string
SkipTemplates string
AppChecker utils.AppChecker
}
@@ -441,8 +443,9 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s}`, opts.StockColors, dank16JSON)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode, "")
dank16TermJSON := generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode, opts.TerminalPalette)
importData := fmt.Sprintf(`{"colors": %s, "dank16": %s, "dank16term": %s}`, opts.StockColors, dank16JSON, dank16TermJSON)
importArgs = []string{"--import-json-string", importData}
log.Info("Running matugen color hex with stock color overrides")
@@ -510,9 +513,10 @@ func buildOnce(opts *Options) (bool, error) {
primaryLight = primaryDark
}
injections := InjectedPalettes(opts.ConfigDir, sourceImage, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode)
dank16JSON = generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode, "")
// The terminal palette only reaches the terminal templates; editor themes and user templates keep dank16.
dank16Term := paletteInjection{Namespace: "dank16term", JSON: generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight, opts.Mode, opts.TerminalPalette)}
injections := append([]paletteInjection{dank16Term}, InjectedPalettes(opts.ConfigDir, sourceImage, opts.Mode)...)
importArgs = []string{"--import-json-string", buildImportData(dank16JSON, sourceImage, specColors, injections)}
log.Infof("Running matugen %s with dank16 injection", opts.Kind)
@@ -537,6 +541,7 @@ func buildOnce(opts *Options) (bool, error) {
if err := renderTemplates(opts, args, tmpDir); err != nil {
return false, err
}
signalTerminals(opts)
if !changed {
return false, nil
}
@@ -569,7 +574,6 @@ func buildOnce(opts *Options) (bool, error) {
}
}
signalTerminals(opts)
if !opts.ShouldSkipTemplate("fcitx5") && appExists(opts.AppChecker, []string{"fcitx5"}, nil) {
refreshFcitx5()
}
@@ -836,7 +840,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
content := string(data)
if !opts.TerminalsAlwaysDark {
if !opts.TerminalsAlwaysDark && !terminalOpacityManaged(opts) {
cfgFile.WriteString(substituteVars(content, opts.ShellDir))
cfgFile.WriteString("\n")
return
@@ -864,7 +868,7 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
continue
}
modified := strings.ReplaceAll(string(origData), ".default.", ".dark.")
modified := applyTerminalTemplate(templateName, string(origData), opts)
tmpPath := filepath.Join(tmpDir, templateName)
if err := os.WriteFile(tmpPath, []byte(modified), 0o644); err != nil {
continue
@@ -879,6 +883,37 @@ func appendTerminalConfig(opts *Options, cfgFile *os.File, tmpDir string, checkC
cfgFile.WriteString("\n")
}
func terminalOpacityManaged(opts *Options) bool {
return opts.TerminalOpacity >= 1 && opts.TerminalOpacity <= 99
}
func applyTerminalTemplate(name, content string, opts *Options) string {
if opts.TerminalsAlwaysDark {
content = strings.ReplaceAll(content, ".default.", ".dark.")
}
if !terminalOpacityManaged(opts) {
return content
}
v := strconv.FormatFloat(float64(opts.TerminalOpacity)/100, 'f', -1, 64)
var snippet string
switch name {
case "kitty.conf":
snippet = "background_opacity " + v + "\ndynamic_background_opacity yes\n"
case "foot.ini":
snippet = "alpha=" + v + "\n"
case "ghostty.conf":
snippet = "background-opacity = " + v + "\n"
case "alacritty.toml":
snippet = "[window]\nopacity = " + v + "\n"
default:
return content
}
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content + snippet
}
func templateSessionActive(tmpl TemplateDef) bool {
if tmpl.RequiredEnv == "" {
return true
@@ -1331,7 +1366,7 @@ func extractNestedColor(jsonStr, colorName, variant string) string {
return color
}
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode) string {
func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight, containerDark, containerLight string, mode ColorMode, profile string) string {
variantOpts := dank16.VariantOptions{
PrimaryDark: primaryDark,
PrimaryLight: primaryLight,
@@ -1341,6 +1376,7 @@ func generateDank16Variants(primaryDark, primaryLight, surfaceDark, surfaceLight
ContainerLight: containerLight,
UseDPS: true,
IsLightMode: mode == ColorModeLight,
Profile: profile,
}
variantColors := dank16.GenerateVariantPalette(variantOpts)
return dank16.GenerateVariantJSON(variantColors)
+126
View File
@@ -9,6 +9,7 @@ import (
"math"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
@@ -1159,3 +1160,128 @@ func TestWriteDryRunConfigAlwaysDeclaresConfigAndTemplates(t *testing.T) {
})
}
}
func TestApplyTerminalTemplate(t *testing.T) {
opts := func(v int, dark bool) *Options { return &Options{TerminalOpacity: v, TerminalsAlwaysDark: dark} }
tests := []struct {
name, tmpl, in, want string
opts *Options
}{
{"kitty", "kitty.conf", "a\n", "a\nbackground_opacity 0.85\ndynamic_background_opacity yes\n", opts(85, false)},
{"foot", "foot.ini", "a\n", "a\nalpha=0.85\n", opts(85, false)},
{"ghostty", "ghostty.conf", "a\n", "a\nbackground-opacity = 0.85\n", opts(85, false)},
{"alacritty", "alacritty.toml", "a\n", "a\n[window]\nopacity = 0.85\n", opts(85, false)},
{"no trailing newline", "foot.ini", "a", "a\nalpha=0.5\n", opts(50, false)},
{"100", "kitty.conf", "a\n", "a\n", opts(100, false)},
{"0", "kitty.conf", "a\n", "a\n", opts(0, false)},
{"negative", "kitty.conf", "a\n", "a\n", opts(-5, false)},
{"tabs", "kitty-tabs.conf", "a\n", "a\n", opts(85, false)},
{"wezterm", "wezterm.toml", "a\n", "a\n", opts(85, false)},
{"dark and opacity", "kitty.conf", "x.default.y\n", "x.dark.y\nbackground_opacity 0.85\ndynamic_background_opacity yes\n", opts(85, true)},
{"opacity only keeps default", "kitty.conf", "x.default.y\n", "x.default.y\nbackground_opacity 0.85\ndynamic_background_opacity yes\n", opts(85, false)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, applyTerminalTemplate(tt.tmpl, tt.in, tt.opts))
})
}
}
func TestAppendTerminalConfigOpacity(t *testing.T) {
tempDir := t.TempDir()
shellDir := filepath.Join(tempDir, "shell")
cfgDir := filepath.Join(shellDir, "matugen", "configs")
tmplDir := filepath.Join(shellDir, "matugen", "templates")
for _, d := range []string{cfgDir, tmplDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
toml := "[templates.dmskitty]\ninput_path = 'SHELL_DIR/matugen/templates/kitty.conf'\noutput_path = 'out/kitty'\n\n" +
"[templates.dmskittytabs]\ninput_path = 'SHELL_DIR/matugen/templates/kitty-tabs.conf'\noutput_path = 'out/tabs'\n"
for name, c := range map[string]string{
filepath.Join(cfgDir, "kitty.toml"): toml,
filepath.Join(tmplDir, "kitty.conf"): "color0 {{x}}\n",
filepath.Join(tmplDir, "kitty-tabs.conf"): "tab {{x}}\n",
} {
if err := os.WriteFile(name, []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
run := func(opacity int) (string, string) {
runTmp := t.TempDir()
checker := mocks_utils.NewMockAppChecker(t)
checker.EXPECT().AnyCommandExists("kitty").Return(true)
out := filepath.Join(runTmp, "out.toml")
f, err := os.Create(out)
if err != nil {
t.Fatal(err)
}
opts := &Options{ShellDir: shellDir, AppChecker: checker, TerminalOpacity: opacity}
appendTerminalConfig(opts, f, runTmp, []string{"kitty"}, nil, "kitty.toml")
f.Close()
b, _ := os.ReadFile(out)
return string(b), runTmp
}
got, runTmp := run(85)
kittyCopy := filepath.Join(runTmp, "kitty.conf")
tabsCopy := filepath.Join(runTmp, "kitty-tabs.conf")
assert.Contains(t, got, "input_path = '"+kittyCopy+"'")
assert.Contains(t, got, "input_path = '"+tabsCopy+"'")
b, err := os.ReadFile(kittyCopy)
assert.NoError(t, err)
assert.True(t, strings.HasSuffix(string(b), "background_opacity 0.85\ndynamic_background_opacity yes\n"))
b, err = os.ReadFile(tabsCopy)
assert.NoError(t, err)
assert.Equal(t, "tab {{x}}\n", string(b))
got, runTmp = run(0)
assert.Equal(t, substituteVars(toml, shellDir)+"\n", got)
_, err = os.Stat(filepath.Join(runTmp, "kitty.conf"))
assert.True(t, os.IsNotExist(err))
}
func TestGenerateDank16VariantsProfile(t *testing.T) {
gen := func(profile string) map[string]any {
out := generateDank16Variants("#8ad0ee", "#1c5070", "#0f1417", "#f5fafd", "#1b2023", "#e9eef2", ColorModeDark, profile)
var m map[string]any
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatal(err)
}
return m
}
def, high := gen(""), gen("high")
assert.NotEqual(t, def["color1"], high["color1"])
assert.Equal(t, def["color0"], high["color0"])
}
func TestTerminalPaletteOnlyReachesTerminalTemplates(t *testing.T) {
shellDir := filepath.Join("..", "..", "..", "quickshell")
inputPath := regexp.MustCompile(`input_path = 'SHELL_DIR/([^']+)'`)
checked := 0
for _, def := range templateRegistry {
if def.ConfigFile == "" {
continue
}
config, err := os.ReadFile(filepath.Join(shellDir, "matugen", "configs", def.ConfigFile))
if !assert.NoError(t, err) {
continue
}
for _, m := range inputPath.FindAllStringSubmatch(string(config), -1) {
data, err := os.ReadFile(filepath.Join(shellDir, m[1]))
if !assert.NoError(t, err) {
continue
}
content := string(data)
if def.Kind == TemplateKindTerminal {
assert.NotContains(t, content, "{{dank16.", "%s: terminal templates read dank16term", m[1])
} else {
assert.NotContains(t, content, "dank16term", "%s: only terminal templates follow the terminal palette", m[1])
}
checked++
}
}
assert.Greater(t, checked, 10)
}
+2
View File
@@ -27,6 +27,8 @@ func handleMatugenQueue(conn *ipc.ConnWriter, req ipc.Request) {
StockColors: req.GetOr("stockColors", ""),
SyncModeWithPortal: req.GetOr("syncModeWithPortal", false),
TerminalsAlwaysDark: req.GetOr("terminalsAlwaysDark", false),
TerminalOpacity: int(req.GetOr("terminalOpacity", 0.0)),
TerminalPalette: req.GetOr("terminalPalette", ""),
SkipTemplates: req.GetOr("skipTemplates", ""),
Contrast: req.GetOr("contrast", 0.0),
SourceMode: req.GetOr("sourceMode", ""),
+2
View File
@@ -746,6 +746,8 @@ Singleton {
property bool syncModeWithPortal: Spec.SPEC.syncModeWithPortal.def
property bool terminalsAlwaysDark: Spec.SPEC.terminalsAlwaysDark.def
property int terminalOpacity: Spec.SPEC.terminalOpacity.def
property string terminalPalette: Spec.SPEC.terminalPalette.def
property string muxType: Spec.SPEC.muxType.def
property bool muxUseCustomCommand: Spec.SPEC.muxUseCustomCommand.def
+6
View File
@@ -1791,6 +1791,12 @@ Singleton {
if (typeof SettingsData !== "undefined" && SettingsData.terminalsAlwaysDark) {
args.push("--terminals-always-dark");
}
if (typeof SettingsData !== "undefined" && SettingsData.terminalOpacity < 100) {
args.push("--terminal-opacity", SettingsData.terminalOpacity.toString());
}
if (typeof SettingsData !== "undefined" && SettingsData.terminalPalette !== "default") {
args.push("--terminal-palette", SettingsData.terminalPalette);
}
if (typeof SettingsData !== "undefined" && SettingsData.matugenContrast !== 0) {
args.push("--contrast", SettingsData.matugenContrast.toString());
}
@@ -1121,6 +1121,14 @@ var LOCAL_SPEC = {
def: false,
onChange: "regenSystemThemes"
},
terminalOpacity: {
def: 100,
onChange: "regenSystemThemes"
},
terminalPalette: {
def: "default",
onChange: "regenSystemThemes"
},
muxType: {
def: "tmux"
},
@@ -132,6 +132,41 @@ Item {
checked: SettingsData.terminalsAlwaysDark
onToggled: checked => SettingsData.set("terminalsAlwaysDark", checked)
}
SettingsSliderRow {
tab: "theme"
tags: ["terminal", "opacity", "transparency", "background", "kitty", "foot", "ghostty", "alacritty"]
settingKey: "terminalOpacity"
text: I18n.tr("Opacity")
value: SettingsData.terminalOpacity
minimum: 10
maximum: 100
unit: "%"
enabled: Theme.matugenAvailable
onSliderDragFinished: finalValue => SettingsData.set("terminalOpacity", finalValue)
}
SettingsToggleRow {
tab: "theme"
tags: ["terminal", "palette", "ansi", "colors", "wallpaper", "intensity"]
settingKey: "terminalPalette"
text: I18n.tr("Colors from wallpaper")
enabled: Theme.matugenAvailable
checked: SettingsData.terminalPalette !== "default"
onToggled: checked => SettingsData.set("terminalPalette", checked ? "medium" : "default")
}
SettingsButtonGroupRow {
text: I18n.tr("Intensity")
description: I18n.tr("How far terminal colors shift toward the wallpaper color", "terminal wallpaper colors intensity description")
visible: SettingsData.terminalPalette !== "default"
enabled: Theme.matugenAvailable
model: [I18n.tr("Low"), I18n.tr("Medium"), I18n.tr("High")]
values: ["low", "medium", "high"]
value: SettingsData.terminalPalette
fallbackValue: "medium"
onValueSelected: value => SettingsData.set("terminalPalette", value)
}
}
SettingsCard {
+16 -16
View File
@@ -11,21 +11,21 @@ text = '{{colors.background.default.hex}}'
cursor = '{{colors.primary.default.hex}}'
[colors.normal]
black = '{{dank16.color0.default.hex}}'
red = '{{dank16.color1.default.hex}}'
green = '{{dank16.color2.default.hex}}'
yellow = '{{dank16.color3.default.hex}}'
blue = '{{dank16.color4.default.hex}}'
magenta = '{{dank16.color5.default.hex}}'
cyan = '{{dank16.color6.default.hex}}'
white = '{{dank16.color7.default.hex}}'
black = '{{dank16term.color0.default.hex}}'
red = '{{dank16term.color1.default.hex}}'
green = '{{dank16term.color2.default.hex}}'
yellow = '{{dank16term.color3.default.hex}}'
blue = '{{dank16term.color4.default.hex}}'
magenta = '{{dank16term.color5.default.hex}}'
cyan = '{{dank16term.color6.default.hex}}'
white = '{{dank16term.color7.default.hex}}'
[colors.bright]
black = '{{dank16.color8.default.hex}}'
red = '{{dank16.color9.default.hex}}'
green = '{{dank16.color10.default.hex}}'
yellow = '{{dank16.color11.default.hex}}'
blue = '{{dank16.color12.default.hex}}'
magenta = '{{dank16.color13.default.hex}}'
cyan = '{{dank16.color14.default.hex}}'
white = '{{dank16.color15.default.hex}}'
black = '{{dank16term.color8.default.hex}}'
red = '{{dank16term.color9.default.hex}}'
green = '{{dank16term.color10.default.hex}}'
yellow = '{{dank16term.color11.default.hex}}'
blue = '{{dank16term.color12.default.hex}}'
magenta = '{{dank16term.color13.default.hex}}'
cyan = '{{dank16term.color14.default.hex}}'
white = '{{dank16term.color15.default.hex}}'
+16 -16
View File
@@ -5,21 +5,21 @@ selection-foreground={{colors.on_surface.default.hex_stripped}}
selection-background={{colors.primary_container.default.hex_stripped}}
cursor = {{colors.background.default.hex_stripped}} {{colors.primary.default.hex_stripped}}
regular0={{dank16.color0.default.hex_stripped}}
regular1={{dank16.color1.default.hex_stripped}}
regular2={{dank16.color2.default.hex_stripped}}
regular3={{dank16.color3.default.hex_stripped}}
regular4={{dank16.color4.default.hex_stripped}}
regular5={{dank16.color5.default.hex_stripped}}
regular6={{dank16.color6.default.hex_stripped}}
regular7={{dank16.color7.default.hex_stripped}}
bright0={{dank16.color8.default.hex_stripped}}
bright1={{dank16.color9.default.hex_stripped}}
bright2={{dank16.color10.default.hex_stripped}}
bright3={{dank16.color11.default.hex_stripped}}
bright4={{dank16.color12.default.hex_stripped}}
bright5={{dank16.color13.default.hex_stripped}}
bright6={{dank16.color14.default.hex_stripped}}
bright7={{dank16.color15.default.hex_stripped}}
regular0={{dank16term.color0.default.hex_stripped}}
regular1={{dank16term.color1.default.hex_stripped}}
regular2={{dank16term.color2.default.hex_stripped}}
regular3={{dank16term.color3.default.hex_stripped}}
regular4={{dank16term.color4.default.hex_stripped}}
regular5={{dank16term.color5.default.hex_stripped}}
regular6={{dank16term.color6.default.hex_stripped}}
regular7={{dank16term.color7.default.hex_stripped}}
bright0={{dank16term.color8.default.hex_stripped}}
bright1={{dank16term.color9.default.hex_stripped}}
bright2={{dank16term.color10.default.hex_stripped}}
bright3={{dank16term.color11.default.hex_stripped}}
bright4={{dank16term.color12.default.hex_stripped}}
bright5={{dank16term.color13.default.hex_stripped}}
bright6={{dank16term.color14.default.hex_stripped}}
bright7={{dank16term.color15.default.hex_stripped}}
dim-blend-towards=<* if {{ is_dark_mode }} *>black<* else *>white<* endif *>
+16 -16
View File
@@ -4,19 +4,19 @@ cursor-color = {{colors.primary.default.hex}}
selection-background = {{colors.primary_container.default.hex}}
selection-foreground = {{colors.on_surface.default.hex}}
palette = 0={{dank16.color0.default.hex}}
palette = 1={{dank16.color1.default.hex}}
palette = 2={{dank16.color2.default.hex}}
palette = 3={{dank16.color3.default.hex}}
palette = 4={{dank16.color4.default.hex}}
palette = 5={{dank16.color5.default.hex}}
palette = 6={{dank16.color6.default.hex}}
palette = 7={{dank16.color7.default.hex}}
palette = 8={{dank16.color8.default.hex}}
palette = 9={{dank16.color9.default.hex}}
palette = 10={{dank16.color10.default.hex}}
palette = 11={{dank16.color11.default.hex}}
palette = 12={{dank16.color12.default.hex}}
palette = 13={{dank16.color13.default.hex}}
palette = 14={{dank16.color14.default.hex}}
palette = 15={{dank16.color15.default.hex}}
palette = 0={{dank16term.color0.default.hex}}
palette = 1={{dank16term.color1.default.hex}}
palette = 2={{dank16term.color2.default.hex}}
palette = 3={{dank16term.color3.default.hex}}
palette = 4={{dank16term.color4.default.hex}}
palette = 5={{dank16term.color5.default.hex}}
palette = 6={{dank16term.color6.default.hex}}
palette = 7={{dank16term.color7.default.hex}}
palette = 8={{dank16term.color8.default.hex}}
palette = 9={{dank16term.color9.default.hex}}
palette = 10={{dank16term.color10.default.hex}}
palette = 11={{dank16term.color11.default.hex}}
palette = 12={{dank16term.color12.default.hex}}
palette = 13={{dank16term.color13.default.hex}}
palette = 14={{dank16term.color14.default.hex}}
palette = 15={{dank16term.color15.default.hex}}
+16 -16
View File
@@ -7,19 +7,19 @@ selection_foreground {{colors.on_secondary.default.hex}}
selection_background {{colors.secondary_fixed_dim.default.hex}}
url_color {{colors.primary.default.hex}}
color0 {{dank16.color0.default.hex}}
color1 {{dank16.color1.default.hex}}
color2 {{dank16.color2.default.hex}}
color3 {{dank16.color3.default.hex}}
color4 {{dank16.color4.default.hex}}
color5 {{dank16.color5.default.hex}}
color6 {{dank16.color6.default.hex}}
color7 {{dank16.color7.default.hex}}
color8 {{dank16.color8.default.hex}}
color9 {{dank16.color9.default.hex}}
color10 {{dank16.color10.default.hex}}
color11 {{dank16.color11.default.hex}}
color12 {{dank16.color12.default.hex}}
color13 {{dank16.color13.default.hex}}
color14 {{dank16.color14.default.hex}}
color15 {{dank16.color15.default.hex}}
color0 {{dank16term.color0.default.hex}}
color1 {{dank16term.color1.default.hex}}
color2 {{dank16term.color2.default.hex}}
color3 {{dank16term.color3.default.hex}}
color4 {{dank16term.color4.default.hex}}
color5 {{dank16term.color5.default.hex}}
color6 {{dank16term.color6.default.hex}}
color7 {{dank16term.color7.default.hex}}
color8 {{dank16term.color8.default.hex}}
color9 {{dank16term.color9.default.hex}}
color10 {{dank16term.color10.default.hex}}
color11 {{dank16term.color11.default.hex}}
color12 {{dank16term.color12.default.hex}}
color13 {{dank16term.color13.default.hex}}
color14 {{dank16term.color14.default.hex}}
color15 {{dank16term.color15.default.hex}}
+2 -2
View File
@@ -9,5 +9,5 @@ cursor_border = '{{colors.primary.default.hex}}'
selection_bg = '{{colors.primary_container.default.hex}}'
selection_fg = '{{colors.on_surface.default.hex}}'
ansi = ['{{dank16.color0.default.hex}}', '{{dank16.color1.default.hex}}', '{{dank16.color2.default.hex}}', '{{dank16.color3.default.hex}}', '{{dank16.color4.default.hex}}', '{{dank16.color5.default.hex}}', '{{dank16.color6.default.hex}}', '{{dank16.color7.default.hex}}']
brights = ['{{dank16.color8.default.hex}}', '{{dank16.color9.default.hex}}', '{{dank16.color10.default.hex}}', '{{dank16.color11.default.hex}}', '{{dank16.color12.default.hex}}', '{{dank16.color13.default.hex}}', '{{dank16.color14.default.hex}}', '{{dank16.color15.default.hex}}']
ansi = ['{{dank16term.color0.default.hex}}', '{{dank16term.color1.default.hex}}', '{{dank16term.color2.default.hex}}', '{{dank16term.color3.default.hex}}', '{{dank16term.color4.default.hex}}', '{{dank16term.color5.default.hex}}', '{{dank16term.color6.default.hex}}', '{{dank16term.color7.default.hex}}']
brights = ['{{dank16term.color8.default.hex}}', '{{dank16term.color9.default.hex}}', '{{dank16term.color10.default.hex}}', '{{dank16term.color11.default.hex}}', '{{dank16term.color12.default.hex}}', '{{dank16term.color13.default.hex}}', '{{dank16term.color14.default.hex}}', '{{dank16term.color15.default.hex}}']
@@ -14709,6 +14709,32 @@
"parentLabel": "Wallpaper & colors",
"description": "GTK, Qt, portal, matugen templates"
},
{
"section": "terminalPalette",
"label": "Colors from wallpaper",
"tabIndex": 50,
"category": "App theming",
"keywords": [
"ansi",
"app",
"background",
"bg",
"colors",
"colour",
"colours",
"desktop",
"hue",
"image",
"intensity",
"palette",
"picture",
"terminal",
"theming",
"tint",
"wallpaper"
],
"parentLabel": "Wallpaper & colors"
},
{
"section": "matugenTemplateNeovimSettings",
"label": "Dark mode base",
@@ -15023,6 +15049,36 @@
"icon": "auto_awesome",
"conditionKey": "matugenAvailable"
},
{
"section": "terminalOpacity",
"label": "Opacity",
"tabIndex": 50,
"category": "App theming",
"keywords": [
"alacritty",
"app",
"background",
"bg",
"colors",
"colour",
"colours",
"desktop",
"foot",
"ghostty",
"hue",
"image",
"kitty",
"opacity",
"palette",
"picture",
"terminal",
"theming",
"tint",
"transparency",
"wallpaper"
],
"parentLabel": "Wallpaper & colors"
},
{
"section": "applications",
"label": "Portal & terminals",