1package theme
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk/v4/gdk"
11 "github.com/bnema/puregotk/v4/gtk"
12)
13
14// Manager handles theme state and CSS application.
15type Manager struct {
16 scheme string // "light", "dark", "system"
17 prefersDark bool // Resolved dark mode preference
18 lightPalette Palette // Light theme colors
19 darkPalette Palette // Dark theme colors
20 uiScale float64 // UI scaling factor (1.0 = 100%)
21 fonts FontConfig
22 modeColors ModeColors // Modal mode indicator colors
23 cssProvider *gtk.CssProvider
24 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
25}
26
27// NewManager creates a new theme manager from configuration.
28// The ColorSchemeResolver is required for proper color scheme detection.
29//
30// appearance controls fonts, palette overrides and color scheme.
31// uiScale is the GTK UI scale factor (0 or negative means use default 1.0).
32// styling controls mode indicator colors; may be nil to use defaults.
33func NewManager(
34 ctx context.Context,
35 appearance *entity.AppearanceConfig,
36 uiScale float64,
37 styling *entity.WorkspaceStylingConfig,
38 resolver port.ColorSchemeResolver,
39) *Manager {
40 log := logging.FromContext(ctx)
41
42 // Determine color scheme preference
43 scheme := "system"
44 if appearance != nil && appearance.ColorScheme != "" {
45 scheme = appearance.ColorScheme
46 }
47
48 // Resolve whether we should use dark mode via resolver
49 pref := resolver.Resolve()
50 prefersDark := pref.PrefersDark
51
52 // Build palettes from config or defaults
53 var lightPalette, darkPalette Palette
54 if appearance != nil {
55 lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
56 darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
57 } else {
58 lightPalette = DefaultLightPalette()
59 darkPalette = DefaultDarkPalette()
60 }
61
62 // Get UI scale factor (default to 1.0 if not set)
63 if uiScale <= 0 {
64 uiScale = 1.0
65 }
66
67 fonts := DefaultFontConfig()
68 if appearance != nil {
69 fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont)
70 fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont)
71 }
72
73 // Build mode colors from config
74 modeColors := modeColorsFromStyling(styling)
75
76 log.Debug().
77 Str("scheme", scheme).
78 Bool("prefers_dark", prefersDark).
79 Float64("ui_scale", uiScale).
80 Str("sans_font", fonts.SansFont).
81 Str("monospace_font", fonts.MonospaceFont).
82 Msg("theme manager initialized")
83
84 return &Manager{
85 scheme: scheme,
86 prefersDark: prefersDark,
87 lightPalette: lightPalette,
88 darkPalette: darkPalette,
89 uiScale: uiScale,
90 fonts: fonts,
91 modeColors: modeColors,
92 colorResolver: resolver,
93 }
94}
95
96// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.
97func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {
98 defaults := DefaultModeColors()
99 if styling == nil {
100 return defaults
101 }
102 return ModeColors{
103 PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
104 TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
105 SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
106 ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
107 }
108}
109
110// PrefersDark returns true if dark mode is active.
111func (m *Manager) PrefersDark() bool {
112 return m.prefersDark
113}
114
115// GetCurrentPalette returns the active palette based on current scheme.
116func (m *Manager) GetCurrentPalette() Palette {
117 if m.prefersDark {
118 return m.darkPalette
119 }
120 return m.lightPalette
121}
122
123// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
124// Used to set WebView background color to eliminate white flash during loading.
125func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
126 palette := m.GetCurrentPalette()
127 return HexToRGBA(palette.Background)
128}
129
130// GetLightPalette returns the light theme palette.
131func (m *Manager) GetLightPalette() Palette {
132 return m.lightPalette
133}
134
135// GetDarkPalette returns the dark theme palette.
136func (m *Manager) GetDarkPalette() Palette {
137 return m.darkPalette
138}
139
140// GetModeColors returns the modal mode indicator colors.
141func (m *Manager) GetModeColors() ModeColors {
142 return m.modeColors
143}
144
145// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
146// WebUI uses `:root` for light and `.dark` overrides for dark.
147func (m *Manager) GetWebUIThemeCSS() string {
148 lightVars := m.lightPalette.ToWebCSSVars()
149 darkVars := m.darkPalette.ToWebCSSVars()
150 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
151}
152
153// ApplyToDisplay loads the theme CSS into the display.
154func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
155 log := logging.FromContext(ctx)
156
157 if display == nil {
158 log.Warn().Msg("cannot apply theme: display is nil")
159 return
160 }
161
162 // Generate CSS with current palette, UI scale, fonts, and mode colors
163 palette := m.GetCurrentPalette()
164 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
165
166 // Create CSS provider if needed
167 if m.cssProvider == nil {
168 m.cssProvider = gtk.NewCssProvider()
169 }
170
171 if m.cssProvider == nil {
172 log.Error().Msg("failed to create CSS provider")
173 return
174 }
175
176 // Load CSS
177 m.cssProvider.LoadFromString(css)
178 gtk.StyleContextAddProviderForDisplay(
179 display,
180 m.cssProvider,
181 uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
182 )
183
184 log.Debug().
185 Bool("dark_mode", m.prefersDark).
186 Msg("theme CSS applied to display")
187}
188
189// SetColorScheme changes the active color scheme at runtime.
190func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
191 log := logging.FromContext(ctx)
192
193 m.scheme = scheme
194 pref := m.colorResolver.Refresh()
195 m.prefersDark = pref.PrefersDark
196
197 log.Info().
198 Str("scheme", scheme).
199 Bool("prefers_dark", m.prefersDark).
200 Str("source", pref.Source).
201 Msg("color scheme changed")
202
203 // Re-apply CSS if display is available
204 if display != nil {
205 m.ApplyToDisplay(ctx, display)
206 }
207}
208
209// UpdateFromConfig updates the theme manager state from new config values.
210//
211// appearance controls fonts, palette overrides and color scheme.
212// uiScale is the GTK UI scale factor (0 or negative means keep existing scale).
213// styling controls mode indicator colors; may be nil to keep existing colors.
214func (m *Manager) UpdateFromConfig(
215 ctx context.Context,
216 appearance *entity.AppearanceConfig,
217 uiScale float64,
218 styling *entity.WorkspaceStylingConfig,
219 display *gdk.Display,
220) {
221 log := logging.FromContext(ctx)
222
223 if appearance == nil {
224 return
225 }
226
227 // Update scheme and resolve prefersDark via resolver
228 scheme := "system"
229 if appearance.ColorScheme != "" {
230 scheme = appearance.ColorScheme
231 }
232 m.scheme = scheme
233 pref := m.colorResolver.Refresh()
234 m.prefersDark = pref.PrefersDark
235
236 // Update palettes
237 m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
238 m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
239
240 // Update UI scale
241 if uiScale > 0 {
242 m.uiScale = uiScale
243 }
244
245 defaults := DefaultFontConfig()
246 m.fonts = FontConfig{
247 SansFont: Coalesce(appearance.SansFont, defaults.SansFont),
248 MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont),
249 }
250
251 // Update mode colors
252 if styling != nil {
253 m.modeColors = modeColorsFromStyling(styling)
254 }
255
256 log.Info().
257 Str("scheme", m.scheme).
258 Bool("prefers_dark", m.prefersDark).
259 Float64("ui_scale", m.uiScale).
260 Str("sans_font", m.fonts.SansFont).
261 Str("monospace_font", m.fonts.MonospaceFont).
262 Msg("theme manager updated from config")
263
264 // Re-apply CSS if display is available
265 if display != nil {
266 m.ApplyToDisplay(ctx, display)
267 }
268}