v0.30.0 eb3068f

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	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}

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