v0.23.2 bfd5eb4

manager.go RawHistoryFolder

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

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