v0.21.0 73bf644

manager.go RawHistoryFolder

1package theme
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/bnema/dumber/internal/infrastructure/config"
8	"github.com/bnema/dumber/internal/logging"
9	"github.com/jwijenbergh/puregotk/v4/gdk"
10	"github.com/jwijenbergh/puregotk/v4/gtk"
11)
12
13// Manager handles theme state and CSS application.
14type Manager struct {
15	scheme       string  // "light", "dark", "system"
16	prefersDark  bool    // Resolved dark mode preference
17	lightPalette Palette // Light theme colors
18	darkPalette  Palette // Dark theme colors
19	uiScale      float64 // UI scaling factor (1.0 = 100%)
20	fonts        FontConfig
21	cssProvider  *gtk.CssProvider
22}
23
24// NewManager creates a new theme manager from configuration.
25func NewManager(ctx context.Context, cfg *config.Config) *Manager {
26	log := logging.FromContext(ctx)
27
28	// Determine color scheme preference
29	scheme := "system"
30	if cfg != nil && cfg.Appearance.ColorScheme != "" {
31		scheme = cfg.Appearance.ColorScheme
32	}
33
34	// Resolve whether we should use dark mode
35	prefersDark := ResolveColorScheme(scheme)
36
37	// Build palettes from config or defaults
38	var lightPalette, darkPalette Palette
39	if cfg != nil {
40		lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
41		darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
42	} else {
43		lightPalette = DefaultLightPalette()
44		darkPalette = DefaultDarkPalette()
45	}
46
47	// Get UI scale factor (default to 1.0 if not set)
48	uiScale := 1.0
49	if cfg != nil && cfg.DefaultUIScale > 0 {
50		uiScale = cfg.DefaultUIScale
51	}
52
53	fonts := DefaultFontConfig()
54	if cfg != nil {
55		fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)
56		fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
57	}
58
59	log.Debug().
60		Str("scheme", scheme).
61		Bool("prefers_dark", prefersDark).
62		Float64("ui_scale", uiScale).
63		Str("sans_font", fonts.SansFont).
64		Str("monospace_font", fonts.MonospaceFont).
65		Msg("theme manager initialized")
66
67	return &Manager{
68		scheme:       scheme,
69		prefersDark:  prefersDark,
70		lightPalette: lightPalette,
71		darkPalette:  darkPalette,
72		uiScale:      uiScale,
73		fonts:        fonts,
74	}
75}
76
77// PrefersDark returns true if dark mode is active.
78func (m *Manager) PrefersDark() bool {
79	return m.prefersDark
80}
81
82// GetCurrentPalette returns the active palette based on current scheme.
83func (m *Manager) GetCurrentPalette() Palette {
84	if m.prefersDark {
85		return m.darkPalette
86	}
87	return m.lightPalette
88}
89
90// GetBackgroundRGBA returns the current theme's background color as RGBA float32 values.
91// Used to set WebView background color to eliminate white flash during loading.
92func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) {
93	palette := m.GetCurrentPalette()
94	return HexToRGBA(palette.Background)
95}
96
97// GetLightPalette returns the light theme palette.
98func (m *Manager) GetLightPalette() Palette {
99	return m.lightPalette
100}
101
102// GetDarkPalette returns the dark theme palette.
103func (m *Manager) GetDarkPalette() Palette {
104	return m.darkPalette
105}
106
107// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
108// WebUI uses `:root` for light and `.dark` overrides for dark.
109func (m *Manager) GetWebUIThemeCSS() string {
110	lightVars := m.lightPalette.ToWebCSSVars()
111	darkVars := m.darkPalette.ToWebCSSVars()
112	return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
113}
114
115// ApplyToDisplay loads the theme CSS into the display.
116func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
117	log := logging.FromContext(ctx)
118
119	if display == nil {
120		log.Warn().Msg("cannot apply theme: display is nil")
121		return
122	}
123
124	// Generate CSS with current palette, UI scale and fonts
125	palette := m.GetCurrentPalette()
126	css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)
127
128	// Create CSS provider if needed
129	if m.cssProvider == nil {
130		m.cssProvider = gtk.NewCssProvider()
131	}
132
133	if m.cssProvider == nil {
134		log.Error().Msg("failed to create CSS provider")
135		return
136	}
137
138	// Load CSS
139	m.cssProvider.LoadFromString(css)
140	gtk.StyleContextAddProviderForDisplay(
141		display,
142		m.cssProvider,
143		uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
144	)
145
146	log.Debug().
147		Bool("dark_mode", m.prefersDark).
148		Msg("theme CSS applied to display")
149}
150
151// SetColorScheme changes the active color scheme at runtime.
152func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
153	log := logging.FromContext(ctx)
154
155	m.scheme = scheme
156	m.prefersDark = ResolveColorScheme(scheme)
157
158	log.Info().
159		Str("scheme", scheme).
160		Bool("prefers_dark", m.prefersDark).
161		Msg("color scheme changed")
162
163	// Re-apply CSS if display is available
164	if display != nil {
165		m.ApplyToDisplay(ctx, display)
166	}
167}
168
169// UpdateFromConfig updates the theme manager state from a new config.
170func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {
171	log := logging.FromContext(ctx)
172
173	if cfg == nil {
174		return
175	}
176
177	// Update scheme and resolve prefersDark
178	scheme := "system"
179	if cfg.Appearance.ColorScheme != "" {
180		scheme = cfg.Appearance.ColorScheme
181	}
182	m.scheme = scheme
183	m.prefersDark = ResolveColorScheme(scheme)
184
185	// Update palettes
186	m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
187	m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
188
189	// Update UI scale
190	if cfg.DefaultUIScale > 0 {
191		m.uiScale = cfg.DefaultUIScale
192	}
193
194	defaults := DefaultFontConfig()
195	m.fonts = FontConfig{
196		SansFont:      Coalesce(cfg.Appearance.SansFont, defaults.SansFont),
197		MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
198	}
199
200	log.Info().
201		Str("scheme", m.scheme).
202		Bool("prefers_dark", m.prefersDark).
203		Float64("ui_scale", m.uiScale).
204		Str("sans_font", m.fonts.SansFont).
205		Str("monospace_font", m.fonts.MonospaceFont).
206		Msg("theme manager updated from config")
207
208	// Re-apply CSS if display is available
209	if display != nil {
210		m.ApplyToDisplay(ctx, display)
211	}
212}

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