v0.29.0 21b3150

manager.go RawHistoryFolder

1package theme
2
3import (
4	"context"
5	"fmt"
6	"math"
7	"strings"
8
9	"github.com/bnema/dumber/internal/application/port"
10	"github.com/bnema/dumber/internal/domain/entity"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/puregotk/v4/gdk"
13	"github.com/bnema/puregotk/v4/gtk"
14)
15
16// Manager handles theme state and CSS application.
17type Manager struct {
18	scheme        string  // "light", "dark", "system"
19	prefersDark   bool    // Resolved dark mode preference
20	lightPalette  Palette // Light theme colors
21	darkPalette   Palette // Dark theme colors
22	uiScale       float64 // UI scaling factor (1.0 = 100%)
23	fonts         FontConfig
24	gtkFont       string
25	modeColors    ModeColors // Modal mode indicator colors
26	cssProvider   *gtk.CssProvider
27	appliedFont   string
28	colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
29}
30
31// NewManager creates a new theme manager from configuration.
32// The ColorSchemeResolver is required for proper color scheme detection.
33//
34// appearance controls fonts, palette overrides and color scheme.
35// uiScale is the GTK UI scale factor (0 or negative means use default 1.0).
36// styling controls mode indicator colors; may be nil to use defaults.
37func NewManager(
38	ctx context.Context,
39	appearance *entity.AppearanceConfig,
40	uiScale float64,
41	styling *entity.WorkspaceStylingConfig,
42	resolver port.ColorSchemeResolver,
43) *Manager {
44	log := logging.FromContext(ctx)
45
46	// Determine color scheme preference
47	scheme := "system"
48	if appearance != nil && appearance.ColorScheme != "" {
49		scheme = appearance.ColorScheme
50	}
51
52	// Resolve whether we should use dark mode via resolver
53	pref := resolver.Resolve()
54	prefersDark := pref.PrefersDark
55
56	// Build palettes from config or defaults
57	var lightPalette, darkPalette Palette
58	if appearance != nil {
59		lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
60		darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
61	} else {
62		lightPalette = DefaultLightPalette()
63		darkPalette = DefaultDarkPalette()
64	}
65
66	// Get UI scale factor (default to 1.0 if not set)
67	if uiScale <= 0 {
68		uiScale = 1.0
69	}
70
71	fonts := DefaultFontConfig()
72	gtkFont := DefaultGTKFont()
73	if appearance != nil {
74		fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont)
75		fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont)
76		gtkFont = Coalesce(appearance.GtkFont, gtkFont)
77		fonts.GtkFont = gtkFont
78	}
79
80	// Build mode colors from config
81	modeColors := modeColorsFromStyling(styling)
82
83	log.Debug().
84		Str("scheme", scheme).
85		Bool("prefers_dark", prefersDark).
86		Float64("ui_scale", uiScale).
87		Str("sans_font", fonts.SansFont).
88		Str("monospace_font", fonts.MonospaceFont).
89		Str("gtk_font", gtkFont).
90		Msg("theme manager initialized")
91
92	return &Manager{
93		scheme:        scheme,
94		prefersDark:   prefersDark,
95		lightPalette:  lightPalette,
96		darkPalette:   darkPalette,
97		uiScale:       uiScale,
98		fonts:         fonts,
99		gtkFont:       gtkFont,
100		modeColors:    modeColors,
101		colorResolver: resolver,
102	}
103}
104
105// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.
106func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {
107	defaults := DefaultModeColors()
108	if styling == nil {
109		return defaults
110	}
111	return ModeColors{
112		PaneMode:    Coalesce(styling.PaneModeColor, defaults.PaneMode),
113		TabMode:     Coalesce(styling.TabModeColor, defaults.TabMode),
114		SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
115		ResizeMode:  Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
116	}
117}
118
119// PrefersDark returns true if dark mode is active.
120func (m *Manager) PrefersDark() bool {
121	return m.prefersDark
122}
123
124// GetCurrentPalette returns the active palette based on current scheme.
125func (m *Manager) GetCurrentPalette() Palette {
126	if m.prefersDark {
127		return m.darkPalette
128	}
129	return m.lightPalette
130}
131
132// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
133// Used to set WebView background color to eliminate white flash during loading.
134func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
135	palette := m.GetCurrentPalette()
136	return HexToRGBA(palette.Background)
137}
138
139// GetLightPalette returns the light theme palette.
140func (m *Manager) GetLightPalette() Palette {
141	return m.lightPalette
142}
143
144// GetDarkPalette returns the dark theme palette.
145func (m *Manager) GetDarkPalette() Palette {
146	return m.darkPalette
147}
148
149// GetModeColors returns the modal mode indicator colors.
150func (m *Manager) GetModeColors() ModeColors {
151	return m.modeColors
152}
153
154// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
155// WebUI uses `:root` for light and `.dark` overrides for dark.
156func (m *Manager) GetWebUIThemeCSS() string {
157	lightVars := m.lightPalette.ToWebCSSVars()
158	darkVars := m.darkPalette.ToWebCSSVars()
159	return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
160}
161
162const gtkDefaultFontPointSize = 11
163
164func DefaultGTKFont() string { return "Adwaita Sans" }
165
166func formatGTKFontName(family string, uiScale float64) string {
167	family = strings.TrimSpace(family)
168	if family == "" {
169		family = DefaultGTKFont()
170	}
171	if uiScale <= 0 {
172		uiScale = 1.0
173	}
174	size := int(math.Round(gtkDefaultFontPointSize * uiScale))
175	if size < 1 {
176		size = gtkDefaultFontPointSize
177	}
178	return fmt.Sprintf("%s %d", family, size)
179}
180
181func shouldApplyGTKFontName(current, next string) bool {
182	return next != "" && next != current
183}
184
185// ApplyToDisplay loads the theme CSS into the display.
186func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
187	log := logging.FromContext(ctx)
188
189	if display == nil {
190		log.Warn().Msg("cannot apply theme: display is nil")
191		return
192	}
193
194	// Generate CSS with current palette, UI scale, fonts, and mode colors
195	palette := m.GetCurrentPalette()
196	css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
197	fontName := formatGTKFontName(m.gtkFont, m.uiScale)
198
199	settings := gtk.SettingsGetForDisplay(display)
200	if settings == nil {
201		log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
202	} else if shouldApplyGTKFontName(m.appliedFont, fontName) {
203		settings.SetPropertyGtkFontName(fontName)
204		m.appliedFont = fontName
205		settings.Unref()
206	} else {
207		settings.Unref()
208	}
209
210	// Create CSS provider if needed
211	if m.cssProvider == nil {
212		m.cssProvider = gtk.NewCssProvider()
213	}
214
215	if m.cssProvider == nil {
216		log.Error().Msg("failed to create CSS provider")
217		return
218	}
219
220	// Load CSS
221	m.cssProvider.LoadFromString(css)
222	gtk.StyleContextAddProviderForDisplay(
223		display,
224		m.cssProvider,
225		uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
226	)
227
228	log.Debug().
229		Bool("dark_mode", m.prefersDark).
230		Msg("theme CSS applied to display")
231}
232
233// SetColorScheme changes the active color scheme at runtime.
234func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
235	log := logging.FromContext(ctx)
236
237	m.scheme = scheme
238	pref := m.colorResolver.Refresh()
239	m.prefersDark = pref.PrefersDark
240
241	log.Info().
242		Str("scheme", scheme).
243		Bool("prefers_dark", m.prefersDark).
244		Str("source", pref.Source).
245		Msg("color scheme changed")
246
247	// Re-apply CSS if display is available
248	if display != nil {
249		m.ApplyToDisplay(ctx, display)
250	}
251}
252
253// UpdateFromConfig updates the theme manager state from new config values.
254//
255// appearance controls fonts, palette overrides and color scheme.
256// uiScale is the GTK UI scale factor (0 or negative means keep existing scale).
257// styling controls mode indicator colors; may be nil to keep existing colors.
258func (m *Manager) UpdateFromConfig(
259	ctx context.Context,
260	appearance *entity.AppearanceConfig,
261	uiScale float64,
262	styling *entity.WorkspaceStylingConfig,
263	display *gdk.Display,
264) {
265	log := logging.FromContext(ctx)
266
267	if appearance == nil {
268		return
269	}
270
271	// Update scheme and resolve prefersDark via resolver
272	scheme := "system"
273	if appearance.ColorScheme != "" {
274		scheme = appearance.ColorScheme
275	}
276	m.scheme = scheme
277	pref := m.colorResolver.Refresh()
278	m.prefersDark = pref.PrefersDark
279
280	// Update palettes
281	m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
282	m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
283
284	// Update UI scale
285	if uiScale > 0 {
286		m.uiScale = uiScale
287	}
288
289	defaults := DefaultFontConfig()
290	m.gtkFont = Coalesce(appearance.GtkFont, DefaultGTKFont())
291	m.fonts = FontConfig{
292		SansFont:      Coalesce(appearance.SansFont, defaults.SansFont),
293		MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont),
294		GtkFont:       m.gtkFont,
295	}
296
297	// Update mode colors
298	if styling != nil {
299		m.modeColors = modeColorsFromStyling(styling)
300	}
301
302	log.Info().
303		Str("scheme", m.scheme).
304		Bool("prefers_dark", m.prefersDark).
305		Float64("ui_scale", m.uiScale).
306		Str("sans_font", m.fonts.SansFont).
307		Str("monospace_font", m.fonts.MonospaceFont).
308		Str("gtk_font", m.gtkFont).
309		Msg("theme manager updated from config")
310
311	// Re-apply CSS if display is available
312	if display != nil {
313		m.ApplyToDisplay(ctx, display)
314	}
315}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close