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