v0.30.0 eb3068f

theme.go RawHistoryFolder

1package entity
2
3import "regexp"
4
5// ThemeSourceKind describes where the resolved palette colors originated.
6type ThemeSourceKind string
7
8const (
9	// ThemeSourceDefault indicates palette colors come from built-in defaults.
10	ThemeSourceDefault ThemeSourceKind = "default"
11	// ThemeSourceConfig indicates palette colors come from user configuration.
12	ThemeSourceConfig ThemeSourceKind = "config"
13	// ThemeSourceExternal indicates palette colors come from an external theme source.
14	ThemeSourceExternal ThemeSourceKind = "external"
15)
16
17// ThemeSourceMetadata describes the palette source used for a resolved theme.
18type ThemeSourceMetadata struct {
19	Kind     ThemeSourceKind
20	Provider string
21	LastGood bool
22}
23
24// ThemeWarning represents a non-fatal issue encountered during theme resolution.
25type ThemeWarning struct {
26	Field   string
27	Message string
28}
29
30// ThemeFonts holds the resolved font families and default size.
31type ThemeFonts struct {
32	SansFont      string
33	SerifFont     string
34	MonospaceFont string
35	GtkFont       string
36	DefaultSize   int
37}
38
39// ThemeModeColors holds mode indicator colors.
40// All values must be CSS-safe hex colors (e.g., "#4A90E2").
41type ThemeModeColors struct {
42	PaneMode    string
43	TabMode     string
44	SessionMode string
45	ResizeMode  string
46}
47
48// ResolvedThemePalette is a CSS-safe hex color palette.
49// Alias for ColorPalette with the contract that all color strings
50// are valid CSS hex colors (validated during resolution).
51type ResolvedThemePalette = ColorPalette
52
53// ExternalTheme represents palette data from an external source (e.g., Noctalia).
54// External themes override palette colors only; fonts, UI scale, and mode colors stay in Dumber config.
55// It must not depend on GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config.
56type ExternalTheme struct {
57	Name     string
58	Provider string
59
60	// LightPalette and DarkPalette may contain partial overrides.
61	// Nil palette pointers are malformed; empty fields mean "use the resolved config/default field".
62	LightPalette *ColorPalette
63	DarkPalette  *ColorPalette
64}
65
66// ResolvedTheme is the final resolved theme output.
67// It contains palettes, fonts, colors, scale, and metadata.
68// All palette colors are guaranteed to be CSS-safe hex colors.
69// CSS generation is NOT part of this core type — it is a separate adapter concern.
70type ResolvedTheme struct {
71	LightPalette      ResolvedThemePalette
72	DarkPalette       ResolvedThemePalette
73	ActivePalette     ResolvedThemePalette
74	PrefersDark       bool
75	ColorSchemeSource string
76	ThemeSource       ThemeSourceMetadata
77	Fonts             ThemeFonts
78	UIScale           float64
79	ModeColors        ThemeModeColors
80	Warnings          []ThemeWarning
81}
82
83// hexColorRegex matches valid CSS-safe #RRGGBB hex colors (strict).
84var hexColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
85
86// IsValidHex returns true if s is a valid CSS-safe hex color (#RRGGBB format only).
87func IsValidHex(s string) bool {
88	return hexColorRegex.MatchString(s)
89}
90
91// DefaultThemeFonts returns Dumber's appearance font defaults.
92func DefaultThemeFonts() ThemeFonts {
93	return ThemeFonts{
94		SansFont:      "Fira Sans",
95		SerifFont:     "Fira Sans",
96		MonospaceFont: "Fira Code",
97		GtkFont:       "Adwaita Sans",
98		DefaultSize:   16,
99	}
100}
101
102// DefaultThemeModeColors returns the default mode indicator colors.
103// These match the existing default mode colors from config/infrastructure.
104func DefaultThemeModeColors() ThemeModeColors {
105	return ThemeModeColors{
106		PaneMode:    "#4A90E2",
107		TabMode:     "#FFA500",
108		SessionMode: "#9B59B6",
109		ResizeMode:  "#00D4AA",
110	}
111}
112
113// DefaultDarkPalette returns the built-in dark theme palette.
114// These values mirror the config default dark palette.
115func DefaultDarkPalette() ColorPalette {
116	return ColorPalette{
117		Background:     "#0a0a0b",
118		Surface:        "#18181b",
119		SurfaceVariant: "#27272a",
120		Text:           "#fafafa",
121		Muted:          "#a1a1aa",
122		Accent:         "#4ade80",
123		Border:         "#3f3f46",
124	}
125}
126
127// DefaultLightPalette returns the built-in light theme palette.
128// These values mirror the config default light palette.
129func DefaultLightPalette() ColorPalette {
130	return ColorPalette{
131		Background:     "#fafafa",
132		Surface:        "#f4f4f5",
133		SurfaceVariant: "#e4e4e7",
134		Text:           "#18181b",
135		Muted:          "#71717a",
136		Accent:         "#22c55e",
137		Border:         "#d4d4d8",
138	}
139}
140
141// ValidatePaletteHex checks every field in the palette is a valid #RRGGBB hex color.
142// Returns a slice of warnings for each invalid field. Empty slice means valid.
143func ValidatePaletteHex(p *ColorPalette, prefix string) []ThemeWarning {
144	if p == nil {
145		return []ThemeWarning{{Field: prefix, Message: "palette is nil"}}
146	}
147	var warnings []ThemeWarning
148	checkRequiredHex(&warnings, prefix, "background", p.Background)
149	checkRequiredHex(&warnings, prefix, "surface", p.Surface)
150	checkRequiredHex(&warnings, prefix, "surface_variant", p.SurfaceVariant)
151	checkRequiredHex(&warnings, prefix, "text", p.Text)
152	checkRequiredHex(&warnings, prefix, "muted", p.Muted)
153	checkRequiredHex(&warnings, prefix, "accent", p.Accent)
154	checkRequiredHex(&warnings, prefix, "border", p.Border)
155	return warnings
156}
157
158// ValidatePaletteOverrideHex checks non-empty override fields are valid #RRGGBB hex colors.
159// Empty fields are accepted and mean "inherit from the fallback palette".
160func ValidatePaletteOverrideHex(p *ColorPalette, prefix string) []ThemeWarning {
161	if p == nil {
162		return []ThemeWarning{{Field: prefix, Message: "palette is nil"}}
163	}
164	var warnings []ThemeWarning
165	checkOptionalHex(&warnings, prefix, "background", p.Background)
166	checkOptionalHex(&warnings, prefix, "surface", p.Surface)
167	checkOptionalHex(&warnings, prefix, "surface_variant", p.SurfaceVariant)
168	checkOptionalHex(&warnings, prefix, "text", p.Text)
169	checkOptionalHex(&warnings, prefix, "muted", p.Muted)
170	checkOptionalHex(&warnings, prefix, "accent", p.Accent)
171	checkOptionalHex(&warnings, prefix, "border", p.Border)
172	return warnings
173}
174
175func checkRequiredHex(warnings *[]ThemeWarning, prefix, field, value string) {
176	if !IsValidHex(value) {
177		*warnings = append(*warnings, ThemeWarning{
178			Field:   prefix + "." + field,
179			Message: "not a valid CSS-safe hex color (#RRGGBB)",
180		})
181	}
182}
183
184func checkOptionalHex(warnings *[]ThemeWarning, prefix, field, value string) {
185	if value != "" && !IsValidHex(value) {
186		*warnings = append(*warnings, ThemeWarning{
187			Field:   prefix + "." + field,
188			Message: "not a valid CSS-safe hex color (#RRGGBB)",
189		})
190	}
191}
192
193// IsPaletteValid returns true if every field in the palette is a valid #RRGGBB hex color.
194func IsPaletteValid(p *ColorPalette) bool {
195	return len(ValidatePaletteHex(p, "palette")) == 0
196}
197
198// IsPaletteOverrideValid returns true if every non-empty field in the palette is a valid #RRGGBB hex color.
199func IsPaletteOverrideValid(p *ColorPalette) bool {
200	return len(ValidatePaletteOverrideHex(p, "palette")) == 0
201}
202
203// MergePalette returns a new ColorPalette where empty fields in src are filled from defaults.
204// Both src and defaults may be nil. Returns a copy, never mutates inputs.
205func MergePalette(src, defaults *ColorPalette) ColorPalette {
206	var d ColorPalette
207	if defaults != nil {
208		d = *defaults
209	}
210	if src == nil {
211		return d
212	}
213	result := *src
214	if result.Background == "" {
215		result.Background = d.Background
216	}
217	if result.Surface == "" {
218		result.Surface = d.Surface
219	}
220	if result.SurfaceVariant == "" {
221		result.SurfaceVariant = d.SurfaceVariant
222	}
223	if result.Text == "" {
224		result.Text = d.Text
225	}
226	if result.Muted == "" {
227		result.Muted = d.Muted
228	}
229	if result.Accent == "" {
230		result.Accent = d.Accent
231	}
232	if result.Border == "" {
233		result.Border = d.Border
234	}
235	return result
236}
237
238// MergeFonts returns a ThemeFonts where zero/empty fields in src are filled from defaults.
239func MergeFonts(src *ThemeFonts, defaults ThemeFonts) ThemeFonts {
240	if src == nil {
241		return defaults
242	}
243	result := *src
244	if result.SansFont == "" {
245		result.SansFont = defaults.SansFont
246	}
247	if result.SerifFont == "" {
248		result.SerifFont = defaults.SerifFont
249	}
250	if result.MonospaceFont == "" {
251		result.MonospaceFont = defaults.MonospaceFont
252	}
253	if result.GtkFont == "" {
254		result.GtkFont = defaults.GtkFont
255	}
256	if result.DefaultSize <= 0 {
257		result.DefaultSize = defaults.DefaultSize
258	}
259	return result
260}
261
262// ValidateModeColorsHex checks every mode color field is a valid #RRGGBB hex color.
263func ValidateModeColorsHex(colors *ThemeModeColors, prefix string) []ThemeWarning {
264	if colors == nil {
265		return []ThemeWarning{{Field: prefix, Message: "mode colors are nil"}}
266	}
267	var warnings []ThemeWarning
268	checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode)
269	checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode)
270	checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode)
271	checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode)
272	return warnings
273}
274
275// IsModeColorsValid returns true when every mode color is a valid #RRGGBB hex color.
276func IsModeColorsValid(colors *ThemeModeColors) bool {
277	return len(ValidateModeColorsHex(colors, "mode_colors")) == 0
278}
279
280// MergeModeColors returns a ThemeModeColors where empty fields in src are filled from defaults.
281func MergeModeColors(src *ThemeModeColors, defaults ThemeModeColors) ThemeModeColors {
282	if src == nil {
283		return defaults
284	}
285	result := *src
286	if result.PaneMode == "" {
287		result.PaneMode = defaults.PaneMode
288	}
289	if result.TabMode == "" {
290		result.TabMode = defaults.TabMode
291	}
292	if result.SessionMode == "" {
293		result.SessionMode = defaults.SessionMode
294	}
295	if result.ResizeMode == "" {
296		result.ResizeMode = defaults.ResizeMode
297	}
298	return result
299}

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