1package theme
2
3import (
4 "context"
5 "fmt"
6 "math"
7 "strings"
8
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/puregotk/v4/gdk"
12 "github.com/bnema/puregotk/v4/gtk"
13)
14
15// Manager handles theme state and CSS application.
16// Theme precedence and fallback are resolved before data reaches this adapter.
17type Manager struct {
18 prefersDark bool // Resolved dark mode preference
19 lightPalette Palette // Light theme colors
20 darkPalette Palette // Dark theme colors
21 uiScale float64 // UI scaling factor (1.0 = 100%)
22 fonts FontConfig
23 gtkFont string
24 modeColors ModeColors // Modal mode indicator colors
25 transitionDurationMs int
26 cssProvider *gtk.CssProvider
27 // appliedFonts records the GTK font name last set per display, keyed by
28 // native display address. GtkSettings are per display, so the font must
29 // be applied on every display even when the theme is unchanged. Keys
30 // are plain addresses: no display reference is retained, so destroyed
31 // displays cannot leak through the manager.
32 appliedFonts map[uintptr]string
33 // appliedCSS is the effective CSS identity last loaded into cssProvider.
34 // appliedDisplayPtr is the native display address that provider was last
35 // added for. Both are plain values: no display reference is retained, so
36 // destroyed displays cannot leak through the manager. Native display
37 // addresses are unique among live displays; address reuse after a display
38 // is destroyed is pathological in single-display processes.
39 appliedCSS string
40 appliedDisplayPtr uintptr
41}
42
43// NewManager creates a new theme manager from an already-resolved theme.
44func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {
45 log := logging.FromContext(ctx)
46
47 m := &Manager{transitionDurationMs: defaultTransitionDurationMs}
48 m.applyResolvedTheme(resolved)
49
50 log.Debug().
51 Bool("prefers_dark", m.prefersDark).
52 Float64("ui_scale", m.uiScale).
53 Str("sans_font", m.fonts.SansFont).
54 Str("monospace_font", m.fonts.MonospaceFont).
55 Str("gtk_font", m.gtkFont).
56 Msg("theme manager initialized")
57
58 return m
59}
60
61func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
62 m.prefersDark = resolved.PrefersDark
63 m.lightPalette = PaletteFromEntity(resolved.LightPalette, false)
64 m.darkPalette = PaletteFromEntity(resolved.DarkPalette, true)
65 m.uiScale = resolved.UIScale
66 if m.uiScale <= 0 {
67 m.uiScale = 1.0
68 }
69 m.fonts = FontConfigFromEntity(resolved.Fonts)
70 if m.fonts.GtkFont == "" {
71 m.fonts.GtkFont = DefaultGTKFont()
72 }
73 m.gtkFont = m.fonts.GtkFont
74 m.modeColors = ModeColorsFromEntity(resolved.ModeColors)
75}
76
77// SetTransitionDuration sets the CSS transition duration used for theme-driven
78// animations such as Vim mode pulse timing.
79func (m *Manager) SetTransitionDuration(ms int) {
80 if ms < 0 {
81 ms = defaultTransitionDurationMs
82 }
83 m.transitionDurationMs = ms
84}
85
86// PrefersDark returns true if dark mode is active.
87func (m *Manager) PrefersDark() bool {
88 return m.prefersDark
89}
90
91// GetCurrentPalette returns the active palette based on current preference.
92func (m *Manager) GetCurrentPalette() Palette {
93 if m.prefersDark {
94 return m.darkPalette
95 }
96 return m.lightPalette
97}
98
99// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
100// Used to set WebView background color to eliminate white flash during loading.
101func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
102 palette := m.GetCurrentPalette()
103 return HexToRGBA(palette.Background)
104}
105
106// GetLightPalette returns the light theme palette.
107func (m *Manager) GetLightPalette() Palette {
108 return m.lightPalette
109}
110
111// GetDarkPalette returns the dark theme palette.
112func (m *Manager) GetDarkPalette() Palette {
113 return m.darkPalette
114}
115
116// GetModeColors returns the modal mode indicator colors.
117func (m *Manager) GetModeColors() ModeColors {
118 return m.modeColors
119}
120
121// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
122// WebUI uses `:root` for light and `.dark` overrides for dark.
123func (m *Manager) GetWebUIThemeCSS() string {
124 lightVars := m.lightPalette.ToWebCSSVars()
125 darkVars := m.darkPalette.ToWebCSSVars()
126 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
127}
128
129const gtkDefaultFontPointSize = 11
130
131func DefaultGTKFont() string { return "Adwaita Sans" }
132
133func formatGTKFontName(family string, uiScale float64) string {
134 family = strings.TrimSpace(family)
135 if family == "" {
136 family = DefaultGTKFont()
137 }
138 if uiScale <= 0 {
139 uiScale = 1.0
140 }
141 size := int(math.Round(gtkDefaultFontPointSize * uiScale))
142 if size < 1 {
143 size = gtkDefaultFontPointSize
144 }
145 return fmt.Sprintf("%s %d", family, size)
146}
147
148func shouldApplyGTKFontName(current, next string) bool {
149 return next != "" && next != current
150}
151
152// lastAppliedFontFor returns the GTK font name recorded for a display, or
153// "" when the manager has not applied a font there yet.
154func (m *Manager) lastAppliedFontFor(ptr uintptr) string {
155 if m == nil || m.appliedFonts == nil {
156 return ""
157 }
158 return m.appliedFonts[ptr]
159}
160
161// recordAppliedFontFor stores the GTK font name set on a display. The map
162// is created on first use so zero-value Managers stay usable.
163func (m *Manager) recordAppliedFontFor(ptr uintptr, font string) {
164 if m == nil {
165 return
166 }
167 if m.appliedFonts == nil {
168 m.appliedFonts = make(map[uintptr]string)
169 }
170 m.appliedFonts[ptr] = font
171}
172
173// shouldSkipThemeReapply reports whether a theme application can be skipped
174// entirely: the provider exists, the effective CSS and font are unchanged,
175// and the target is the display the provider was last added for.
176func shouldSkipThemeReapply(hasProvider bool, appliedPtr, ptr uintptr, appliedCSS, css, appliedFont, font string) bool {
177 return hasProvider && ptr == appliedPtr && css == appliedCSS && font != "" && font == appliedFont
178}
179
180// ApplyToDisplay loads the theme CSS into the display, skipping the reload
181// and re-add when the same provider already carries the effective CSS on the
182// same display. A different display always gets the provider added; the CSS
183// is only re-parsed when its identity changed.
184func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
185 log := logging.FromContext(ctx)
186
187 if display == nil {
188 log.Warn().Msg("cannot apply theme: display is nil")
189 return
190 }
191
192 // Generate CSS with current palette, UI scale, fonts, mode colors, and the
193 // configured transition duration.
194 palette := m.GetCurrentPalette()
195 css := GenerateCSSFullWithTiming(palette, m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs)
196 fontName := formatGTKFontName(m.gtkFont, m.uiScale)
197 displayFont := m.lastAppliedFontFor(display.Ptr)
198
199 if shouldSkipThemeReapply(m.cssProvider != nil, m.appliedDisplayPtr, display.Ptr, m.appliedCSS, css, displayFont, fontName) {
200 log.Debug().
201 Bool("dark_mode", m.prefersDark).
202 Msg("theme CSS unchanged for display, skipping reapply")
203 return
204 }
205
206 settings := gtk.SettingsGetForDisplay(display)
207 if settings == nil {
208 log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
209 } else if shouldApplyGTKFontName(displayFont, fontName) {
210 settings.SetPropertyGtkFontName(fontName)
211 m.recordAppliedFontFor(display.Ptr, fontName)
212 settings.Unref()
213 } else {
214 settings.Unref()
215 }
216
217 // Create CSS provider if needed
218 providerFresh := false
219 if m.cssProvider == nil {
220 m.cssProvider = gtk.NewCssProvider()
221 providerFresh = true
222 }
223
224 if m.cssProvider == nil {
225 log.Error().Msg("failed to create CSS provider")
226 return
227 }
228
229 // Load CSS only when its identity changed; the shared provider already
230 // carries the previous CSS for a display change with an unchanged theme.
231 // A freshly created provider always loads, even if the generated CSS is
232 // empty, so the skip identity can never mask a missing load.
233 if providerFresh || css != m.appliedCSS {
234 m.cssProvider.LoadFromString(css)
235 }
236 gtk.StyleContextAddProviderForDisplay(
237 display,
238 m.cssProvider,
239 uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
240 )
241 m.appliedCSS = css
242 m.appliedDisplayPtr = display.Ptr
243
244 log.Debug().
245 Bool("dark_mode", m.prefersDark).
246 Msg("theme CSS applied to display")
247}
248
249// UpdateFromResolved updates the theme manager state from a resolved theme.
250func (m *Manager) UpdateFromResolved(ctx context.Context, resolved entity.ResolvedTheme, display *gdk.Display) {
251 log := logging.FromContext(ctx)
252
253 m.applyResolvedTheme(resolved)
254
255 log.Info().
256 Bool("prefers_dark", m.prefersDark).
257 Float64("ui_scale", m.uiScale).
258 Str("sans_font", m.fonts.SansFont).
259 Str("monospace_font", m.fonts.MonospaceFont).
260 Str("gtk_font", m.gtkFont).
261 Msg("theme manager updated from resolved theme")
262
263 // Re-apply CSS if display is available
264 if display != nil {
265 m.ApplyToDisplay(ctx, display)
266 }
267}