1// Package theme provides GTK CSS styling for UI components.
2package theme
3
4import (
5 "fmt"
6 "strings"
7)
8
9// GenerateCSS creates GTK4 CSS using the provided palette with default scale.
10func GenerateCSS(p Palette) string {
11 return GenerateCSSWithScale(p, 1.0)
12}
13
14// GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.
15// Scale affects font sizes and widget padding/margins proportionally.
16func GenerateCSSWithScale(p Palette, scale float64) string {
17 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
18}
19
20// FontConfig holds font settings for CSS generation.
21type FontConfig struct {
22 SansFont string
23 MonospaceFont string
24}
25
26// DefaultFontConfig returns safe font fallbacks.
27func DefaultFontConfig() FontConfig {
28 return FontConfig{
29 SansFont: "sans-serif",
30 MonospaceFont: "monospace",
31 }
32}
33
34// FontCSSVars generates CSS custom properties for fonts.
35func FontCSSVars(fonts FontConfig) string {
36 var sb strings.Builder
37 // Quote the configured family; include generic fallback.
38 sb.WriteString(fmt.Sprintf(" --font-sans: %q, sans-serif;\n", fonts.SansFont))
39 sb.WriteString(fmt.Sprintf(" --font-mono: %q, monospace;\n", fonts.MonospaceFont))
40 return sb.String()
41}
42
43func generateFontCSS() string {
44 return `/* Font styling */
45window, tooltip, popover {
46 font-family: var(--font-sans);
47}
48`
49}
50
51// GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52// Scale affects font sizes and widget padding/margins proportionally.
53func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
54 if scale <= 0 {
55 scale = 1.0
56 }
57
58 defaults := DefaultFontConfig()
59 fonts = FontConfig{
60 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
61 MonospaceFont: Coalesce(fonts.MonospaceFont, defaults.MonospaceFont),
62 }
63
64 var sb strings.Builder
65
66 // CSS custom properties (variables) - GTK4 uses :root selector
67 sb.WriteString("/* Theme variables */\n")
68 sb.WriteString(":root {\n")
69 sb.WriteString(p.ToCSSVars())
70 sb.WriteString(FontCSSVars(fonts))
71 sb.WriteString("}\n\n")
72
73 // Global font styling
74 sb.WriteString(generateFontCSS())
75 sb.WriteString("\n")
76
77 // Global UI scaling via font-size on all widgets
78 if scale != 1.0 {
79 sb.WriteString(generateScalingCSS(scale))
80 sb.WriteString("\n")
81 }
82
83 // Tab bar styling
84 sb.WriteString(generateTabBarCSS(p))
85 sb.WriteString("\n")
86
87 // Omnibox styling
88 sb.WriteString(generateOmniboxCSS(p))
89 sb.WriteString("\n")
90
91 // Find bar styling
92 sb.WriteString(generateFindBarCSS(p))
93 sb.WriteString("\n")
94
95 // Pane styling
96 sb.WriteString(generatePaneCSS(p))
97 sb.WriteString("\n")
98
99 // Stacked pane styling
100 sb.WriteString(generateStackedPaneCSS(p))
101 sb.WriteString("\n")
102
103 // Progress bar styling
104 sb.WriteString(generateProgressBarCSS(p))
105 sb.WriteString("\n")
106
107 // Toaster styling
108 sb.WriteString(generateToasterCSS(p))
109
110 return sb.String()
111}
112
113// generateScalingCSS creates CSS rules that scale the UI.
114// GTK4 CSS doesn't support transform:scale() well, so we scale font-size
115// and use relative units (em) for padding/margins in other rules.
116func generateScalingCSS(scale float64) string {
117 // Use 16px base so em conversions are correct:
118 // 1px = 0.0625em (1/16), 4px = 0.25em, etc.
119 basePx := int(16 * scale)
120 return fmt.Sprintf(`/* UI Scaling (%.0f%%) */
121window {
122 font-size: %dpx;
123}
124`, scale*100, basePx)
125}
126
127// generateTabBarCSS creates tab bar styles.
128// Uses em units for scalable UI.
129func generateTabBarCSS(p Palette) string {
130 return `/* Tab bar styling */
131.tab-bar {
132 background-color: var(--surface);
133 border-top: 0.125em solid var(--border);
134 padding: 0;
135 min-height: 2em;
136}
137
138/* Tab button styling */
139button.tab-button {
140 background-color: var(--surface-variant);
141 background-image: none;
142 border: none;
143 border-right: 0.0625em solid var(--border);
144 border-radius: 0;
145 padding: 0.25em 0.5em;
146 transition: background-color 200ms ease-in-out;
147}
148
149button.tab-button:hover {
150 background-color: shade(var(--surface-variant), 1.2);
151}
152
153button.tab-button.tab-button-active {
154 background-color: shade(var(--surface-variant), 1.2);
155 border-bottom: 0.125em solid var(--accent);
156 font-weight: 600;
157}
158
159/* Tab title text */
160.tab-title {
161 font-size: 0.75em;
162 color: var(--text);
163 font-weight: 500;
164}
165`
166}
167
168// generateOmniboxCSS creates omnibox styles.
169// Uses em units for scalable UI.
170func generateOmniboxCSS(p Palette) string {
171 return `/* ===== Omnibox Styling ===== */
172
173/* Omnibox outer container - for positioning in overlay */
174.omnibox-outer {
175 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */
176}
177
178/* Omnibox main container - the visible popup */
179.omnibox-container {
180 background-color: var(--surface-variant);
181 border: 0.0625em solid var(--border);
182 border-radius: 0.1875em;
183 padding: 0;
184}
185
186/* Header with History/Favorites toggle */
187.omnibox-header {
188 background-color: shade(var(--surface-variant), 1.1);
189 border-bottom: 0.0625em solid var(--border);
190 padding: 0.375em 0.75em;
191}
192
193.omnibox-header-btn {
194 background-color: transparent;
195 background-image: none;
196 border: none;
197 border-radius: 0.125em;
198 padding: 0.25em 0.75em;
199 margin-right: 0.5em;
200 font-size: 0.8125em;
201 font-weight: 500;
202 color: var(--muted);
203 transition: all 100ms ease-in-out;
204}
205
206.omnibox-header-btn:hover {
207 background-color: alpha(var(--accent), 0.15);
208 color: var(--text);
209}
210
211.omnibox-header-btn.omnibox-header-active {
212 background-color: alpha(var(--accent), 0.2);
213 color: var(--accent);
214 font-weight: 600;
215}
216
217/* Search entry field */
218.omnibox-entry {
219 background-color: var(--bg);
220 color: var(--text);
221 border: 0.0625em solid var(--border);
222 border-radius: 0.125em;
223 padding: 0.625em 0.75em;
224 margin: 0.5em 0.75em;
225 font-size: 1em;
226 caret-color: var(--accent);
227}
228
229entry.omnibox-entry:focus,
230entry.omnibox-entry:focus-within,
231entry.omnibox-entry:focus-visible {
232 border-color: var(--accent);
233 background-color: shade(var(--bg), 1.05);
234 outline-style: none;
235 outline-width: 0;
236 outline-color: transparent;
237}
238
239/* Override any internal focus styling */
240entry.omnibox-entry > *:focus,
241entry.omnibox-entry > *:focus-visible {
242 outline-style: none;
243 outline-color: transparent;
244}
245
246/* Scrolled window for suggestions */
247.omnibox-scrolled {
248 background-color: shade(var(--surface-variant), 0.95);
249 border-top: 0.0625em solid var(--border);
250}
251
252/* List box */
253.omnibox-listbox {
254 background-color: transparent;
255}
256
257/* Suggestion rows */
258.omnibox-row {
259 padding: 0.5em 0.75em;
260 margin: 0;
261 border-radius: 0;
262 border-left: 0.1875em solid transparent;
263 border-bottom: 0.0625em solid alpha(var(--border), 0.5);
264 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
265 min-height: 2.75em;
266}
267
268.omnibox-row:last-child {
269 border-bottom: none;
270}
271
272.omnibox-row:hover {
273 background-color: alpha(var(--accent), 0.12);
274 border-left: 0.1875em solid var(--accent);
275}
276
277.omnibox-row:selected {
278 background-color: alpha(var(--accent), 0.2);
279 border-left: 0.1875em solid var(--accent);
280}
281
282/* Favicon in omnibox rows */
283.omnibox-favicon {
284 min-width: 1em;
285 min-height: 1em;
286 margin-right: 0.5em;
287}
288
289/* Suggestion title/URL */
290.omnibox-suggestion-title {
291 font-size: 0.875em;
292 color: var(--text);
293 font-weight: 400;
294}
295
296/* Also style labels inside omnibox rows directly */
297.omnibox-row label {
298 color: var(--text);
299}
300
301.omnibox-row:selected .omnibox-suggestion-title {
302 color: var(--text);
303 font-weight: 500;
304}
305
306/* URL text below title */
307.omnibox-suggestion-url {
308 font-size: 0.75em;
309 color: var(--muted);
310 font-weight: 400;
311}
312
313.omnibox-row:selected .omnibox-suggestion-url {
314 color: var(--muted);
315}
316
317/* Keyboard shortcut badge */
318.omnibox-shortcut-badge {
319 background-color: alpha(var(--muted), 0.3);
320 color: var(--muted);
321 border-radius: 0.25em;
322 padding: 0.125em 0.375em;
323 font-size: 0.625em;
324 font-weight: 500;
325 font-family: var(--font-mono);
326 margin-left: 0.5em;
327}
328
329.omnibox-row:hover .omnibox-shortcut-badge {
330 background-color: alpha(var(--accent), 0.2);
331 color: var(--accent);
332}
333
334.omnibox-row:selected .omnibox-shortcut-badge {
335 background-color: alpha(var(--accent), 0.3);
336 color: var(--accent);
337}
338
339/* Zoom indicator in omnibox header */
340.omnibox-zoom-indicator {
341 background-color: alpha(var(--accent), 0.2);
342 color: var(--accent);
343 border-radius: 0.25em;
344 padding: 0.125em 0.5em;
345 font-size: 0.6875em;
346 font-weight: 600;
347 margin-left: auto;
348}
349`
350}
351
352// generateFindBarCSS creates find bar styles.
353// Uses em units for scalable UI.
354func generateFindBarCSS(p Palette) string {
355 _ = p
356 return `/* ===== Find Bar Styling ===== */
357.find-bar-outer {
358 margin: 0.5em;
359}
360
361.find-bar-container {
362 background-color: var(--surface-variant);
363 border: 0.0625em solid var(--border);
364 border-radius: 0.25em;
365 box-shadow: 0 0.25em 0.75em alpha(black, 0.2);
366 padding: 0.5em;
367 min-width: 20em;
368}
369
370.find-bar-input-row {
371 margin-bottom: 0.25em;
372}
373
374.find-bar-entry {
375 background-color: var(--bg);
376 color: var(--text);
377 border: 0.0625em solid var(--border);
378 border-radius: 0.1875em;
379 padding: 0.4em 0.5em;
380 caret-color: var(--accent);
381}
382
383entry.find-bar-entry:focus,
384entry.find-bar-entry:focus-within,
385entry.find-bar-entry:focus-visible {
386 border-color: var(--accent);
387 outline-style: none;
388 outline-width: 0;
389 outline-color: transparent;
390}
391
392.find-bar-entry.not-found {
393 background-color: alpha(#ef4444, 0.15);
394 border-color: #ef4444;
395}
396
397.find-bar-count {
398 font-size: 0.8em;
399 color: var(--muted);
400 min-width: 3.5em;
401}
402
403.find-bar-count.find-bar-count-has {
404 color: var(--accent);
405}
406
407.find-bar-nav,
408.find-bar-close,
409.find-bar-toggle {
410 background-image: none;
411 background-color: alpha(var(--bg), 0.6);
412 border: 0.0625em solid var(--border);
413 border-radius: 0.1875em;
414 padding: 0.25em 0.5em;
415 transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
416}
417
418.find-bar-nav:hover,
419.find-bar-close:hover,
420.find-bar-toggle:hover {
421 background-color: alpha(var(--accent), 0.15);
422}
423
424.find-bar-toggle:checked {
425 background-color: alpha(var(--accent), 0.2);
426 border-color: var(--accent);
427 color: var(--accent);
428}
429`
430}
431
432// generatePaneCSS creates pane border styles.
433// Uses em units for scalable UI.
434func generatePaneCSS(p Palette) string {
435 return `/* ===== Pane Styling ===== */
436
437/* Pane border - default transparent */
438.pane-border {
439 border: 0.0625em solid transparent;
440}
441
442/* Active pane - accent border */
443.pane-active {
444 border: 0.0625em solid var(--accent);
445}
446
447/* Hide active border when only one pane exists */
448.single-pane .pane-active {
449 border-color: transparent;
450}
451
452/* Pane mode active - thick blue inset border (for overlay) */
453.pane-mode-active {
454 background-color: transparent;
455 box-shadow: inset 0 0 0 0.25em #4A90E2;
456 border-radius: 0;
457}
458
459/* Tab mode active - thick orange inset border (for overlay) */
460.tab-mode-active {
461 background-color: transparent;
462 box-shadow: inset 0 0 0 0.25em #FFA500;
463 border-radius: 0;
464}
465`
466}
467
468// generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles.
469// Uses em units for scalable UI.
470func generateStackedPaneCSS(p Palette) string {
471 return `/* ===== Stacked Pane Styling ===== */
472
473/* Title bar for inactive panes in a stack */
474.stacked-pane-titlebar {
475 background-color: var(--surface-variant);
476 border-bottom: 0.0625em solid var(--border);
477 padding: 0.25em 0.5em;
478 min-height: 1.5em;
479}
480
481/* Title bar button wrapper - clickable area */
482button.stacked-pane-title-button {
483 background-color: var(--surface-variant);
484 background-image: none;
485 border: none;
486 border-bottom: 0.0625em solid var(--border);
487 border-radius: 0;
488 padding: 0;
489 margin: 0;
490 transition: background-color 150ms ease-in-out;
491}
492
493button.stacked-pane-title-button:hover {
494 background-color: shade(var(--surface-variant), 1.15);
495}
496
497button.stacked-pane-title-button:focus {
498 outline: none;
499 box-shadow: none;
500}
501
502/* Title bar content box */
503.stacked-pane-titlebar.active {
504 background-color: shade(var(--surface-variant), 1.2);
505 border-left: 0.1875em solid var(--accent);
506}
507
508/* Favicon image in title bar */
509.stacked-pane-titlebar image {
510 min-width: 1em;
511 min-height: 1em;
512 margin-right: 0.375em;
513}
514
515/* Title text in title bar */
516.stacked-pane-titlebar label {
517 color: var(--text);
518 font-size: 0.75em;
519 font-weight: 400;
520}
521
522button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
523 color: var(--accent);
524}
525`
526}
527
528// generateProgressBarCSS creates progress bar styles for page loading indication.
529func generateProgressBarCSS(p Palette) string {
530 return `/* ===== Progress Bar Styling ===== */
531
532/* Native GtkProgressBar with osd class */
533progressbar.osd {
534 min-height: 4px;
535}
536
537progressbar.osd trough {
538 min-height: 4px;
539 min-width: 0px;
540 background-color: alpha(var(--bg), 0.3);
541}
542
543progressbar.osd progress {
544 min-height: 4px;
545 min-width: 0px;
546 background-color: var(--accent);
547}
548`
549}
550
551// generateToasterCSS creates toast notification styles.
552// Uses em units for scalable UI.
553func generateToasterCSS(p Palette) string {
554 return `/* ===== Toaster Styling ===== */
555
556/* Toast notification container */
557.toast {
558 background-color: var(--surface-variant);
559 border-radius: 0.25em;
560 padding: 0.375em 0.625em;
561 margin: 0.5em;
562 font-size: 0.875em;
563 font-weight: 600;
564 box-shadow: 0 2px 8px alpha(black, 0.3);
565 min-width: 2.5em;
566}
567
568/* Toast level: info (default, accent color) */
569.toast-info {
570 background-color: alpha(var(--accent), 0.9);
571 color: var(--bg);
572}
573
574/* Toast level: success */
575.toast-success {
576 background-color: alpha(var(--success), 0.9);
577 color: var(--bg);
578}
579
580/* Toast level: warning */
581.toast-warning {
582 background-color: alpha(var(--warning), 0.9);
583 color: var(--bg);
584}
585
586/* Toast level: error */
587.toast-error {
588 background-color: alpha(var(--destructive), 0.9);
589 color: var(--bg);
590}
591`
592}