v0.21.0 73bf644

css.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close