main 5041353

manager.go RawHistoryFolder

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}

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