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 appliedFont string
28}
29
30// NewManager creates a new theme manager from an already-resolved theme.
31func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {
32 log := logging.FromContext(ctx)
33
34 m := &Manager{transitionDurationMs: defaultTransitionDurationMs}
35 m.applyResolvedTheme(resolved)
36
37 log.Debug().
38 Bool("prefers_dark", m.prefersDark).
39 Float64("ui_scale", m.uiScale).
40 Str("sans_font", m.fonts.SansFont).
41 Str("monospace_font", m.fonts.MonospaceFont).
42 Str("gtk_font", m.gtkFont).
43 Msg("theme manager initialized")
44
45 return m
46}
47
48func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
49 m.prefersDark = resolved.PrefersDark
50 m.lightPalette = PaletteFromEntity(resolved.LightPalette, false)
51 m.darkPalette = PaletteFromEntity(resolved.DarkPalette, true)
52 m.uiScale = resolved.UIScale
53 if m.uiScale <= 0 {
54 m.uiScale = 1.0
55 }
56 m.fonts = FontConfigFromEntity(resolved.Fonts)
57 if m.fonts.GtkFont == "" {
58 m.fonts.GtkFont = DefaultGTKFont()
59 }
60 m.gtkFont = m.fonts.GtkFont
61 m.modeColors = ModeColorsFromEntity(resolved.ModeColors)
62}
63
64// SetTransitionDuration sets the CSS transition duration used for theme-driven
65// animations such as Vim mode pulse timing.
66func (m *Manager) SetTransitionDuration(ms int) {
67 if ms < 0 {
68 ms = defaultTransitionDurationMs
69 }
70 m.transitionDurationMs = ms
71}
72
73// PrefersDark returns true if dark mode is active.
74func (m *Manager) PrefersDark() bool {
75 return m.prefersDark
76}
77
78// GetCurrentPalette returns the active palette based on current preference.
79func (m *Manager) GetCurrentPalette() Palette {
80 if m.prefersDark {
81 return m.darkPalette
82 }
83 return m.lightPalette
84}
85
86// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
87// Used to set WebView background color to eliminate white flash during loading.
88func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
89 palette := m.GetCurrentPalette()
90 return HexToRGBA(palette.Background)
91}
92
93// GetLightPalette returns the light theme palette.
94func (m *Manager) GetLightPalette() Palette {
95 return m.lightPalette
96}
97
98// GetDarkPalette returns the dark theme palette.
99func (m *Manager) GetDarkPalette() Palette {
100 return m.darkPalette
101}
102
103// GetModeColors returns the modal mode indicator colors.
104func (m *Manager) GetModeColors() ModeColors {
105 return m.modeColors
106}
107
108// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
109// WebUI uses `:root` for light and `.dark` overrides for dark.
110func (m *Manager) GetWebUIThemeCSS() string {
111 lightVars := m.lightPalette.ToWebCSSVars()
112 darkVars := m.darkPalette.ToWebCSSVars()
113 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
114}
115
116const gtkDefaultFontPointSize = 11
117
118func DefaultGTKFont() string { return "Adwaita Sans" }
119
120func formatGTKFontName(family string, uiScale float64) string {
121 family = strings.TrimSpace(family)
122 if family == "" {
123 family = DefaultGTKFont()
124 }
125 if uiScale <= 0 {
126 uiScale = 1.0
127 }
128 size := int(math.Round(gtkDefaultFontPointSize * uiScale))
129 if size < 1 {
130 size = gtkDefaultFontPointSize
131 }
132 return fmt.Sprintf("%s %d", family, size)
133}
134
135func shouldApplyGTKFontName(current, next string) bool {
136 return next != "" && next != current
137}
138
139// ApplyToDisplay loads the theme CSS into the display.
140func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
141 log := logging.FromContext(ctx)
142
143 if display == nil {
144 log.Warn().Msg("cannot apply theme: display is nil")
145 return
146 }
147
148 // Generate CSS with current palette, UI scale, fonts, mode colors, and the
149 // configured transition duration.
150 palette := m.GetCurrentPalette()
151 css := GenerateCSSFullWithTiming(palette, m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs)
152 fontName := formatGTKFontName(m.gtkFont, m.uiScale)
153
154 settings := gtk.SettingsGetForDisplay(display)
155 if settings == nil {
156 log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
157 } else if shouldApplyGTKFontName(m.appliedFont, fontName) {
158 settings.SetPropertyGtkFontName(fontName)
159 m.appliedFont = fontName
160 settings.Unref()
161 } else {
162 settings.Unref()
163 }
164
165 // Create CSS provider if needed
166 if m.cssProvider == nil {
167 m.cssProvider = gtk.NewCssProvider()
168 }
169
170 if m.cssProvider == nil {
171 log.Error().Msg("failed to create CSS provider")
172 return
173 }
174
175 // Load CSS
176 m.cssProvider.LoadFromString(css)
177 gtk.StyleContextAddProviderForDisplay(
178 display,
179 m.cssProvider,
180 uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
181 )
182
183 log.Debug().
184 Bool("dark_mode", m.prefersDark).
185 Msg("theme CSS applied to display")
186}
187
188// UpdateFromResolved updates the theme manager state from a resolved theme.
189func (m *Manager) UpdateFromResolved(ctx context.Context, resolved entity.ResolvedTheme, display *gdk.Display) {
190 log := logging.FromContext(ctx)
191
192 m.applyResolvedTheme(resolved)
193
194 log.Info().
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 Str("gtk_font", m.gtkFont).
200 Msg("theme manager updated from resolved theme")
201
202 // Re-apply CSS if display is available
203 if display != nil {
204 m.ApplyToDisplay(ctx, display)
205 }
206}