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