1package theme
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/bnema/dumber/internal/logging"
9 "github.com/jwijenbergh/puregotk/v4/gdk"
10 "github.com/jwijenbergh/puregotk/v4/gtk"
11)
12
13// Manager handles theme state and CSS application.
14type Manager struct {
15 scheme string // "light", "dark", "system"
16 prefersDark bool // Resolved dark mode preference
17 lightPalette Palette // Light theme colors
18 darkPalette Palette // Dark theme colors
19 uiScale float64 // UI scaling factor (1.0 = 100%)
20 fonts FontConfig
21 cssProvider *gtk.CssProvider
22}
23
24// NewManager creates a new theme manager from configuration.
25func NewManager(ctx context.Context, cfg *config.Config) *Manager {
26 log := logging.FromContext(ctx)
27
28 // Determine color scheme preference
29 scheme := "system"
30 if cfg != nil && cfg.Appearance.ColorScheme != "" {
31 scheme = cfg.Appearance.ColorScheme
32 }
33
34 // Resolve whether we should use dark mode
35 prefersDark := ResolveColorScheme(scheme)
36
37 // Build palettes from config or defaults
38 var lightPalette, darkPalette Palette
39 if cfg != nil {
40 lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
41 darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
42 } else {
43 lightPalette = DefaultLightPalette()
44 darkPalette = DefaultDarkPalette()
45 }
46
47 // Get UI scale factor (default to 1.0 if not set)
48 uiScale := 1.0
49 if cfg != nil && cfg.DefaultUIScale > 0 {
50 uiScale = cfg.DefaultUIScale
51 }
52
53 fonts := DefaultFontConfig()
54 if cfg != nil {
55 fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)
56 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
57 }
58
59 log.Debug().
60 Str("scheme", scheme).
61 Bool("prefers_dark", prefersDark).
62 Float64("ui_scale", uiScale).
63 Str("sans_font", fonts.SansFont).
64 Str("monospace_font", fonts.MonospaceFont).
65 Msg("theme manager initialized")
66
67 return &Manager{
68 scheme: scheme,
69 prefersDark: prefersDark,
70 lightPalette: lightPalette,
71 darkPalette: darkPalette,
72 uiScale: uiScale,
73 fonts: fonts,
74 }
75}
76
77// PrefersDark returns true if dark mode is active.
78func (m *Manager) PrefersDark() bool {
79 return m.prefersDark
80}
81
82// GetCurrentPalette returns the active palette based on current scheme.
83func (m *Manager) GetCurrentPalette() Palette {
84 if m.prefersDark {
85 return m.darkPalette
86 }
87 return m.lightPalette
88}
89
90// GetLightPalette returns the light theme palette.
91func (m *Manager) GetLightPalette() Palette {
92 return m.lightPalette
93}
94
95// GetDarkPalette returns the dark theme palette.
96func (m *Manager) GetDarkPalette() Palette {
97 return m.darkPalette
98}
99
100// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
101// WebUI uses `:root` for light and `.dark` overrides for dark.
102func (m *Manager) GetWebUIThemeCSS() string {
103 lightVars := m.lightPalette.ToWebCSSVars()
104 darkVars := m.darkPalette.ToWebCSSVars()
105 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
106}
107
108// ApplyToDisplay loads the theme CSS into the display.
109func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
110 log := logging.FromContext(ctx)
111
112 if display == nil {
113 log.Warn().Msg("cannot apply theme: display is nil")
114 return
115 }
116
117 // Generate CSS with current palette, UI scale and fonts
118 palette := m.GetCurrentPalette()
119 css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)
120
121 // Create CSS provider if needed
122 if m.cssProvider == nil {
123 m.cssProvider = gtk.NewCssProvider()
124 }
125
126 if m.cssProvider == nil {
127 log.Error().Msg("failed to create CSS provider")
128 return
129 }
130
131 // Load CSS
132 m.cssProvider.LoadFromString(css)
133 gtk.StyleContextAddProviderForDisplay(
134 display,
135 m.cssProvider,
136 uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
137 )
138
139 log.Debug().
140 Bool("dark_mode", m.prefersDark).
141 Msg("theme CSS applied to display")
142}
143
144// SetColorScheme changes the active color scheme at runtime.
145func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
146 log := logging.FromContext(ctx)
147
148 m.scheme = scheme
149 m.prefersDark = ResolveColorScheme(scheme)
150
151 log.Info().
152 Str("scheme", scheme).
153 Bool("prefers_dark", m.prefersDark).
154 Msg("color scheme changed")
155
156 // Re-apply CSS if display is available
157 if display != nil {
158 m.ApplyToDisplay(ctx, display)
159 }
160}
161
162// UpdateFromConfig updates the theme manager state from a new config.
163func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {
164 log := logging.FromContext(ctx)
165
166 if cfg == nil {
167 return
168 }
169
170 // Update scheme and resolve prefersDark
171 scheme := "system"
172 if cfg.Appearance.ColorScheme != "" {
173 scheme = cfg.Appearance.ColorScheme
174 }
175 m.scheme = scheme
176 m.prefersDark = ResolveColorScheme(scheme)
177
178 // Update palettes
179 m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
180 m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
181
182 // Update UI scale
183 if cfg.DefaultUIScale > 0 {
184 m.uiScale = cfg.DefaultUIScale
185 }
186
187 defaults := DefaultFontConfig()
188 m.fonts = FontConfig{
189 SansFont: Coalesce(cfg.Appearance.SansFont, defaults.SansFont),
190 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
191 }
192
193 log.Info().
194 Str("scheme", m.scheme).
195 Bool("prefers_dark", m.prefersDark).
196 Float64("ui_scale", m.uiScale).
197 Str("sans_font", m.fonts.SansFont).
198 Str("monospace_font", m.fonts.MonospaceFont).
199 Msg("theme manager updated from config")
200
201 // Re-apply CSS if display is available
202 if display != nil {
203 m.ApplyToDisplay(ctx, display)
204 }
205}