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}