v0.20.0 c741a39

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/infrastructure/config"
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// DefaultDarkPalette returns the default dark theme palette.
28func DefaultDarkPalette() Palette {
29	return Palette{
30		Background:     "#0a0a0b",
31		Surface:        "#1a1a1b",
32		SurfaceVariant: "#2d2d2d",
33		Text:           "#ffffff",
34		Muted:          "#909090",
35		Accent:         "#4ade80",
36		Border:         "#333333",
37		Success:        "#4ade80",
38		Warning:        "#fbbf24",
39		Destructive:    "#ef4444",
40	}
41}
42
43// DefaultLightPalette returns the default light theme palette.
44func DefaultLightPalette() Palette {
45	return Palette{
46		Background:     "#fafafa",
47		Surface:        "#ffffff",
48		SurfaceVariant: "#f0f0f0",
49		Text:           "#1a1a1a",
50		Muted:          "#666666",
51		Accent:         "#22c55e",
52		Border:         "#dddddd",
53		Success:        "#22c55e",
54		Warning:        "#f59e0b",
55		Destructive:    "#dc2626",
56	}
57}
58
59// PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
60func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
61	var defaults Palette
62	if isDark {
63		defaults = DefaultDarkPalette()
64	} else {
65		defaults = DefaultLightPalette()
66	}
67
68	if cfg == nil {
69		return defaults
70	}
71
72	return Palette{
73		Background:     Coalesce(cfg.Background, defaults.Background),
74		Surface:        Coalesce(cfg.Surface, defaults.Surface),
75		SurfaceVariant: Coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
76		Text:           Coalesce(cfg.Text, defaults.Text),
77		Muted:          Coalesce(cfg.Muted, defaults.Muted),
78		Accent:         Coalesce(cfg.Accent, defaults.Accent),
79		Border:         Coalesce(cfg.Border, defaults.Border),
80		// Semantic colors always use defaults (not user-editable)
81		Success:     defaults.Success,
82		Warning:     defaults.Warning,
83		Destructive: defaults.Destructive,
84	}
85}
86
87// Coalesce returns the first non-empty string.
88func Coalesce(values ...string) string {
89	for _, v := range values {
90		if v != "" {
91			return v
92		}
93	}
94	return ""
95}
96
97// hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
98var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
99
100// ValidateHexColor checks if a string is a valid hex color.
101func ValidateHexColor(color string) error {
102	if color == "" {
103		return nil // Empty is valid (will use default)
104	}
105	if !hexColorRegex.MatchString(color) {
106		return fmt.Errorf("invalid hex color: %s", color)
107	}
108	return nil
109}
110
111// Validate checks all palette colors are valid hex values.
112func (p Palette) Validate() error {
113	colors := map[string]string{
114		"background":      p.Background,
115		"surface":         p.Surface,
116		"surface_variant": p.SurfaceVariant,
117		"text":            p.Text,
118		"muted":           p.Muted,
119		"accent":          p.Accent,
120		"border":          p.Border,
121	}
122
123	for name, color := range colors {
124		if err := ValidateHexColor(color); err != nil {
125			return fmt.Errorf("%s: %w", name, err)
126		}
127	}
128	return nil
129}
130
131// ToCSSVars generates CSS custom property declarations for GTK.
132func (p Palette) ToCSSVars() string {
133	var sb strings.Builder
134	sb.WriteString("  --bg: " + p.Background + ";\n")
135	sb.WriteString("  --surface: " + p.Surface + ";\n")
136	sb.WriteString("  --surface-variant: " + p.SurfaceVariant + ";\n")
137	sb.WriteString("  --text: " + p.Text + ";\n")
138	sb.WriteString("  --muted: " + p.Muted + ";\n")
139	sb.WriteString("  --accent: " + p.Accent + ";\n")
140	sb.WriteString("  --border: " + p.Border + ";\n")
141	sb.WriteString("  --success: " + p.Success + ";\n")
142	sb.WriteString("  --warning: " + p.Warning + ";\n")
143	sb.WriteString("  --destructive: " + p.Destructive + ";\n")
144	return sb.String()
145}
146
147// ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
148// Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
149func (p Palette) ToWebCSSVars() string {
150	var sb strings.Builder
151	// Tailwind-compatible variables
152	sb.WriteString("  --background: " + p.Background + " !important;\n")
153	sb.WriteString("  --foreground: " + p.Text + " !important;\n")
154	sb.WriteString("  --card: " + p.Surface + " !important;\n")
155	sb.WriteString("  --card-foreground: " + p.Text + " !important;\n")
156	sb.WriteString("  --primary: " + p.Accent + " !important;\n")
157	sb.WriteString("  --primary-foreground: " + p.Background + " !important;\n")
158	sb.WriteString("  --muted: " + p.SurfaceVariant + " !important;\n")
159	sb.WriteString("  --muted-foreground: " + p.Muted + " !important;\n")
160	sb.WriteString("  --border: " + p.Border + " !important;\n")
161	// Ring follows primary (accent)
162	sb.WriteString("  --ring: " + p.Accent + " !important;\n")
163	// Semantic colors from palette defaults
164	sb.WriteString("  --success: " + p.Success + " !important;\n")
165	sb.WriteString("  --success-foreground: " + p.Background + " !important;\n")
166	sb.WriteString("  --warning: " + p.Warning + " !important;\n")
167	sb.WriteString("  --warning-foreground: " + p.Text + " !important;\n")
168	sb.WriteString("  --destructive: " + p.Destructive + " !important;\n")
169	sb.WriteString("  --destructive-foreground: " + p.Background + " !important;\n")
170	// Keep GTK names for compatibility
171	sb.WriteString("  --bg: " + p.Background + " !important;\n")
172	sb.WriteString("  --surface: " + p.Surface + " !important;\n")
173	sb.WriteString("  --surface-variant: " + p.SurfaceVariant + " !important;\n")
174	sb.WriteString("  --text: " + p.Text + " !important;\n")
175	sb.WriteString("  --accent: " + p.Accent + " !important;\n")
176	return sb.String()
177}

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