v0.30.0 eb3068f

palette.go RawHistoryFolder

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}

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