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 sb.WriteString("\n")
110
111 // Link status overlay styling
112 sb.WriteString(generateLinkStatusCSS(p))
113 sb.WriteString("\n")
114
115 // Session manager styling
116 sb.WriteString(generateSessionManagerCSS(p))
117
118 return sb.String()
119}
120
121// generateScalingCSS creates CSS rules that scale the UI.
122// GTK4 CSS doesn't support transform:scale() well, so we scale font-size
123// and use relative units (em) for padding/margins in other rules.
124func generateScalingCSS(scale float64) string {
125 // Use 16px base so em conversions are correct:
126 // 1px = 0.0625em (1/16), 4px = 0.25em, etc.
127 basePx := int(16 * scale)
128 return fmt.Sprintf(`/* UI Scaling (%.0f%%) */
129window {
130 font-size: %dpx;
131}
132`, scale*100, basePx)
133}
134
135// generateTabBarCSS creates tab bar styles.
136// Uses em units for scalable UI.
137func generateTabBarCSS(p Palette) string {
138 return `/* Tab bar styling */
139.tab-bar {
140 background-color: var(--surface);
141 border-top: 0.125em solid var(--border);
142 padding: 0;
143 min-height: 2em;
144}
145
146/* Tab button styling */
147button.tab-button {
148 background-color: var(--surface-variant);
149 background-image: none;
150 border: none;
151 border-right: 0.0625em solid var(--border);
152 border-radius: 0;
153 padding: 0.25em 0.5em;
154 transition: background-color 200ms ease-in-out;
155}
156
157button.tab-button:hover {
158 background-color: shade(var(--surface-variant), 1.2);
159}
160
161button.tab-button.tab-button-active {
162 background-color: shade(var(--surface-variant), 1.2);
163 border-bottom: 0.125em solid var(--accent);
164 font-weight: 600;
165}
166
167/* Tab title text */
168.tab-title {
169 font-size: 0.75em;
170 color: var(--text);
171 font-weight: 500;
172}
173`
174}
175
176// generateOmniboxCSS creates omnibox styles.
177// Uses em units for scalable UI.
178func generateOmniboxCSS(p Palette) string {
179 return `/* ===== Omnibox Styling ===== */
180
181/* Omnibox outer container - for positioning in overlay */
182.omnibox-outer {
183 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */
184}
185
186/* Omnibox main container - the visible popup */
187.omnibox-container {
188 background-color: var(--surface-variant);
189 border: 0.0625em solid var(--border);
190 border-radius: 0.1875em;
191 padding: 0;
192}
193
194/* Header with History/Favorites toggle */
195.omnibox-header {
196 background-color: shade(var(--surface-variant), 1.1);
197 border-bottom: 0.0625em solid var(--border);
198 padding: 0.375em 0.75em;
199}
200
201.omnibox-header-btn {
202 background-color: transparent;
203 background-image: none;
204 border: none;
205 border-radius: 0.125em;
206 padding: 0.25em 0.75em;
207 margin-right: 0.5em;
208 font-size: 0.8125em;
209 font-weight: 500;
210 color: var(--muted);
211 transition: all 100ms ease-in-out;
212}
213
214.omnibox-header-btn:hover {
215 background-color: alpha(var(--accent), 0.15);
216 color: var(--text);
217}
218
219.omnibox-header-btn.omnibox-header-active {
220 background-color: alpha(var(--accent), 0.2);
221 color: var(--accent);
222 font-weight: 600;
223}
224
225/* Search entry field */
226.omnibox-entry {
227 background-color: var(--bg);
228 color: var(--text);
229 border: 0.0625em solid var(--border);
230 border-radius: 0.125em;
231 padding: 0.625em 0.75em;
232 margin: 0.5em 0.75em;
233 font-size: 1em;
234 caret-color: var(--accent);
235}
236
237entry.omnibox-entry:focus,
238entry.omnibox-entry:focus-within,
239entry.omnibox-entry:focus-visible {
240 border-color: var(--accent);
241 background-color: shade(var(--bg), 1.05);
242 outline-style: none;
243 outline-width: 0px;
244 outline-color: transparent;
245}
246
247/* Override any internal focus styling */
248entry.omnibox-entry > *:focus,
249entry.omnibox-entry > *:focus-visible {
250 outline-style: none;
251 outline-color: transparent;
252}
253
254/* Scrolled window for suggestions */
255.omnibox-scrolled {
256 background-color: shade(var(--surface-variant), 0.95);
257 border-top: 0.0625em solid var(--border);
258}
259
260/* List box */
261.omnibox-listbox {
262 background-color: transparent;
263}
264
265/* Suggestion rows */
266.omnibox-row {
267 padding: 0.5em 0.75em;
268 margin: 0;
269 border-radius: 0;
270 border-left: 0.1875em solid transparent;
271 border-bottom: 0.0625em solid alpha(var(--border), 0.5);
272 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
273 min-height: 2.75em;
274}
275
276.omnibox-row:last-child {
277 border-bottom: none;
278}
279
280.omnibox-row:hover {
281 background-color: alpha(var(--accent), 0.12);
282 border-left: 0.1875em solid var(--accent);
283}
284
285.omnibox-row:selected {
286 background-color: alpha(var(--accent), 0.2);
287 border-left: 0.1875em solid var(--accent);
288}
289
290/* Favicon in omnibox rows */
291.omnibox-favicon {
292 min-width: 1em;
293 min-height: 1em;
294 margin-right: 0.5em;
295}
296
297/* Suggestion title/URL */
298.omnibox-suggestion-title {
299 font-size: 0.875em;
300 color: var(--text);
301 font-weight: 400;
302}
303
304/* Also style labels inside omnibox rows directly */
305.omnibox-row label {
306 color: var(--text);
307}
308
309.omnibox-row:selected .omnibox-suggestion-title {
310 color: var(--text);
311 font-weight: 500;
312}
313
314/* URL text below title */
315.omnibox-suggestion-url {
316 font-size: 0.75em;
317 color: var(--muted);
318 font-weight: 400;
319}
320
321.omnibox-row:selected .omnibox-suggestion-url {
322 color: var(--muted);
323}
324
325/* Keyboard shortcut badge */
326.omnibox-shortcut-badge {
327 background-color: alpha(var(--muted), 0.3);
328 color: var(--muted);
329 border-radius: 0.25em;
330 padding: 0.125em 0.375em;
331 font-size: 0.625em;
332 font-weight: 500;
333 font-family: var(--font-mono);
334 margin-left: 0.5em;
335}
336
337.omnibox-row:hover .omnibox-shortcut-badge {
338 background-color: alpha(var(--accent), 0.2);
339 color: var(--accent);
340}
341
342.omnibox-row:selected .omnibox-shortcut-badge {
343 background-color: alpha(var(--accent), 0.3);
344 color: var(--accent);
345}
346
347/* Zoom indicator in omnibox header */
348.omnibox-zoom-indicator {
349 background-color: alpha(var(--accent), 0.2);
350 color: var(--accent);
351 border-radius: 0.25em;
352 padding: 0.125em 0.5em;
353 font-size: 0.6875em;
354 font-weight: 600;
355 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
356}
357`
358}
359
360// generateFindBarCSS creates find bar styles.
361// Uses em units for scalable UI.
362func generateFindBarCSS(p Palette) string {
363 _ = p
364 return `/* ===== Find Bar Styling ===== */
365.find-bar-outer {
366 margin: 0.5em;
367}
368
369.find-bar-container {
370 background-color: var(--surface-variant);
371 border: 0.0625em solid var(--border);
372 border-radius: 0.25em;
373 box-shadow: 0 0.25em 0.75em alpha(black, 0.2);
374 padding: 0.5em;
375 min-width: 20em;
376}
377
378.find-bar-input-row {
379 margin-bottom: 0.25em;
380}
381
382.find-bar-entry {
383 background-color: var(--bg);
384 color: var(--text);
385 border: 0.0625em solid var(--border);
386 border-radius: 0.1875em;
387 padding: 0.4em 0.5em;
388 caret-color: var(--accent);
389}
390
391entry.find-bar-entry:focus,
392entry.find-bar-entry:focus-within,
393entry.find-bar-entry:focus-visible {
394 border-color: var(--accent);
395 outline-style: none;
396 outline-width: 0px;
397 outline-color: transparent;
398}
399
400.find-bar-entry.not-found {
401 background-color: alpha(#ef4444, 0.15);
402 border-color: #ef4444;
403}
404
405.find-bar-count {
406 font-size: 0.8em;
407 color: var(--muted);
408 min-width: 3.5em;
409}
410
411.find-bar-count.find-bar-count-has {
412 color: var(--accent);
413}
414
415.find-bar-nav,
416.find-bar-close,
417.find-bar-toggle {
418 background-image: none;
419 background-color: alpha(var(--bg), 0.6);
420 border: 0.0625em solid var(--border);
421 border-radius: 0.1875em;
422 padding: 0.25em 0.5em;
423 transition: background-color 120ms ease-in-out, border-color 120ms ease-in-out;
424}
425
426.find-bar-nav:hover,
427.find-bar-close:hover,
428.find-bar-toggle:hover {
429 background-color: alpha(var(--accent), 0.15);
430}
431
432.find-bar-toggle:checked {
433 background-color: alpha(var(--accent), 0.2);
434 border-color: var(--accent);
435 color: var(--accent);
436}
437`
438}
439
440// generatePaneCSS creates pane border styles.
441// Uses em units for scalable UI.
442func generatePaneCSS(p Palette) string {
443 return `/* ===== Pane Styling ===== */
444
445/* Pane overlay container - theme background prevents white flash */
446.pane-overlay {
447 background-color: var(--bg);
448}
449
450/* Pane border - default transparent */
451.pane-border {
452 border: 0.0625em solid transparent;
453}
454
455/* Active pane - accent border */
456.pane-active {
457 border: 0.0625em solid var(--accent);
458}
459
460/* Hide active border when only one pane exists */
461.single-pane .pane-active {
462 border-color: transparent;
463}
464
465/* Pane mode active - thick blue inset border (for overlay) */
466.pane-mode-active {
467 background-color: transparent;
468 box-shadow: inset 0 0 0 0.25em #4A90E2;
469 border-radius: 0;
470}
471
472/* Tab mode active - thick orange inset border (for overlay) */
473.tab-mode-active {
474 background-color: transparent;
475 box-shadow: inset 0 0 0 0.25em #FFA500;
476 border-radius: 0;
477}
478
479/* Resize mode active - thick teal inset border */
480.resize-mode-active {
481 background-color: transparent;
482 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
483 box-shadow: inset 0 0 0 0.25em #00D4AA;
484 outline: 0.25em solid #00D4AA;
485 outline-offset: -0.25em;
486 border-radius: 0;
487}
488`
489}
490
491// generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles.
492// Uses em units for scalable UI.
493func generateStackedPaneCSS(p Palette) string {
494 return `/* ===== Stacked Pane Styling ===== */
495
496/* Title bar for inactive panes in a stack */
497.stacked-pane-titlebar {
498 background-color: var(--surface-variant);
499 border-bottom: 0.0625em solid var(--border);
500 padding: 0.25em 0.5em;
501 min-height: 1.5em;
502}
503
504/* Title bar button wrapper - clickable area */
505button.stacked-pane-title-button {
506 background-color: var(--surface-variant);
507 background-image: none;
508 border: none;
509 border-bottom: 0.0625em solid var(--border);
510 border-radius: 0;
511 padding: 0;
512 margin: 0;
513 transition: background-color 150ms ease-in-out;
514}
515
516button.stacked-pane-title-button:hover {
517 background-color: shade(var(--surface-variant), 1.15);
518}
519
520button.stacked-pane-title-button:focus {
521 outline: none;
522 box-shadow: none;
523}
524
525/* Title bar content box */
526.stacked-pane-titlebar.active {
527 background-color: shade(var(--surface-variant), 1.2);
528 border-left: 0.1875em solid var(--accent);
529}
530
531/* Favicon image in title bar */
532.stacked-pane-titlebar image {
533 min-width: 1em;
534 min-height: 1em;
535 margin-right: 0.375em;
536}
537
538/* Title text in title bar */
539.stacked-pane-titlebar label {
540 color: var(--text);
541 font-size: 0.75em;
542 font-weight: 400;
543}
544
545button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
546 color: var(--accent);
547}
548`
549}
550
551// generateProgressBarCSS creates progress bar styles for page loading indication.
552func generateProgressBarCSS(p Palette) string {
553 return `/* ===== Progress Bar Styling ===== */
554
555/* Native GtkProgressBar with osd class */
556progressbar.osd {
557 min-height: 4px;
558}
559
560progressbar.osd trough {
561 min-height: 4px;
562 min-width: 2px;
563 margin: 0;
564 padding: 0;
565 background-color: alpha(var(--bg), 0.3);
566}
567
568progressbar.osd progress {
569 min-height: 4px;
570 min-width: 2px;
571 margin: 0;
572 padding: 0;
573 background-color: var(--accent);
574}
575`
576}
577
578// generateSessionManagerCSS creates session manager modal styles.
579// Uses em units for scalable UI, matches omnibox styling patterns.
580func generateSessionManagerCSS(p Palette) string {
581 return `/* ===== Session Manager Styling ===== */
582
583/* Session manager outer container - for positioning in overlay */
584.session-manager-outer {
585 /* Positioning is handled via SetHalign/SetValign in Go */
586}
587
588/* Session manager main container - the visible popup */
589.session-manager-container {
590 background-color: var(--surface-variant);
591 border: 0.0625em solid var(--border);
592 border-radius: 0.1875em;
593 padding: 0;
594 min-width: 28em;
595 /* Note: max-width not supported in GTK4 CSS, rely on container constraints */
596}
597
598/* Header with title */
599.session-manager-header {
600 background-color: shade(var(--surface-variant), 1.1);
601 border-bottom: 0.0625em solid var(--border);
602 padding: 0.5em 0.75em;
603}
604
605.session-manager-title {
606 font-size: 0.9375em;
607 font-weight: 600;
608 color: var(--text);
609}
610
611/* Scrolled window for session list */
612.session-manager-scrolled {
613 background-color: shade(var(--surface-variant), 0.95);
614}
615
616/* List box */
617.session-manager-list {
618 background-color: transparent;
619}
620
621/* Session rows */
622.session-manager-row {
623 padding: 0.5em 0.75em;
624 margin: 0;
625 border-radius: 0;
626 border-left: 0.1875em solid transparent;
627 border-bottom: 0.0625em solid alpha(var(--border), 0.5);
628 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
629 min-height: 2.75em;
630}
631
632.session-manager-row:last-child {
633 border-bottom: none;
634}
635
636.session-manager-row:hover {
637 background-color: alpha(var(--accent), 0.12);
638 border-left: 0.1875em solid var(--accent);
639}
640
641.session-manager-row:selected,
642row:selected .session-manager-row {
643 background-color: alpha(var(--accent), 0.2);
644 border-left: 0.1875em solid var(--accent);
645}
646
647/* Status indicator dot */
648.session-status {
649 font-size: 0.875em;
650 min-width: 1em;
651 margin-right: 0.5em;
652}
653
654/* Current session - accent color dot */
655.session-current .session-status {
656 color: var(--accent);
657}
658
659/* Active session (other instance) - muted dot */
660.session-active .session-status {
661 color: var(--muted);
662}
663
664/* Exited session - dimmed appearance with subtle stop icon */
665.session-exited {
666 opacity: 0.75;
667}
668
669.session-exited .session-status {
670 color: var(--error);
671 opacity: 0.5;
672}
673
674/* Session ID labels */
675.session-id-short {
676 font-size: 0.875em;
677 font-weight: 600;
678 color: var(--accent);
679 font-family: var(--font-mono);
680}
681
682.session-id-prefix {
683 font-size: 0.875em;
684 font-weight: 400;
685 color: var(--muted);
686 font-family: var(--font-mono);
687}
688
689.session-id-badge {
690 font-size: 0.75em;
691 font-weight: 500;
692 color: var(--accent);
693 margin-left: 0.5em;
694}
695
696/* Tab/pane count label */
697.session-count {
698 font-size: 0.75em;
699 color: var(--muted);
700}
701
702/* Relative time label */
703.session-time {
704 font-size: 0.75em;
705 color: var(--muted);
706 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
707}
708
709/* Section divider for EXITED sessions */
710.session-divider {
711 font-size: 0.6875em;
712 font-weight: 600;
713 color: var(--muted);
714 text-transform: uppercase;
715 letter-spacing: 0.05em;
716 padding: 0.5em 0.75em 0.25em 0.75em;
717 background-color: alpha(var(--border), 0.3);
718 border-bottom: 0.0625em solid var(--border);
719}
720
721/* Footer with keyboard shortcuts */
722.session-manager-footer {
723 background-color: shade(var(--surface-variant), 0.9);
724 border-top: 0.0625em solid var(--border);
725 padding: 0.375em 0.75em;
726 font-size: 0.6875em;
727 color: var(--muted);
728 font-family: var(--font-mono);
729}
730
731/* Session mode border (purple) */
732.session-mode-active {
733 background-color: transparent;
734 box-shadow: inset 0 0 0 0.25em #9B59B6;
735 border-radius: 0;
736}
737`
738}
739
740// generateToasterCSS creates toast notification styles.
741// Uses em units for scalable UI.
742func generateToasterCSS(p Palette) string {
743 return `/* ===== Toaster Styling ===== */
744
745/* Toast notification container */
746.toast {
747 background-color: var(--surface-variant);
748 border-radius: 0.25em;
749 padding: 0.375em 0.625em;
750 margin: 0.5em;
751 font-size: 0.875em;
752 font-weight: 600;
753 box-shadow: 0 2px 8px alpha(black, 0.3);
754 min-width: 2.5em;
755}
756
757/* Toast level: info (default, accent color) */
758.toast-info {
759 background-color: alpha(var(--accent), 0.9);
760 color: var(--bg);
761}
762
763/* Toast level: success */
764.toast-success {
765 background-color: alpha(var(--success), 0.9);
766 color: var(--bg);
767}
768
769/* Toast level: warning */
770.toast-warning {
771 background-color: alpha(var(--warning), 0.9);
772 color: var(--bg);
773}
774
775/* Toast level: error */
776.toast-error {
777 background-color: alpha(var(--destructive), 0.9);
778 color: var(--bg);
779}
780`
781}
782
783// generateLinkStatusCSS creates link status overlay styles.
784// Uses CSS transitions for fade-in/fade-out effect.
785func generateLinkStatusCSS(p Palette) string {
786 return `/* ===== Link Status Overlay Styling ===== */
787
788/* Link status container - bottom left positioning */
789.link-status {
790 background-color: alpha(var(--surface-variant), 0.95);
791 border-radius: 0.25em 0.25em 0 0;
792 padding: 0.25em 0.5em;
793 margin: 0;
794 font-size: 0.75em;
795 /* Note: max-width not supported in GTK4 CSS, use label SetMaxWidthChars instead */
796 box-shadow: 0 -1px 4px alpha(black, 0.15);
797
798 /* Fade transition - hidden by default */
799 opacity: 0;
800 transition: opacity 150ms ease-in-out;
801}
802
803/* Visible state - triggered by adding .visible class */
804.link-status.visible {
805 opacity: 1;
806}
807
808/* Link status label text */
809.link-status label {
810 color: var(--muted);
811}
812`
813}