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}