v0.22.0 a85da87

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

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