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