From 549cbf6d0892c191636fce0d551916c97a8986a9 Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Thu, 15 Oct 2026 17:17:29 +0200 Subject: [PATCH] feat(theme): terminal background opacity and wallpaper-tinted colors (#3662) --- core/cmd/dms/commands_dank16.go | 7 + core/cmd/dms/commands_matugen.go | 8 + core/internal/dank16/dank16.go | 63 +++++++- core/internal/dank16/dank16_test.go | 140 ++++++++++++++++++ core/internal/matugen/matugen.go | 54 +++++-- core/internal/matugen/matugen_test.go | 126 ++++++++++++++++ core/internal/server/matugen_handler.go | 2 + quickshell/Common/SettingsData.qml | 2 + quickshell/Common/Theme.qml | 6 + quickshell/Common/settings/SettingsSpec.js | 8 + quickshell/Modules/Settings/ThemeAppsTab.qml | 35 +++++ quickshell/matugen/templates/alacritty.toml | 32 ++-- quickshell/matugen/templates/foot.ini | 32 ++-- quickshell/matugen/templates/ghostty.conf | 32 ++-- quickshell/matugen/templates/kitty.conf | 32 ++-- quickshell/matugen/templates/wezterm.toml | 4 +- .../translations/settings_search_index.json | 56 +++++++ 17 files changed, 562 insertions(+), 77 deletions(-) diff --git a/core/cmd/dms/commands_dank16.go b/core/cmd/dms/commands_dank16.go index f4d01d21b..2e21a0b70 100644 --- a/core/cmd/dms/commands_dank16.go +++ b/core/cmd/dms/commands_dank16.go @@ -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) diff --git a/core/cmd/dms/commands_matugen.go b/core/cmd/dms/commands_matugen.go index c32b7c4a2..2148973cb 100644 --- a/core/cmd/dms/commands_matugen.go +++ b/core/cmd/dms/commands_matugen.go @@ -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, diff --git a/core/internal/dank16/dank16.go b/core/internal/dank16/dank16.go index 07def621b..2dbf09f3d 100644 --- a/core/internal/dank16/dank16.go +++ b/core/internal/dank16/dank16.go @@ -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) diff --git a/core/internal/dank16/dank16_test.go b/core/internal/dank16/dank16_test.go index 5215b1001..b90a5c16d 100644 --- a/core/internal/dank16/dank16_test.go +++ b/core/internal/dank16/dank16_test.go @@ -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") + } +} diff --git a/core/internal/matugen/matugen.go b/core/internal/matugen/matugen.go index 2419b33d4..16b3ffc98 100644 --- a/core/internal/matugen/matugen.go +++ b/core/internal/matugen/matugen.go @@ -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) diff --git a/core/internal/matugen/matugen_test.go b/core/internal/matugen/matugen_test.go index e79db0389..952540f75 100644 --- a/core/internal/matugen/matugen_test.go +++ b/core/internal/matugen/matugen_test.go @@ -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) +} diff --git a/core/internal/server/matugen_handler.go b/core/internal/server/matugen_handler.go index 68e756a02..ed09dc96d 100644 --- a/core/internal/server/matugen_handler.go +++ b/core/internal/server/matugen_handler.go @@ -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", ""), diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index f28cf4cee..05124047e 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -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 diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index aa2c5a12a..6b753465b 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -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()); } diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 1eead5273..c96445c4d 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -1121,6 +1121,14 @@ var LOCAL_SPEC = { def: false, onChange: "regenSystemThemes" }, + terminalOpacity: { + def: 100, + onChange: "regenSystemThemes" + }, + terminalPalette: { + def: "default", + onChange: "regenSystemThemes" + }, muxType: { def: "tmux" }, diff --git a/quickshell/Modules/Settings/ThemeAppsTab.qml b/quickshell/Modules/Settings/ThemeAppsTab.qml index 2e8f8d586..c448b6810 100644 --- a/quickshell/Modules/Settings/ThemeAppsTab.qml +++ b/quickshell/Modules/Settings/ThemeAppsTab.qml @@ -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 { diff --git a/quickshell/matugen/templates/alacritty.toml b/quickshell/matugen/templates/alacritty.toml index b9295fd48..54ba759f1 100644 --- a/quickshell/matugen/templates/alacritty.toml +++ b/quickshell/matugen/templates/alacritty.toml @@ -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}}' diff --git a/quickshell/matugen/templates/foot.ini b/quickshell/matugen/templates/foot.ini index 32270eab5..d8f32780a 100644 --- a/quickshell/matugen/templates/foot.ini +++ b/quickshell/matugen/templates/foot.ini @@ -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 *> diff --git a/quickshell/matugen/templates/ghostty.conf b/quickshell/matugen/templates/ghostty.conf index 2fe272158..a6fda213e 100644 --- a/quickshell/matugen/templates/ghostty.conf +++ b/quickshell/matugen/templates/ghostty.conf @@ -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}} diff --git a/quickshell/matugen/templates/kitty.conf b/quickshell/matugen/templates/kitty.conf index 0caabf663..122d498b8 100644 --- a/quickshell/matugen/templates/kitty.conf +++ b/quickshell/matugen/templates/kitty.conf @@ -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}} diff --git a/quickshell/matugen/templates/wezterm.toml b/quickshell/matugen/templates/wezterm.toml index 1b7d8b35f..bb90fd9a1 100644 --- a/quickshell/matugen/templates/wezterm.toml +++ b/quickshell/matugen/templates/wezterm.toml @@ -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}}'] diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index b2da5742b..7256257a5 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -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",