1// Package theme provides GTK CSS styling for UI components.
2package theme
3
4import (
5 "fmt"
6 "regexp"
7 "strings"
8
9 "github.com/bnema/dumber/internal/infrastructure/config"
10)
11
12// Palette holds semantic color tokens for theming.
13type Palette struct {
14 Background string // Main background color
15 Surface string // Elevated surfaces (cards, popups)
16 SurfaceVariant string // Secondary surfaces
17 Text string // Primary text color
18 Muted string // Secondary/disabled text
19 Accent string // Primary accent color (actions, highlights)
20 Border string // Border and divider lines
21 // Semantic status colors (not user-editable, derived defaults)
22 Success string // Success/positive feedback
23 Warning string // Warning/caution feedback
24 Destructive string // Error/destructive actions
25}
26
27// DefaultDarkPalette returns the default dark theme palette.
28func DefaultDarkPalette() Palette {
29 return Palette{
30 Background: "#0a0a0b",
31 Surface: "#1a1a1b",
32 SurfaceVariant: "#2d2d2d",
33 Text: "#ffffff",
34 Muted: "#909090",
35 Accent: "#4ade80",
36 Border: "#333333",
37 Success: "#4ade80",
38 Warning: "#fbbf24",
39 Destructive: "#ef4444",
40 }
41}
42
43// DefaultLightPalette returns the default light theme palette.
44func DefaultLightPalette() Palette {
45 return Palette{
46 Background: "#fafafa",
47 Surface: "#ffffff",
48 SurfaceVariant: "#f0f0f0",
49 Text: "#1a1a1a",
50 Muted: "#666666",
51 Accent: "#22c55e",
52 Border: "#dddddd",
53 Success: "#22c55e",
54 Warning: "#f59e0b",
55 Destructive: "#dc2626",
56 }
57}
58
59// PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
60func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
61 var defaults Palette
62 if isDark {
63 defaults = DefaultDarkPalette()
64 } else {
65 defaults = DefaultLightPalette()
66 }
67
68 if cfg == nil {
69 return defaults
70 }
71
72 return Palette{
73 Background: Coalesce(cfg.Background, defaults.Background),
74 Surface: Coalesce(cfg.Surface, defaults.Surface),
75 SurfaceVariant: Coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
76 Text: Coalesce(cfg.Text, defaults.Text),
77 Muted: Coalesce(cfg.Muted, defaults.Muted),
78 Accent: Coalesce(cfg.Accent, defaults.Accent),
79 Border: Coalesce(cfg.Border, defaults.Border),
80 // Semantic colors always use defaults (not user-editable)
81 Success: defaults.Success,
82 Warning: defaults.Warning,
83 Destructive: defaults.Destructive,
84 }
85}
86
87// Coalesce returns the first non-empty string.
88func Coalesce(values ...string) string {
89 for _, v := range values {
90 if v != "" {
91 return v
92 }
93 }
94 return ""
95}
96
97// hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
98var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
99
100// Hex format length constants.
101const (
102 hexLenShort = 3 // #RGB
103 hexLenStandard = 6 // #RRGGBB
104 hexLenWithAlha = 8 // #RRGGBBAA
105 maxColorValue = 255 // Maximum value for 8-bit color channel
106)
107
108// HexToRGBA converts a hex color string to RGBA float32 values (0.0-1.0).
109// Supports #RGB, #RRGGBB, and #RRGGBBAA formats.
110// Returns opaque black (0,0,0,1) if parsing fails.
111func HexToRGBA(hex string) (r, g, b, a float32) {
112 // Default to opaque black
113 if hex == "" || hex[0] != '#' {
114 return 0, 0, 0, 1
115 }
116 hex = hex[1:]
117
118 var ri, gi, bi, ai uint64
119 switch len(hex) {
120 case hexLenShort: // #RGB
121 ri, _ = parseHex(hex[0:1])
122 gi, _ = parseHex(hex[1:2])
123 bi, _ = parseHex(hex[2:3])
124 ri *= 17 // 0xF -> 0xFF
125 gi *= 17
126 bi *= 17
127 ai = maxColorValue
128 case hexLenStandard: // #RRGGBB
129 ri, _ = parseHex(hex[0:2])
130 gi, _ = parseHex(hex[2:4])
131 bi, _ = parseHex(hex[4:6])
132 ai = maxColorValue
133 case hexLenWithAlha: // #RRGGBBAA
134 ri, _ = parseHex(hex[0:2])
135 gi, _ = parseHex(hex[2:4])
136 bi, _ = parseHex(hex[4:6])
137 ai, _ = parseHex(hex[6:8])
138 default:
139 return 0, 0, 0, 1
140 }
141
142 return float32(ri) / maxColorValue, float32(gi) / maxColorValue, float32(bi) / maxColorValue, float32(ai) / maxColorValue
143}
144
145// parseHex parses a hex string to uint64.
146func parseHex(s string) (uint64, error) {
147 var result uint64
148 for _, c := range s {
149 result *= 16
150 switch {
151 case c >= '0' && c <= '9':
152 result += uint64(c - '0')
153 case c >= 'a' && c <= 'f':
154 result += uint64(c - 'a' + 10)
155 case c >= 'A' && c <= 'F':
156 result += uint64(c - 'A' + 10)
157 default:
158 return 0, fmt.Errorf("invalid hex char: %c", c)
159 }
160 }
161 return result, nil
162}
163
164// ValidateHexColor checks if a string is a valid hex color.
165func ValidateHexColor(color string) error {
166 if color == "" {
167 return nil // Empty is valid (will use default)
168 }
169 if !hexColorRegex.MatchString(color) {
170 return fmt.Errorf("invalid hex color: %s", color)
171 }
172 return nil
173}
174
175// Validate checks all palette colors are valid hex values.
176func (p Palette) Validate() error {
177 colors := map[string]string{
178 "background": p.Background,
179 "surface": p.Surface,
180 "surface_variant": p.SurfaceVariant,
181 "text": p.Text,
182 "muted": p.Muted,
183 "accent": p.Accent,
184 "border": p.Border,
185 }
186
187 for name, color := range colors {
188 if err := ValidateHexColor(color); err != nil {
189 return fmt.Errorf("%s: %w", name, err)
190 }
191 }
192 return nil
193}
194
195// ToCSSVars generates CSS custom property declarations for GTK.
196func (p Palette) ToCSSVars() string {
197 var sb strings.Builder
198 sb.WriteString(" --bg: " + p.Background + ";\n")
199 sb.WriteString(" --surface: " + p.Surface + ";\n")
200 sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
201 sb.WriteString(" --text: " + p.Text + ";\n")
202 sb.WriteString(" --muted: " + p.Muted + ";\n")
203 sb.WriteString(" --accent: " + p.Accent + ";\n")
204 sb.WriteString(" --border: " + p.Border + ";\n")
205 sb.WriteString(" --success: " + p.Success + ";\n")
206 sb.WriteString(" --warning: " + p.Warning + ";\n")
207 sb.WriteString(" --destructive: " + p.Destructive + ";\n")
208 return sb.String()
209}
210
211// ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
212// Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
213func (p Palette) ToWebCSSVars() string {
214 var sb strings.Builder
215 // Tailwind-compatible variables
216 sb.WriteString(" --background: " + p.Background + " !important;\n")
217 sb.WriteString(" --foreground: " + p.Text + " !important;\n")
218 sb.WriteString(" --card: " + p.Surface + " !important;\n")
219 sb.WriteString(" --card-foreground: " + p.Text + " !important;\n")
220 sb.WriteString(" --primary: " + p.Accent + " !important;\n")
221 sb.WriteString(" --primary-foreground: " + p.Background + " !important;\n")
222 sb.WriteString(" --muted: " + p.SurfaceVariant + " !important;\n")
223 sb.WriteString(" --muted-foreground: " + p.Muted + " !important;\n")
224 sb.WriteString(" --border: " + p.Border + " !important;\n")
225 // Ring follows primary (accent)
226 sb.WriteString(" --ring: " + p.Accent + " !important;\n")
227 // Semantic colors from palette defaults
228 sb.WriteString(" --success: " + p.Success + " !important;\n")
229 sb.WriteString(" --success-foreground: " + p.Background + " !important;\n")
230 sb.WriteString(" --warning: " + p.Warning + " !important;\n")
231 sb.WriteString(" --warning-foreground: " + p.Text + " !important;\n")
232 sb.WriteString(" --destructive: " + p.Destructive + " !important;\n")
233 sb.WriteString(" --destructive-foreground: " + p.Background + " !important;\n")
234 // Keep GTK names for compatibility
235 sb.WriteString(" --bg: " + p.Background + " !important;\n")
236 sb.WriteString(" --surface: " + p.Surface + " !important;\n")
237 sb.WriteString(" --surface-variant: " + p.SurfaceVariant + " !important;\n")
238 sb.WriteString(" --text: " + p.Text + " !important;\n")
239 sb.WriteString(" --accent: " + p.Accent + " !important;\n")
240 return sb.String()
241}