main 5041353

palette.go RawHistoryFolder

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}

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