main 5041353

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			VimMode:     styling.VimModeColor,
115			TabMode:     styling.TabModeColor,
116			SessionMode: styling.SessionModeColor,
117			ResizeMode:  styling.ResizeModeColor,
118		}
119		input.ModeColors = &modeColors
120	}
121
122	return input
123}
124
125// ColorSchemePreferenceFromConfig converts explicit config color-scheme values into
126// a resolver preference, falling back when config asks for the default/system value.
127func ColorSchemePreferenceFromConfig(colorScheme string, fallback port.ColorSchemePreference) port.ColorSchemePreference {
128	switch strings.ToLower(strings.TrimSpace(colorScheme)) {
129	case "prefer-dark", "dark":
130		return port.ColorSchemePreference{PrefersDark: true, Source: "config"}
131	case "prefer-light", "light":
132		return port.ColorSchemePreference{PrefersDark: false, Source: "config"}
133	default:
134		return fallback
135	}
136}
137
138// Refresh resolves the active theme for config reloads and future external-theme reloads.
139func (uc *ResolveThemeUseCase) Refresh(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) {
140	return uc.Execute(ctx, input)
141}
142
143// Execute resolves the active theme.
144func (uc *ResolveThemeUseCase) Execute(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) {
145	uc.mu.Lock()
146	defer uc.mu.Unlock()
147
148	base := uc.resolveBase(input)
149	lightPalette := base.lightPalette
150	darkPalette := base.darkPalette
151	warnings := base.warnings
152	themeSource := base.themeSource
153
154	if uc.external != nil && uc.external.IsEnabled() {
155		uc.syncExternalIdentity(externalThemeIdentity(uc.external))
156		ext, err := uc.external.Get(ctx)
157		if err != nil {
158			lightPalette, darkPalette, themeSource, warnings = uc.handleExternalError(err, lightPalette, darkPalette, themeSource, warnings)
159		} else if ext != nil {
160			lightPalette, darkPalette, themeSource, warnings = uc.applyExternalTheme(ext, lightPalette, darkPalette, themeSource, warnings)
161		} else {
162			lightPalette, darkPalette, themeSource, warnings = uc.handleMissingExternalTheme(lightPalette, darkPalette, themeSource, warnings)
163		}
164	} else {
165		uc.clearLastGoodExternal()
166	}
167
168	return ResolveThemeOutput{
169		Theme: buildResolvedTheme(input, lightPalette, darkPalette, base.fonts, base.modeColors, base.uiScale, themeSource, warnings),
170	}, nil
171}
172
173type resolvedThemeBase struct {
174	lightPalette entity.ColorPalette
175	darkPalette  entity.ColorPalette
176	fonts        entity.ThemeFonts
177	modeColors   entity.ThemeModeColors
178	uiScale      float64
179	themeSource  entity.ThemeSourceMetadata
180	warnings     []entity.ThemeWarning
181}
182
183func (*ResolveThemeUseCase) resolveBase(input ResolveThemeInput) resolvedThemeBase {
184	lightDefault := entity.DefaultLightPalette()
185	darkDefault := entity.DefaultDarkPalette()
186	lightPalette := entity.MergePalette(input.LightPalette, &lightDefault)
187	darkPalette := entity.MergePalette(input.DarkPalette, &darkDefault)
188
189	fonts := entity.MergeFonts(input.Fonts, entity.DefaultThemeFonts())
190	modeColors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors())
191	uiScale := input.UIScale
192	if uiScale <= 0 {
193		uiScale = 1.0
194	}
195
196	themeSourceKind := entity.ThemeSourceConfig
197	if input.LightPalette == nil && input.DarkPalette == nil {
198		themeSourceKind = entity.ThemeSourceDefault
199	}
200
201	return resolvedThemeBase{
202		lightPalette: lightPalette,
203		darkPalette:  darkPalette,
204		fonts:        fonts,
205		modeColors:   modeColors,
206		uiScale:      uiScale,
207		themeSource: entity.ThemeSourceMetadata{
208			Kind: themeSourceKind,
209		},
210	}
211}
212
213func (uc *ResolveThemeUseCase) clearLastGoodExternal() {
214	uc.lastGoodExternal = nil
215	uc.lastExternalIdentity = ""
216}
217
218func (uc *ResolveThemeUseCase) syncExternalIdentity(identity string) {
219	if identity == "" {
220		return
221	}
222	if uc.lastExternalIdentity != "" && uc.lastExternalIdentity != identity {
223		uc.lastGoodExternal = nil
224	}
225	uc.lastExternalIdentity = identity
226}
227
228func externalThemeIdentity(source port.ExternalThemeSource) string {
229	identityProvider, ok := source.(port.ExternalThemeIdentityProvider)
230	if !ok {
231		return ""
232	}
233	return identityProvider.ExternalThemeIdentity()
234}
235
236func (uc *ResolveThemeUseCase) handleMissingExternalTheme(
237	lightPalette, darkPalette entity.ColorPalette,
238	themeSource entity.ThemeSourceMetadata,
239	warnings []entity.ThemeWarning,
240) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
241	if uc.lastGoodExternal != nil {
242		warnings = append(warnings, entity.ThemeWarning{
243			Field:   "external",
244			Message: "external theme is missing, using last-good",
245		})
246		return uc.lastGoodPalettes(themeSource, warnings)
247	}
248
249	warnings = append(warnings, entity.ThemeWarning{
250		Field:   "external",
251		Message: "external theme is missing, using config/defaults",
252	})
253	return lightPalette, darkPalette, themeSource, warnings
254}
255
256func (uc *ResolveThemeUseCase) handleExternalError(
257	err error,
258	lightPalette, darkPalette entity.ColorPalette,
259	themeSource entity.ThemeSourceMetadata,
260	warnings []entity.ThemeWarning,
261) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
262	if uc.lastGoodExternal != nil {
263		warnings = append(warnings, entity.ThemeWarning{
264			Field:   "external",
265			Message: "failed to fetch external theme: " + err.Error() + ", using last-good",
266		})
267		return uc.lastGoodPalettes(themeSource, warnings)
268	}
269
270	warnings = append(warnings, entity.ThemeWarning{
271		Field:   "external",
272		Message: "failed to fetch external theme: " + err.Error(),
273	})
274	return lightPalette, darkPalette, themeSource, warnings
275}
276
277func (uc *ResolveThemeUseCase) applyExternalTheme(
278	ext *entity.ExternalTheme,
279	lightPalette, darkPalette entity.ColorPalette,
280	themeSource entity.ThemeSourceMetadata,
281	warnings []entity.ThemeWarning,
282) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
283	validationWarnings := externalThemeWarnings(ext)
284	if len(validationWarnings) > 0 {
285		if uc.lastGoodExternal != nil {
286			warnings = append(warnings, entity.ThemeWarning{
287				Field:   "external",
288				Message: "malformed external theme update, keeping last-good",
289			})
290			warnings = append(warnings, validationWarnings...)
291			return uc.lastGoodPalettes(themeSource, warnings)
292		}
293
294		warnings = append(warnings, entity.ThemeWarning{
295			Field:   "external",
296			Message: "malformed external theme, using config/defaults",
297		})
298		warnings = append(warnings, validationWarnings...)
299		return lightPalette, darkPalette, themeSource, warnings
300	}
301
302	// External palettes are partial overrides. Empty fields fall back through a
303	// repaired config/default palette so one invalid config value cannot discard
304	// otherwise valid external overrides or poison the last-good cache.
305	lightPalette, warnings = paletteWithValidFallbackFields(lightPalette, entity.DefaultLightPalette(), "light_palette", warnings)
306	darkPalette, warnings = paletteWithValidFallbackFields(darkPalette, entity.DefaultDarkPalette(), "dark_palette", warnings)
307	resolvedLight := entity.MergePalette(ext.LightPalette, &lightPalette)
308	resolvedDark := entity.MergePalette(ext.DarkPalette, &darkPalette)
309
310	provider := ext.Provider
311	lightCopy := resolvedLight
312	darkCopy := resolvedDark
313	uc.lastGoodExternal = &entity.ExternalTheme{
314		Name:         ext.Name,
315		Provider:     provider,
316		LightPalette: &lightCopy,
317		DarkPalette:  &darkCopy,
318	}
319
320	return resolvedLight, resolvedDark, entity.ThemeSourceMetadata{
321		Kind:     entity.ThemeSourceExternal,
322		Provider: provider,
323		LastGood: false,
324	}, warnings
325}
326
327func externalThemeWarnings(ext *entity.ExternalTheme) []entity.ThemeWarning {
328	if ext == nil {
329		return []entity.ThemeWarning{{Field: "external", Message: "theme is nil"}}
330	}
331	var warnings []entity.ThemeWarning
332	warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.LightPalette, "external.light_palette")...)
333	warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.DarkPalette, "external.dark_palette")...)
334	return warnings
335}
336
337func paletteWithValidFallbackFields(
338	palette entity.ColorPalette,
339	fallback entity.ColorPalette,
340	fieldPrefix string,
341	warnings []entity.ThemeWarning,
342) (entity.ColorPalette, []entity.ThemeWarning) {
343	if entity.IsPaletteValid(&palette) {
344		return palette, warnings
345	}
346	warnings = append(warnings, entity.ThemeWarning{
347		Field:   fieldPrefix,
348		Message: "resolved config palette contained invalid colors, using built-in defaults for invalid fields",
349	})
350	if !entity.IsValidHex(palette.Background) {
351		palette.Background = fallback.Background
352	}
353	if !entity.IsValidHex(palette.Surface) {
354		palette.Surface = fallback.Surface
355	}
356	if !entity.IsValidHex(palette.SurfaceVariant) {
357		palette.SurfaceVariant = fallback.SurfaceVariant
358	}
359	if !entity.IsValidHex(palette.Text) {
360		palette.Text = fallback.Text
361	}
362	if !entity.IsValidHex(palette.Muted) {
363		palette.Muted = fallback.Muted
364	}
365	if !entity.IsValidHex(palette.Accent) {
366		palette.Accent = fallback.Accent
367	}
368	if !entity.IsValidHex(palette.Border) {
369		palette.Border = fallback.Border
370	}
371	return palette, warnings
372}
373
374func (uc *ResolveThemeUseCase) lastGoodPalettes(
375	fallback entity.ThemeSourceMetadata,
376	warnings []entity.ThemeWarning,
377) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
378	if uc.lastGoodExternal == nil || uc.lastGoodExternal.LightPalette == nil || uc.lastGoodExternal.DarkPalette == nil {
379		return entity.DefaultLightPalette(), entity.DefaultDarkPalette(), fallback, warnings
380	}
381
382	return *uc.lastGoodExternal.LightPalette, *uc.lastGoodExternal.DarkPalette, entity.ThemeSourceMetadata{
383		Kind:     entity.ThemeSourceExternal,
384		Provider: uc.lastGoodExternal.Provider,
385		LastGood: true,
386	}, warnings
387}
388
389func buildResolvedTheme(
390	input ResolveThemeInput,
391	lightPalette, darkPalette entity.ColorPalette,
392	fonts entity.ThemeFonts,
393	modeColors entity.ThemeModeColors,
394	uiScale float64,
395	themeSource entity.ThemeSourceMetadata,
396	warnings []entity.ThemeWarning,
397) entity.ResolvedTheme {
398	if !entity.IsPaletteValid(&lightPalette) {
399		warnings = append(warnings, entity.ThemeWarning{
400			Field:   "light_palette",
401			Message: "resolved light palette was invalid, using built-in default",
402		})
403		lightPalette = entity.DefaultLightPalette()
404	}
405	if !entity.IsPaletteValid(&darkPalette) {
406		warnings = append(warnings, entity.ThemeWarning{
407			Field:   "dark_palette",
408			Message: "resolved dark palette was invalid, using built-in default",
409		})
410		darkPalette = entity.DefaultDarkPalette()
411	}
412	if !entity.IsModeColorsValid(&modeColors) {
413		warnings = append(warnings, entity.ThemeWarning{
414			Field:   "mode_colors",
415			Message: "resolved mode colors were invalid, using built-in defaults",
416		})
417		modeColors = entity.DefaultThemeModeColors()
418	}
419
420	activePalette := lightPalette
421	if input.PrefersDark {
422		activePalette = darkPalette
423	}
424
425	return entity.ResolvedTheme{
426		LightPalette:      lightPalette,
427		DarkPalette:       darkPalette,
428		ActivePalette:     activePalette,
429		PrefersDark:       input.PrefersDark,
430		ColorSchemeSource: input.ColorSchemeSource,
431		ThemeSource:       themeSource,
432		Fonts:             fonts,
433		UIScale:           uiScale,
434		ModeColors:        modeColors,
435		Warnings:          warnings,
436	}
437}

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