1// Package theme provides GTK CSS styling for UI components.
2package theme
3
4import (
5 "fmt"
6 "math"
7 "regexp"
8 "strings"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11)
12
13// Palette holds semantic color tokens for theming.
14type Palette struct {
15 Background string // Main background color
16 Surface string // Elevated surfaces (cards, popups)
17 SurfaceVariant string // Secondary surfaces
18 Text string // Primary text color
19 Muted string // Secondary/disabled text
20 Accent string // Primary accent color (actions, highlights)
21 Border string // Border and divider lines
22 // Semantic status colors (not user-editable, derived defaults)
23 Success string // Success/positive feedback
24 Warning string // Warning/caution feedback
25 Destructive string // Error/destructive actions
26}
27
28// ModeColors holds colors for modal mode indicators (borders and toasters).
29type ModeColors struct {
30 PaneMode string // Color for pane mode (default: #4A90E2 blue)
31 TabMode string // Color for tab mode (default: #FFA500 orange)
32 SessionMode string // Color for session mode (default: #9B59B6 purple)
33 ResizeMode string // Color for resize mode (default: #00D4AA teal)
34}
35
36// DefaultModeColors returns the default mode indicator colors.
37func DefaultModeColors() ModeColors {
38 return ModeColors{
39 PaneMode: "#4A90E2",
40 TabMode: "#FFA500",
41 SessionMode: "#9B59B6",
42 ResizeMode: "#00D4AA",
43 }
44}
45
46// ToCSSVars generates CSS custom property declarations for mode colors.
47func (m ModeColors) ToCSSVars() string {
48 var sb strings.Builder
49 sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")
50 sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")
51 sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")
52 sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")
53 return sb.String()
54}
55
56// DefaultDarkPalette returns the default dark theme palette.
57func DefaultDarkPalette() Palette {
58 return Palette{
59 Background: "#0a0a0b",
60 Surface: "#1a1a1b",
61 SurfaceVariant: "#2d2d2d",
62 Text: "#ffffff",
63 Muted: "#909090",
64 Accent: "#4ade80",
65 Border: "#333333",
66 Success: "#4ade80",
67 Warning: "#fbbf24",
68 Destructive: "#ef4444",
69 }
70}
71
72// DefaultLightPalette returns the default light theme palette.
73func DefaultLightPalette() Palette {
74 return Palette{
75 Background: "#fafafa",
76 Surface: "#ffffff",
77 SurfaceVariant: "#f0f0f0",
78 Text: "#1a1a1a",
79 Muted: "#666666",
80 Accent: "#22c55e",
81 Border: "#dddddd",
82 Success: "#22c55e",
83 Warning: "#f59e0b",
84 Destructive: "#dc2626",
85 }
86}
87
88// PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
89func PaletteFromConfig(cfg *entity.ColorPalette, isDark bool) Palette {
90 var defaults Palette
91 if isDark {
92 defaults = DefaultDarkPalette()
93 } else {
94 defaults = DefaultLightPalette()
95 }
96
97 if cfg == nil {
98 return defaults
99 }
100
101 return Palette{
102 Background: Coalesce(cfg.Background, defaults.Background),
103 Surface: Coalesce(cfg.Surface, defaults.Surface),
104 SurfaceVariant: Coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
105 Text: Coalesce(cfg.Text, defaults.Text),
106 Muted: Coalesce(cfg.Muted, defaults.Muted),
107 Accent: Coalesce(cfg.Accent, defaults.Accent),
108 Border: Coalesce(cfg.Border, defaults.Border),
109 // Semantic colors always use defaults (not user-editable)
110 Success: defaults.Success,
111 Warning: defaults.Warning,
112 Destructive: defaults.Destructive,
113 }
114}
115
116// PaletteFromEntity creates a CSS adapter palette from a resolved domain palette.
117// Resolved palettes already contain validated color tokens; this adapter only adds
118// semantic status colors used by GTK/WebUI CSS.
119func PaletteFromEntity(p entity.ColorPalette, isDark bool) Palette {
120 defaults := DefaultLightPalette()
121 if isDark {
122 defaults = DefaultDarkPalette()
123 }
124 return Palette{
125 Background: p.Background,
126 Surface: p.Surface,
127 SurfaceVariant: p.SurfaceVariant,
128 Text: p.Text,
129 Muted: p.Muted,
130 Accent: p.Accent,
131 Border: p.Border,
132 Success: defaults.Success,
133 Warning: defaults.Warning,
134 Destructive: defaults.Destructive,
135 }
136}
137
138// ModeColorsFromEntity creates CSS adapter mode colors from resolved domain mode colors.
139func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors {
140 return ModeColors{
141 PaneMode: colors.PaneMode,
142 TabMode: colors.TabMode,
143 SessionMode: colors.SessionMode,
144 ResizeMode: colors.ResizeMode,
145 }
146}
147
148// FontConfigFromEntity creates CSS adapter font config from resolved domain fonts.
149func FontConfigFromEntity(fonts entity.ThemeFonts) FontConfig {
150 return FontConfig{
151 SansFont: fonts.SansFont,
152 MonospaceFont: fonts.MonospaceFont,
153 GtkFont: fonts.GtkFont,
154 }
155}
156
157// Coalesce returns the first non-empty string.
158func Coalesce(values ...string) string {
159 for _, v := range values {
160 if v != "" {
161 return v
162 }
163 }
164 return ""
165}
166
167// hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
168var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
169
170// Hex format length constants.
171const (
172 hexLenShort = 3 // #RGB
173 hexLenStandard = 6 // #RRGGBB
174 hexLenWithAlha = 8 // #RRGGBBAA
175 maxColorValue = 255 // Maximum value for 8-bit color channel
176)
177
178// HexToRGBA converts a hex color string to RGBA float32 values (0.0-1.0).
179// Supports #RGB, #RRGGBB, and #RRGGBBAA formats.
180// Returns opaque black (0,0,0,1) if parsing fails.
181func HexToRGBA(hex string) (r, g, b, a float32) {
182 // Default to opaque black
183 if hex == "" || hex[0] != '#' {
184 return 0, 0, 0, 1
185 }
186 hex = hex[1:]
187
188 var ri, gi, bi, ai uint64
189 switch len(hex) {
190 case hexLenShort: // #RGB
191 ri, _ = parseHex(hex[0:1])
192 gi, _ = parseHex(hex[1:2])
193 bi, _ = parseHex(hex[2:3])
194 ri *= 17 // 0xF -> 0xFF
195 gi *= 17
196 bi *= 17
197 ai = maxColorValue
198 case hexLenStandard: // #RRGGBB
199 ri, _ = parseHex(hex[0:2])
200 gi, _ = parseHex(hex[2:4])
201 bi, _ = parseHex(hex[4:6])
202 ai = maxColorValue
203 case hexLenWithAlha: // #RRGGBBAA
204 ri, _ = parseHex(hex[0:2])
205 gi, _ = parseHex(hex[2:4])
206 bi, _ = parseHex(hex[4:6])
207 ai, _ = parseHex(hex[6:8])
208 default:
209 return 0, 0, 0, 1
210 }
211
212 return float32(ri) / maxColorValue, float32(gi) / maxColorValue, float32(bi) / maxColorValue, float32(ai) / maxColorValue
213}
214
215// parseHex parses a hex string to uint64.
216func parseHex(s string) (uint64, error) {
217 var result uint64
218 for _, c := range s {
219 result *= 16
220 switch {
221 case c >= '0' && c <= '9':
222 result += uint64(c - '0')
223 case c >= 'a' && c <= 'f':
224 result += uint64(c - 'a' + 10)
225 case c >= 'A' && c <= 'F':
226 result += uint64(c - 'A' + 10)
227 default:
228 return 0, fmt.Errorf("invalid hex char: %c", c)
229 }
230 }
231 return result, nil
232}
233
234// ValidateHexColor checks if a string is a valid hex color.
235func ValidateHexColor(color string) error {
236 if color == "" {
237 return nil // Empty is valid (will use default)
238 }
239 if !hexColorRegex.MatchString(color) {
240 return fmt.Errorf("invalid hex color: %s", color)
241 }
242 return nil
243}
244
245// Validate checks all palette colors are valid hex values.
246func (p Palette) Validate() error {
247 colors := map[string]string{
248 "background": p.Background,
249 "surface": p.Surface,
250 "surface_variant": p.SurfaceVariant,
251 "text": p.Text,
252 "muted": p.Muted,
253 "accent": p.Accent,
254 "border": p.Border,
255 }
256
257 for name, color := range colors {
258 if err := ValidateHexColor(color); err != nil {
259 return fmt.Errorf("%s: %w", name, err)
260 }
261 }
262 return nil
263}
264
265// ToCSSVars generates CSS custom property declarations for GTK.
266func (p Palette) ToCSSVars() string {
267 var sb strings.Builder
268 sb.WriteString(" --bg: " + p.Background + ";\n")
269 sb.WriteString(" --surface: " + p.Surface + ";\n")
270 sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
271 sb.WriteString(" --control-text: " + readableTextColor(p.SurfaceVariant, p.Text) + ";\n")
272 sb.WriteString(" --text: " + p.Text + ";\n")
273 sb.WriteString(" --muted: " + p.Muted + ";\n")
274 sb.WriteString(" --accent: " + p.Accent + ";\n")
275 sb.WriteString(" --border: " + p.Border + ";\n")
276 sb.WriteString(" --success: " + p.Success + ";\n")
277 sb.WriteString(" --warning: " + p.Warning + ";\n")
278 sb.WriteString(" --destructive: " + p.Destructive + ";\n")
279 return sb.String()
280}
281
282const (
283 minimumReadableContrast = 4.5
284 contrastLuminanceOffset = 0.05
285 sRGBLinearThreshold = 0.04045
286 sRGBLinearDivisor = 12.92
287 sRGBGammaOffset = 0.055
288 sRGBGammaScale = 1.055
289 sRGBGammaExponent = 2.4
290 redLuminanceWeight = 0.2126
291 greenLuminanceWeight = 0.7152
292 blueLuminanceWeight = 0.0722
293)
294
295func readableTextColor(background, preferred string) string {
296 if contrastRatio(background, preferred) >= minimumReadableContrast {
297 return preferred
298 }
299 if contrastRatio(background, "#ffffff") >= contrastRatio(background, "#000000") {
300 return "#ffffff"
301 }
302 return "#000000"
303}
304
305func contrastRatio(first, second string) float64 {
306 firstLuminance := relativeLuminance(first)
307 secondLuminance := relativeLuminance(second)
308 if firstLuminance < secondLuminance {
309 firstLuminance, secondLuminance = secondLuminance, firstLuminance
310 }
311 return (firstLuminance + contrastLuminanceOffset) / (secondLuminance + contrastLuminanceOffset)
312}
313
314func relativeLuminance(color string) float64 {
315 r, g, b, _ := HexToRGBA(color)
316 linearize := func(channel float32) float64 {
317 value := float64(channel)
318 if value <= sRGBLinearThreshold {
319 return value / sRGBLinearDivisor
320 }
321 return math.Pow((value+sRGBGammaOffset)/sRGBGammaScale, sRGBGammaExponent)
322 }
323 return redLuminanceWeight*linearize(r) +
324 greenLuminanceWeight*linearize(g) +
325 blueLuminanceWeight*linearize(b)
326}
327
328// ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
329// Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
330func (p Palette) ToWebCSSVars() string {
331 var sb strings.Builder
332 // Tailwind-compatible variables
333 sb.WriteString(" --background: " + p.Background + " !important;\n")
334 sb.WriteString(" --foreground: " + p.Text + " !important;\n")
335 sb.WriteString(" --card: " + p.Surface + " !important;\n")
336 sb.WriteString(" --card-foreground: " + p.Text + " !important;\n")
337 sb.WriteString(" --primary: " + p.Accent + " !important;\n")
338 sb.WriteString(" --primary-foreground: " + p.Background + " !important;\n")
339 sb.WriteString(" --muted: " + p.SurfaceVariant + " !important;\n")
340 sb.WriteString(" --muted-foreground: " + p.Muted + " !important;\n")
341 sb.WriteString(" --border: " + p.Border + " !important;\n")
342 // Ring follows primary (accent)
343 sb.WriteString(" --ring: " + p.Accent + " !important;\n")
344 // Semantic colors from palette defaults
345 sb.WriteString(" --success: " + p.Success + " !important;\n")
346 sb.WriteString(" --success-foreground: " + p.Background + " !important;\n")
347 sb.WriteString(" --warning: " + p.Warning + " !important;\n")
348 sb.WriteString(" --warning-foreground: " + p.Text + " !important;\n")
349 sb.WriteString(" --destructive: " + p.Destructive + " !important;\n")
350 sb.WriteString(" --destructive-foreground: " + p.Background + " !important;\n")
351 // Keep GTK names for compatibility
352 sb.WriteString(" --bg: " + p.Background + " !important;\n")
353 sb.WriteString(" --surface: " + p.Surface + " !important;\n")
354 sb.WriteString(" --surface-variant: " + p.SurfaceVariant + " !important;\n")
355 sb.WriteString(" --text: " + p.Text + " !important;\n")
356 sb.WriteString(" --accent: " + p.Accent + " !important;\n")
357 return sb.String()
358}