v0.28.0 3cb3770

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

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