v0.30.0 eb3068f

resolve_theme.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"strings"
6	"sync"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/domain/entity"
10)
11
12// ResolveThemeInput contains the inputs needed to resolve the active theme.
13// It accepts config-level overrides and delegates to external sources via port.
14type ResolveThemeInput struct {
15	// ColorScheme is the user-configured color scheme preference.
16	// Values: "default", "prefer-dark", "prefer-light", "dark", "light".
17	// Phase 2 expects callers to resolve this into PrefersDark before invoking the usecase.
18	ColorScheme string
19
20	// PrefersDark is the detected system color scheme preference.
21	PrefersDark bool
22
23	// ColorSchemeSource identifies which detector provided the color scheme preference.
24	// Examples: "config", "adwaita", "gsettings", "env", "fallback".
25	ColorSchemeSource string
26
27	// LightPalette from user config (may be empty/nil).
28	LightPalette *entity.ColorPalette
29
30	// DarkPalette from user config (may be empty/nil).
31	DarkPalette *entity.ColorPalette
32
33	// Fonts from user config (may be nil for defaults).
34	Fonts *entity.ThemeFonts
35
36	// UIScale from GTK/display settings.
37	UIScale float64
38
39	// ModeColors from user config (may be nil for defaults).
40	ModeColors *entity.ThemeModeColors
41}
42
43// ResolveThemeOutput is the result of theme resolution.
44type ResolveThemeOutput struct {
45	// Theme is the resolved theme.
46	Theme entity.ResolvedTheme
47}
48
49// ResolveThemeUseCase resolves the active theme by merging config defaults,
50// user configuration, and an optional external theme source.
51//
52// Resolution precedence (locked contract):
53//   - External disabled -> config/default palettes
54//   - Valid external -> external palettes override palette colors only
55//   - Malformed/missing external at startup -> config/default palettes + warning
56//   - Malformed after valid external -> keep last-good external + warning/source metadata
57//   - Disabling external after valid -> clear last-good and use config/defaults
58//
59// CSS generation is NOT part of this usecase.
60// This usecase must NOT import GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config.
61type ResolveThemeUseCase struct {
62	external port.ExternalThemeSource
63	mu       sync.Mutex
64
65	// lastGoodExternal holds the most recent fully-resolved valid external palette.
66	// Used for the "malformed after valid" -> keep last-good contract.
67	lastGoodExternal *entity.ExternalTheme
68
69	// lastExternalIdentity tracks the configured external source identity that produced lastGoodExternal.
70	// If the enabled provider/format/path changes, last-good is invalidated before resolving.
71	lastExternalIdentity string
72}
73
74// NewResolveThemeUseCase creates a new ResolveThemeUseCase.
75// external may be nil if no external theme source is available.
76func NewResolveThemeUseCase(external port.ExternalThemeSource) *ResolveThemeUseCase {
77	return &ResolveThemeUseCase{external: external}
78}
79
80// ResolveThemeInputFromConfig maps domain config shapes plus a detected color-scheme
81// preference into the application usecase input. It performs no precedence/fallback
82// resolution; Execute/Refresh owns that business logic.
83func ResolveThemeInputFromConfig(
84	appearance *entity.AppearanceConfig,
85	uiScale float64,
86	styling *entity.WorkspaceStylingConfig,
87	preference port.ColorSchemePreference,
88) ResolveThemeInput {
89	input := ResolveThemeInput{
90		PrefersDark:       preference.PrefersDark,
91		ColorSchemeSource: preference.Source,
92		UIScale:           uiScale,
93	}
94
95	if appearance != nil {
96		lightPalette := appearance.LightPalette
97		darkPalette := appearance.DarkPalette
98		fonts := entity.ThemeFonts{
99			SansFont:      appearance.SansFont,
100			SerifFont:     appearance.SerifFont,
101			MonospaceFont: appearance.MonospaceFont,
102			GtkFont:       appearance.GtkFont,
103			DefaultSize:   appearance.DefaultFontSize,
104		}
105		input.ColorScheme = appearance.ColorScheme
106		input.LightPalette = &lightPalette
107		input.DarkPalette = &darkPalette
108		input.Fonts = &fonts
109	}
110
111	if styling != nil {
112		modeColors := entity.ThemeModeColors{
113			PaneMode:    styling.PaneModeColor,
114			TabMode:     styling.TabModeColor,
115			SessionMode: styling.SessionModeColor,
116			ResizeMode:  styling.ResizeModeColor,
117		}
118		input.ModeColors = &modeColors
119	}
120
121	return input
122}
123
124// ColorSchemePreferenceFromConfig converts explicit config color-scheme values into
125// a resolver preference, falling back when config asks for the default/system value.
126func ColorSchemePreferenceFromConfig(colorScheme string, fallback port.ColorSchemePreference) port.ColorSchemePreference {
127	switch strings.ToLower(strings.TrimSpace(colorScheme)) {
128	case "prefer-dark", "dark":
129		return port.ColorSchemePreference{PrefersDark: true, Source: "config"}
130	case "prefer-light", "light":
131		return port.ColorSchemePreference{PrefersDark: false, Source: "config"}
132	default:
133		return fallback
134	}
135}
136
137// Refresh resolves the active theme for config reloads and future external-theme reloads.
138func (uc *ResolveThemeUseCase) Refresh(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) {
139	return uc.Execute(ctx, input)
140}
141
142// Execute resolves the active theme.
143func (uc *ResolveThemeUseCase) Execute(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) {
144	uc.mu.Lock()
145	defer uc.mu.Unlock()
146
147	base := uc.resolveBase(input)
148	lightPalette := base.lightPalette
149	darkPalette := base.darkPalette
150	warnings := base.warnings
151	themeSource := base.themeSource
152
153	if uc.external != nil && uc.external.IsEnabled() {
154		uc.syncExternalIdentity(externalThemeIdentity(uc.external))
155		ext, err := uc.external.Get(ctx)
156		if err != nil {
157			lightPalette, darkPalette, themeSource, warnings = uc.handleExternalError(err, lightPalette, darkPalette, themeSource, warnings)
158		} else if ext != nil {
159			lightPalette, darkPalette, themeSource, warnings = uc.applyExternalTheme(ext, lightPalette, darkPalette, themeSource, warnings)
160		} else {
161			lightPalette, darkPalette, themeSource, warnings = uc.handleMissingExternalTheme(lightPalette, darkPalette, themeSource, warnings)
162		}
163	} else {
164		uc.clearLastGoodExternal()
165	}
166
167	return ResolveThemeOutput{
168		Theme: buildResolvedTheme(input, lightPalette, darkPalette, base.fonts, base.modeColors, base.uiScale, themeSource, warnings),
169	}, nil
170}
171
172type resolvedThemeBase struct {
173	lightPalette entity.ColorPalette
174	darkPalette  entity.ColorPalette
175	fonts        entity.ThemeFonts
176	modeColors   entity.ThemeModeColors
177	uiScale      float64
178	themeSource  entity.ThemeSourceMetadata
179	warnings     []entity.ThemeWarning
180}
181
182func (*ResolveThemeUseCase) resolveBase(input ResolveThemeInput) resolvedThemeBase {
183	lightDefault := entity.DefaultLightPalette()
184	darkDefault := entity.DefaultDarkPalette()
185	lightPalette := entity.MergePalette(input.LightPalette, &lightDefault)
186	darkPalette := entity.MergePalette(input.DarkPalette, &darkDefault)
187
188	fonts := entity.MergeFonts(input.Fonts, entity.DefaultThemeFonts())
189	modeColors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors())
190	uiScale := input.UIScale
191	if uiScale <= 0 {
192		uiScale = 1.0
193	}
194
195	themeSourceKind := entity.ThemeSourceConfig
196	if input.LightPalette == nil && input.DarkPalette == nil {
197		themeSourceKind = entity.ThemeSourceDefault
198	}
199
200	return resolvedThemeBase{
201		lightPalette: lightPalette,
202		darkPalette:  darkPalette,
203		fonts:        fonts,
204		modeColors:   modeColors,
205		uiScale:      uiScale,
206		themeSource: entity.ThemeSourceMetadata{
207			Kind: themeSourceKind,
208		},
209	}
210}
211
212func (uc *ResolveThemeUseCase) clearLastGoodExternal() {
213	uc.lastGoodExternal = nil
214	uc.lastExternalIdentity = ""
215}
216
217func (uc *ResolveThemeUseCase) syncExternalIdentity(identity string) {
218	if identity == "" {
219		return
220	}
221	if uc.lastExternalIdentity != "" && uc.lastExternalIdentity != identity {
222		uc.lastGoodExternal = nil
223	}
224	uc.lastExternalIdentity = identity
225}
226
227func externalThemeIdentity(source port.ExternalThemeSource) string {
228	identityProvider, ok := source.(port.ExternalThemeIdentityProvider)
229	if !ok {
230		return ""
231	}
232	return identityProvider.ExternalThemeIdentity()
233}
234
235func (uc *ResolveThemeUseCase) handleMissingExternalTheme(
236	lightPalette, darkPalette entity.ColorPalette,
237	themeSource entity.ThemeSourceMetadata,
238	warnings []entity.ThemeWarning,
239) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
240	if uc.lastGoodExternal != nil {
241		warnings = append(warnings, entity.ThemeWarning{
242			Field:   "external",
243			Message: "external theme is missing, using last-good",
244		})
245		return uc.lastGoodPalettes(themeSource, warnings)
246	}
247
248	warnings = append(warnings, entity.ThemeWarning{
249		Field:   "external",
250		Message: "external theme is missing, using config/defaults",
251	})
252	return lightPalette, darkPalette, themeSource, warnings
253}
254
255func (uc *ResolveThemeUseCase) handleExternalError(
256	err error,
257	lightPalette, darkPalette entity.ColorPalette,
258	themeSource entity.ThemeSourceMetadata,
259	warnings []entity.ThemeWarning,
260) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
261	if uc.lastGoodExternal != nil {
262		warnings = append(warnings, entity.ThemeWarning{
263			Field:   "external",
264			Message: "failed to fetch external theme: " + err.Error() + ", using last-good",
265		})
266		return uc.lastGoodPalettes(themeSource, warnings)
267	}
268
269	warnings = append(warnings, entity.ThemeWarning{
270		Field:   "external",
271		Message: "failed to fetch external theme: " + err.Error(),
272	})
273	return lightPalette, darkPalette, themeSource, warnings
274}
275
276func (uc *ResolveThemeUseCase) applyExternalTheme(
277	ext *entity.ExternalTheme,
278	lightPalette, darkPalette entity.ColorPalette,
279	themeSource entity.ThemeSourceMetadata,
280	warnings []entity.ThemeWarning,
281) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
282	validationWarnings := externalThemeWarnings(ext)
283	if len(validationWarnings) > 0 {
284		if uc.lastGoodExternal != nil {
285			warnings = append(warnings, entity.ThemeWarning{
286				Field:   "external",
287				Message: "malformed external theme update, keeping last-good",
288			})
289			warnings = append(warnings, validationWarnings...)
290			return uc.lastGoodPalettes(themeSource, warnings)
291		}
292
293		warnings = append(warnings, entity.ThemeWarning{
294			Field:   "external",
295			Message: "malformed external theme, using config/defaults",
296		})
297		warnings = append(warnings, validationWarnings...)
298		return lightPalette, darkPalette, themeSource, warnings
299	}
300
301	// External palettes are partial overrides. Empty fields fall back through a
302	// repaired config/default palette so one invalid config value cannot discard
303	// otherwise valid external overrides or poison the last-good cache.
304	lightPalette, warnings = paletteWithValidFallbackFields(lightPalette, entity.DefaultLightPalette(), "light_palette", warnings)
305	darkPalette, warnings = paletteWithValidFallbackFields(darkPalette, entity.DefaultDarkPalette(), "dark_palette", warnings)
306	resolvedLight := entity.MergePalette(ext.LightPalette, &lightPalette)
307	resolvedDark := entity.MergePalette(ext.DarkPalette, &darkPalette)
308
309	provider := ext.Provider
310	lightCopy := resolvedLight
311	darkCopy := resolvedDark
312	uc.lastGoodExternal = &entity.ExternalTheme{
313		Name:         ext.Name,
314		Provider:     provider,
315		LightPalette: &lightCopy,
316		DarkPalette:  &darkCopy,
317	}
318
319	return resolvedLight, resolvedDark, entity.ThemeSourceMetadata{
320		Kind:     entity.ThemeSourceExternal,
321		Provider: provider,
322		LastGood: false,
323	}, warnings
324}
325
326func externalThemeWarnings(ext *entity.ExternalTheme) []entity.ThemeWarning {
327	if ext == nil {
328		return []entity.ThemeWarning{{Field: "external", Message: "theme is nil"}}
329	}
330	var warnings []entity.ThemeWarning
331	warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.LightPalette, "external.light_palette")...)
332	warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.DarkPalette, "external.dark_palette")...)
333	return warnings
334}
335
336func paletteWithValidFallbackFields(
337	palette entity.ColorPalette,
338	fallback entity.ColorPalette,
339	fieldPrefix string,
340	warnings []entity.ThemeWarning,
341) (entity.ColorPalette, []entity.ThemeWarning) {
342	if entity.IsPaletteValid(&palette) {
343		return palette, warnings
344	}
345	warnings = append(warnings, entity.ThemeWarning{
346		Field:   fieldPrefix,
347		Message: "resolved config palette contained invalid colors, using built-in defaults for invalid fields",
348	})
349	if !entity.IsValidHex(palette.Background) {
350		palette.Background = fallback.Background
351	}
352	if !entity.IsValidHex(palette.Surface) {
353		palette.Surface = fallback.Surface
354	}
355	if !entity.IsValidHex(palette.SurfaceVariant) {
356		palette.SurfaceVariant = fallback.SurfaceVariant
357	}
358	if !entity.IsValidHex(palette.Text) {
359		palette.Text = fallback.Text
360	}
361	if !entity.IsValidHex(palette.Muted) {
362		palette.Muted = fallback.Muted
363	}
364	if !entity.IsValidHex(palette.Accent) {
365		palette.Accent = fallback.Accent
366	}
367	if !entity.IsValidHex(palette.Border) {
368		palette.Border = fallback.Border
369	}
370	return palette, warnings
371}
372
373func (uc *ResolveThemeUseCase) lastGoodPalettes(
374	fallback entity.ThemeSourceMetadata,
375	warnings []entity.ThemeWarning,
376) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
377	if uc.lastGoodExternal == nil || uc.lastGoodExternal.LightPalette == nil || uc.lastGoodExternal.DarkPalette == nil {
378		return entity.DefaultLightPalette(), entity.DefaultDarkPalette(), fallback, warnings
379	}
380
381	return *uc.lastGoodExternal.LightPalette, *uc.lastGoodExternal.DarkPalette, entity.ThemeSourceMetadata{
382		Kind:     entity.ThemeSourceExternal,
383		Provider: uc.lastGoodExternal.Provider,
384		LastGood: true,
385	}, warnings
386}
387
388func buildResolvedTheme(
389	input ResolveThemeInput,
390	lightPalette, darkPalette entity.ColorPalette,
391	fonts entity.ThemeFonts,
392	modeColors entity.ThemeModeColors,
393	uiScale float64,
394	themeSource entity.ThemeSourceMetadata,
395	warnings []entity.ThemeWarning,
396) entity.ResolvedTheme {
397	if !entity.IsPaletteValid(&lightPalette) {
398		warnings = append(warnings, entity.ThemeWarning{
399			Field:   "light_palette",
400			Message: "resolved light palette was invalid, using built-in default",
401		})
402		lightPalette = entity.DefaultLightPalette()
403	}
404	if !entity.IsPaletteValid(&darkPalette) {
405		warnings = append(warnings, entity.ThemeWarning{
406			Field:   "dark_palette",
407			Message: "resolved dark palette was invalid, using built-in default",
408		})
409		darkPalette = entity.DefaultDarkPalette()
410	}
411	if !entity.IsModeColorsValid(&modeColors) {
412		warnings = append(warnings, entity.ThemeWarning{
413			Field:   "mode_colors",
414			Message: "resolved mode colors were invalid, using built-in defaults",
415		})
416		modeColors = entity.DefaultThemeModeColors()
417	}
418
419	activePalette := lightPalette
420	if input.PrefersDark {
421		activePalette = darkPalette
422	}
423
424	return entity.ResolvedTheme{
425		LightPalette:      lightPalette,
426		DarkPalette:       darkPalette,
427		ActivePalette:     activePalette,
428		PrefersDark:       input.PrefersDark,
429		ColorSchemeSource: input.ColorSchemeSource,
430		ThemeSource:       themeSource,
431		Fonts:             fonts,
432		UIScale:           uiScale,
433		ModeColors:        modeColors,
434		Warnings:          warnings,
435	}
436}

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