v0.31.0 bf0756b

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

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