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