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