detached 34930dd

refactor(theme): use ColorSchemeResolver in ThemeManager, remove legacy system.go

commit
34930dd710d938bf30e4e510c961b82237229b6a
author
bnema · · 2025-12-29 20:51 UTC
parent
2607ac5
signature
GPG Unverified2B82207D370127A0 The key is unknown
3 files · +381 -99
M internal/ui/theme/manager.go +39 -21
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/infrastructure/config"
89 "github.com/bnema/dumber/internal/logging"
910 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -12,18 +13,30 @@ import (
1213
1314 // Manager handles theme state and CSS application.
1415 type 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
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+// ManagerOption is a functional option for configuring Manager.
28+type ManagerOption func(*Manager)
29+
30+// WithResolver configures the Manager to use a ColorSchemeResolver.
31+func WithResolver(resolver port.ColorSchemeResolver) ManagerOption {
32+ return func(m *Manager) {
33+ m.colorResolver = resolver
34+ }
2335 }
2436
2537 // NewManager creates a new theme manager from configuration.
26-func NewManager(ctx context.Context, cfg *config.Config) *Manager {
38+// The ColorSchemeResolver is required for proper color scheme detection.
39+func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
2740 log := logging.FromContext(ctx)
2841
2942 // Determine color scheme preference
@@ -32,8 +45,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
3245 scheme = cfg.Appearance.ColorScheme
3346 }
3447
35- // Resolve whether we should use dark mode
36- prefersDark := ResolveColorScheme(scheme)
48+ // Resolve whether we should use dark mode via resolver
49+ pref := resolver.Resolve()
50+ prefersDark := pref.PrefersDark
3751
3852 // Build palettes from config or defaults
3953 var lightPalette, darkPalette Palette
@@ -69,13 +83,14 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
6983 Msg("theme manager initialized")
7084
7185 return &Manager{
72- scheme: scheme,
73- prefersDark: prefersDark,
74- lightPalette: lightPalette,
75- darkPalette: darkPalette,
76- uiScale: uiScale,
77- fonts: fonts,
78- modeColors: modeColors,
86+ scheme: scheme,
87+ prefersDark: prefersDark,
88+ lightPalette: lightPalette,
89+ darkPalette: darkPalette,
90+ uiScale: uiScale,
91+ fonts: fonts,
92+ modeColors: modeColors,
93+ colorResolver: resolver,
7994 }
8095 }
8196
@@ -178,11 +193,13 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
178193 log := logging.FromContext(ctx)
179194
180195 m.scheme = scheme
181- m.prefersDark = ResolveColorScheme(scheme)
196+ pref := m.colorResolver.Refresh()
197+ m.prefersDark = pref.PrefersDark
182198
183199 log.Info().
184200 Str("scheme", scheme).
185201 Bool("prefers_dark", m.prefersDark).
202+ Str("source", pref.Source).
186203 Msg("color scheme changed")
187204
188205 // Re-apply CSS if display is available
@@ -199,13 +216,14 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
199216 return
200217 }
201218
202- // Update scheme and resolve prefersDark
219+ // Update scheme and resolve prefersDark via resolver
203220 scheme := "system"
204221 if cfg.Appearance.ColorScheme != "" {
205222 scheme = cfg.Appearance.ColorScheme
206223 }
207224 m.scheme = scheme
208- m.prefersDark = ResolveColorScheme(scheme)
225+ pref := m.colorResolver.Refresh()
226+ m.prefersDark = pref.PrefersDark
209227
210228 // Update palettes
211229 m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
9 "github.com/jwijenbergh/puregotk/v4/gdk"10 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -12,18 +13,30 @@ import (
12 13
13 // Manager handles theme state and CSS application.14 // Manager handles theme state and CSS application.
14 type Manager struct {15 type Manager struct {
15- scheme string // "light", "dark", "system"16+ scheme string // "light", "dark", "system"
16- prefersDark bool // Resolved dark mode preference17+ prefersDark bool // Resolved dark mode preference
17- lightPalette Palette // Light theme colors18+ lightPalette Palette // Light theme colors
18- darkPalette Palette // Dark theme colors19+ darkPalette Palette // Dark theme colors
19- uiScale float64 // UI scaling factor (1.0 = 100%)20+ uiScale float64 // UI scaling factor (1.0 = 100%)
20- fonts FontConfig21+ fonts FontConfig
21- modeColors ModeColors // Modal mode indicator colors22+ modeColors ModeColors // Modal mode indicator colors
22- cssProvider *gtk.CssProvider23+ cssProvider *gtk.CssProvider
24+ colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
25+}
26+
27+// ManagerOption is a functional option for configuring Manager.
28+type ManagerOption func(*Manager)
29+
30+// WithResolver configures the Manager to use a ColorSchemeResolver.
31+func WithResolver(resolver port.ColorSchemeResolver) ManagerOption {
32+ return func(m *Manager) {
33+ m.colorResolver = resolver
34+ }
23 }35 }
24 36
25 // NewManager creates a new theme manager from configuration.37 // NewManager creates a new theme manager from configuration.
26-func NewManager(ctx context.Context, cfg *config.Config) *Manager {38+// The ColorSchemeResolver is required for proper color scheme detection.
39+func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
27 log := logging.FromContext(ctx)40 log := logging.FromContext(ctx)
28 41
29 // Determine color scheme preference42 // Determine color scheme preference
@@ -32,8 +45,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
32 scheme = cfg.Appearance.ColorScheme45 scheme = cfg.Appearance.ColorScheme
33 }46 }
34 47
35- // Resolve whether we should use dark mode48+ // Resolve whether we should use dark mode via resolver
36- prefersDark := ResolveColorScheme(scheme)49+ pref := resolver.Resolve()
50+ prefersDark := pref.PrefersDark
37 51
38 // Build palettes from config or defaults52 // Build palettes from config or defaults
39 var lightPalette, darkPalette Palette53 var lightPalette, darkPalette Palette
@@ -69,13 +83,14 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
69 Msg("theme manager initialized")83 Msg("theme manager initialized")
70 84
71 return &Manager{85 return &Manager{
72- scheme: scheme,86+ scheme: scheme,
73- prefersDark: prefersDark,87+ prefersDark: prefersDark,
74- lightPalette: lightPalette,88+ lightPalette: lightPalette,
75- darkPalette: darkPalette,89+ darkPalette: darkPalette,
76- uiScale: uiScale,90+ uiScale: uiScale,
77- fonts: fonts,91+ fonts: fonts,
78- modeColors: modeColors,92+ modeColors: modeColors,
93+ colorResolver: resolver,
79 }94 }
80 }95 }
81 96
@@ -178,11 +193,13 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
178 log := logging.FromContext(ctx)193 log := logging.FromContext(ctx)
179 194
180 m.scheme = scheme195 m.scheme = scheme
181- m.prefersDark = ResolveColorScheme(scheme)196+ pref := m.colorResolver.Refresh()
197+ m.prefersDark = pref.PrefersDark
182 198
183 log.Info().199 log.Info().
184 Str("scheme", scheme).200 Str("scheme", scheme).
185 Bool("prefers_dark", m.prefersDark).201 Bool("prefers_dark", m.prefersDark).
202+ Str("source", pref.Source).
186 Msg("color scheme changed")203 Msg("color scheme changed")
187 204
188 // Re-apply CSS if display is available205 // Re-apply CSS if display is available
@@ -199,13 +216,14 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
199 return216 return
200 }217 }
201 218
202- // Update scheme and resolve prefersDark219+ // Update scheme and resolve prefersDark via resolver
203 scheme := "system"220 scheme := "system"
204 if cfg.Appearance.ColorScheme != "" {221 if cfg.Appearance.ColorScheme != "" {
205 scheme = cfg.Appearance.ColorScheme222 scheme = cfg.Appearance.ColorScheme
206 }223 }
207 m.scheme = scheme224 m.scheme = scheme
208- m.prefersDark = ResolveColorScheme(scheme)225+ pref := m.colorResolver.Refresh()
226+ m.prefersDark = pref.PrefersDark
209 227
210 // Update palettes228 // Update palettes
211 m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)229 m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
A internal/ui/theme/manager_test.go +342 -0
@@ -0,0 +1,342 @@
1+package theme
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestNewManager_DarkMode(t *testing.T) {
15+ ctx := context.Background()
16+ cfg := &config.Config{
17+ Appearance: config.AppearanceConfig{
18+ ColorScheme: "default",
19+ },
20+ }
21+
22+ mockResolver := mocks.NewMockColorSchemeResolver(t)
23+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
24+ PrefersDark: true,
25+ Source: "test",
26+ })
27+
28+ manager := NewManager(ctx, cfg, mockResolver)
29+
30+ assert.True(t, manager.PrefersDark())
31+ assert.Equal(t, "default", manager.scheme)
32+}
33+
34+func TestNewManager_LightMode(t *testing.T) {
35+ ctx := context.Background()
36+ cfg := &config.Config{
37+ Appearance: config.AppearanceConfig{
38+ ColorScheme: "default",
39+ },
40+ }
41+
42+ mockResolver := mocks.NewMockColorSchemeResolver(t)
43+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
44+ PrefersDark: false,
45+ Source: "test",
46+ })
47+
48+ manager := NewManager(ctx, cfg, mockResolver)
49+
50+ assert.False(t, manager.PrefersDark())
51+}
52+
53+func TestNewManager_WithNilConfig(t *testing.T) {
54+ ctx := context.Background()
55+
56+ mockResolver := mocks.NewMockColorSchemeResolver(t)
57+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
58+ PrefersDark: true,
59+ Source: "fallback",
60+ })
61+
62+ manager := NewManager(ctx, nil, mockResolver)
63+
64+ require.NotNil(t, manager)
65+ assert.True(t, manager.PrefersDark())
66+ assert.Equal(t, "system", manager.scheme)
67+}
68+
69+func TestNewManager_UsesConfigScheme(t *testing.T) {
70+ ctx := context.Background()
71+ cfg := &config.Config{
72+ Appearance: config.AppearanceConfig{
73+ ColorScheme: "prefer-dark",
74+ },
75+ }
76+
77+ mockResolver := mocks.NewMockColorSchemeResolver(t)
78+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
79+ PrefersDark: true,
80+ Source: "config",
81+ })
82+
83+ manager := NewManager(ctx, cfg, mockResolver)
84+
85+ assert.Equal(t, "prefer-dark", manager.scheme)
86+}
87+
88+func TestManager_GetCurrentPalette_Dark(t *testing.T) {
89+ ctx := context.Background()
90+ cfg := &config.Config{}
91+
92+ mockResolver := mocks.NewMockColorSchemeResolver(t)
93+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
94+ PrefersDark: true,
95+ Source: "test",
96+ })
97+
98+ manager := NewManager(ctx, cfg, mockResolver)
99+ palette := manager.GetCurrentPalette()
100+
101+ // Dark palette should be returned
102+ assert.Equal(t, manager.GetDarkPalette(), palette)
103+}
104+
105+func TestManager_GetCurrentPalette_Light(t *testing.T) {
106+ ctx := context.Background()
107+ cfg := &config.Config{}
108+
109+ mockResolver := mocks.NewMockColorSchemeResolver(t)
110+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
111+ PrefersDark: false,
112+ Source: "test",
113+ })
114+
115+ manager := NewManager(ctx, cfg, mockResolver)
116+ palette := manager.GetCurrentPalette()
117+
118+ // Light palette should be returned
119+ assert.Equal(t, manager.GetLightPalette(), palette)
120+}
121+
122+func TestManager_SetColorScheme(t *testing.T) {
123+ ctx := context.Background()
124+ cfg := &config.Config{}
125+
126+ mockResolver := mocks.NewMockColorSchemeResolver(t)
127+ // Initial resolve call
128+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
129+ PrefersDark: true,
130+ Source: "test",
131+ })
132+ // Refresh call when SetColorScheme is called
133+ mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
134+ PrefersDark: false,
135+ Source: "config",
136+ })
137+
138+ manager := NewManager(ctx, cfg, mockResolver)
139+ assert.True(t, manager.PrefersDark())
140+
141+ // Change scheme - this calls Refresh on resolver
142+ manager.SetColorScheme(ctx, "prefer-light", nil)
143+
144+ assert.False(t, manager.PrefersDark())
145+ assert.Equal(t, "prefer-light", manager.scheme)
146+}
147+
148+func TestManager_UpdateFromConfig(t *testing.T) {
149+ ctx := context.Background()
150+ cfg := &config.Config{
151+ Appearance: config.AppearanceConfig{
152+ ColorScheme: "default",
153+ },
154+ }
155+
156+ mockResolver := mocks.NewMockColorSchemeResolver(t)
157+ // Initial resolve
158+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
159+ PrefersDark: true,
160+ Source: "test",
161+ })
162+ // Refresh when UpdateFromConfig is called
163+ mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
164+ PrefersDark: false,
165+ Source: "config",
166+ })
167+
168+ manager := NewManager(ctx, cfg, mockResolver)
169+ assert.True(t, manager.PrefersDark())
170+
171+ // Update with new config
172+ newCfg := &config.Config{
173+ Appearance: config.AppearanceConfig{
174+ ColorScheme: "prefer-light",
175+ },
176+ }
177+ manager.UpdateFromConfig(ctx, newCfg, nil)
178+
179+ assert.False(t, manager.PrefersDark())
180+ assert.Equal(t, "prefer-light", manager.scheme)
181+}
182+
183+func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
184+ ctx := context.Background()
185+ cfg := &config.Config{}
186+
187+ mockResolver := mocks.NewMockColorSchemeResolver(t)
188+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
189+ PrefersDark: true,
190+ Source: "test",
191+ })
192+
193+ manager := NewManager(ctx, cfg, mockResolver)
194+ initialScheme := manager.scheme
195+
196+ // UpdateFromConfig with nil should be a no-op
197+ manager.UpdateFromConfig(ctx, nil, nil)
198+
199+ assert.Equal(t, initialScheme, manager.scheme)
200+}
201+
202+func TestManager_GetWebUIThemeCSS(t *testing.T) {
203+ ctx := context.Background()
204+ cfg := &config.Config{}
205+
206+ mockResolver := mocks.NewMockColorSchemeResolver(t)
207+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
208+ PrefersDark: true,
209+ Source: "test",
210+ })
211+
212+ manager := NewManager(ctx, cfg, mockResolver)
213+ css := manager.GetWebUIThemeCSS()
214+
215+ // Should contain both light and dark variables
216+ assert.Contains(t, css, ":root{")
217+ assert.Contains(t, css, ".dark{")
218+}
219+
220+func TestManager_GetBackgroundRGBA(t *testing.T) {
221+ ctx := context.Background()
222+ cfg := &config.Config{}
223+
224+ mockResolver := mocks.NewMockColorSchemeResolver(t)
225+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
226+ PrefersDark: true,
227+ Source: "test",
228+ })
229+
230+ manager := NewManager(ctx, cfg, mockResolver)
231+ r, g, b, a := manager.GetBackgroundRGBA()
232+
233+ // Should return valid RGBA values (0-1 range)
234+ assert.GreaterOrEqual(t, r, float32(0))
235+ assert.LessOrEqual(t, r, float32(1))
236+ assert.GreaterOrEqual(t, g, float32(0))
237+ assert.LessOrEqual(t, g, float32(1))
238+ assert.GreaterOrEqual(t, b, float32(0))
239+ assert.LessOrEqual(t, b, float32(1))
240+ assert.GreaterOrEqual(t, a, float32(0))
241+ assert.LessOrEqual(t, a, float32(1))
242+}
243+
244+func TestManager_CustomPalettes(t *testing.T) {
245+ ctx := context.Background()
246+ cfg := &config.Config{
247+ Appearance: config.AppearanceConfig{
248+ LightPalette: config.ColorPalette{
249+ Background: "#ffffff",
250+ Text: "#000000",
251+ },
252+ DarkPalette: config.ColorPalette{
253+ Background: "#000000",
254+ Text: "#ffffff",
255+ },
256+ },
257+ }
258+
259+ mockResolver := mocks.NewMockColorSchemeResolver(t)
260+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
261+ PrefersDark: true,
262+ Source: "test",
263+ })
264+
265+ manager := NewManager(ctx, cfg, mockResolver)
266+
267+ lightPalette := manager.GetLightPalette()
268+ darkPalette := manager.GetDarkPalette()
269+
270+ assert.Equal(t, "#ffffff", lightPalette.Background)
271+ assert.Equal(t, "#000000", lightPalette.Text)
272+ assert.Equal(t, "#000000", darkPalette.Background)
273+ assert.Equal(t, "#ffffff", darkPalette.Text)
274+}
275+
276+func TestManager_UIScale(t *testing.T) {
277+ ctx := context.Background()
278+ cfg := &config.Config{
279+ DefaultUIScale: 1.5,
280+ }
281+
282+ mockResolver := mocks.NewMockColorSchemeResolver(t)
283+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
284+ PrefersDark: true,
285+ Source: "test",
286+ })
287+
288+ manager := NewManager(ctx, cfg, mockResolver)
289+
290+ // UI scale should be stored (we can't directly access it, but we can verify
291+ // the manager was created without error)
292+ require.NotNil(t, manager)
293+}
294+
295+func TestManager_Fonts(t *testing.T) {
296+ ctx := context.Background()
297+ cfg := &config.Config{
298+ Appearance: config.AppearanceConfig{
299+ SansFont: "Inter",
300+ MonospaceFont: "JetBrains Mono",
301+ },
302+ }
303+
304+ mockResolver := mocks.NewMockColorSchemeResolver(t)
305+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
306+ PrefersDark: true,
307+ Source: "test",
308+ })
309+
310+ manager := NewManager(ctx, cfg, mockResolver)
311+
312+ // Fonts should be stored (manager creation should succeed)
313+ require.NotNil(t, manager)
314+}
315+
316+func TestManager_ModeColors(t *testing.T) {
317+ ctx := context.Background()
318+ cfg := &config.Config{
319+ Workspace: config.WorkspaceConfig{
320+ Styling: config.WorkspaceStylingConfig{
321+ PaneModeColor: "#ff0000",
322+ TabModeColor: "#00ff00",
323+ SessionModeColor: "#0000ff",
324+ ResizeModeColor: "#ffff00",
325+ },
326+ },
327+ }
328+
329+ mockResolver := mocks.NewMockColorSchemeResolver(t)
330+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
331+ PrefersDark: true,
332+ Source: "test",
333+ })
334+
335+ manager := NewManager(ctx, cfg, mockResolver)
336+ modeColors := manager.GetModeColors()
337+
338+ assert.Equal(t, "#ff0000", modeColors.PaneMode)
339+ assert.Equal(t, "#00ff00", modeColors.TabMode)
340+ assert.Equal(t, "#0000ff", modeColors.SessionMode)
341+ assert.Equal(t, "#ffff00", modeColors.ResizeMode)
342+}
@@ -0,0 +1,342 @@
1+package theme
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestNewManager_DarkMode(t *testing.T) {
15+ ctx := context.Background()
16+ cfg := &config.Config{
17+ Appearance: config.AppearanceConfig{
18+ ColorScheme: "default",
19+ },
20+ }
21+
22+ mockResolver := mocks.NewMockColorSchemeResolver(t)
23+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
24+ PrefersDark: true,
25+ Source: "test",
26+ })
27+
28+ manager := NewManager(ctx, cfg, mockResolver)
29+
30+ assert.True(t, manager.PrefersDark())
31+ assert.Equal(t, "default", manager.scheme)
32+}
33+
34+func TestNewManager_LightMode(t *testing.T) {
35+ ctx := context.Background()
36+ cfg := &config.Config{
37+ Appearance: config.AppearanceConfig{
38+ ColorScheme: "default",
39+ },
40+ }
41+
42+ mockResolver := mocks.NewMockColorSchemeResolver(t)
43+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
44+ PrefersDark: false,
45+ Source: "test",
46+ })
47+
48+ manager := NewManager(ctx, cfg, mockResolver)
49+
50+ assert.False(t, manager.PrefersDark())
51+}
52+
53+func TestNewManager_WithNilConfig(t *testing.T) {
54+ ctx := context.Background()
55+
56+ mockResolver := mocks.NewMockColorSchemeResolver(t)
57+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
58+ PrefersDark: true,
59+ Source: "fallback",
60+ })
61+
62+ manager := NewManager(ctx, nil, mockResolver)
63+
64+ require.NotNil(t, manager)
65+ assert.True(t, manager.PrefersDark())
66+ assert.Equal(t, "system", manager.scheme)
67+}
68+
69+func TestNewManager_UsesConfigScheme(t *testing.T) {
70+ ctx := context.Background()
71+ cfg := &config.Config{
72+ Appearance: config.AppearanceConfig{
73+ ColorScheme: "prefer-dark",
74+ },
75+ }
76+
77+ mockResolver := mocks.NewMockColorSchemeResolver(t)
78+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
79+ PrefersDark: true,
80+ Source: "config",
81+ })
82+
83+ manager := NewManager(ctx, cfg, mockResolver)
84+
85+ assert.Equal(t, "prefer-dark", manager.scheme)
86+}
87+
88+func TestManager_GetCurrentPalette_Dark(t *testing.T) {
89+ ctx := context.Background()
90+ cfg := &config.Config{}
91+
92+ mockResolver := mocks.NewMockColorSchemeResolver(t)
93+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
94+ PrefersDark: true,
95+ Source: "test",
96+ })
97+
98+ manager := NewManager(ctx, cfg, mockResolver)
99+ palette := manager.GetCurrentPalette()
100+
101+ // Dark palette should be returned
102+ assert.Equal(t, manager.GetDarkPalette(), palette)
103+}
104+
105+func TestManager_GetCurrentPalette_Light(t *testing.T) {
106+ ctx := context.Background()
107+ cfg := &config.Config{}
108+
109+ mockResolver := mocks.NewMockColorSchemeResolver(t)
110+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
111+ PrefersDark: false,
112+ Source: "test",
113+ })
114+
115+ manager := NewManager(ctx, cfg, mockResolver)
116+ palette := manager.GetCurrentPalette()
117+
118+ // Light palette should be returned
119+ assert.Equal(t, manager.GetLightPalette(), palette)
120+}
121+
122+func TestManager_SetColorScheme(t *testing.T) {
123+ ctx := context.Background()
124+ cfg := &config.Config{}
125+
126+ mockResolver := mocks.NewMockColorSchemeResolver(t)
127+ // Initial resolve call
128+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
129+ PrefersDark: true,
130+ Source: "test",
131+ })
132+ // Refresh call when SetColorScheme is called
133+ mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
134+ PrefersDark: false,
135+ Source: "config",
136+ })
137+
138+ manager := NewManager(ctx, cfg, mockResolver)
139+ assert.True(t, manager.PrefersDark())
140+
141+ // Change scheme - this calls Refresh on resolver
142+ manager.SetColorScheme(ctx, "prefer-light", nil)
143+
144+ assert.False(t, manager.PrefersDark())
145+ assert.Equal(t, "prefer-light", manager.scheme)
146+}
147+
148+func TestManager_UpdateFromConfig(t *testing.T) {
149+ ctx := context.Background()
150+ cfg := &config.Config{
151+ Appearance: config.AppearanceConfig{
152+ ColorScheme: "default",
153+ },
154+ }
155+
156+ mockResolver := mocks.NewMockColorSchemeResolver(t)
157+ // Initial resolve
158+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
159+ PrefersDark: true,
160+ Source: "test",
161+ })
162+ // Refresh when UpdateFromConfig is called
163+ mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
164+ PrefersDark: false,
165+ Source: "config",
166+ })
167+
168+ manager := NewManager(ctx, cfg, mockResolver)
169+ assert.True(t, manager.PrefersDark())
170+
171+ // Update with new config
172+ newCfg := &config.Config{
173+ Appearance: config.AppearanceConfig{
174+ ColorScheme: "prefer-light",
175+ },
176+ }
177+ manager.UpdateFromConfig(ctx, newCfg, nil)
178+
179+ assert.False(t, manager.PrefersDark())
180+ assert.Equal(t, "prefer-light", manager.scheme)
181+}
182+
183+func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
184+ ctx := context.Background()
185+ cfg := &config.Config{}
186+
187+ mockResolver := mocks.NewMockColorSchemeResolver(t)
188+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
189+ PrefersDark: true,
190+ Source: "test",
191+ })
192+
193+ manager := NewManager(ctx, cfg, mockResolver)
194+ initialScheme := manager.scheme
195+
196+ // UpdateFromConfig with nil should be a no-op
197+ manager.UpdateFromConfig(ctx, nil, nil)
198+
199+ assert.Equal(t, initialScheme, manager.scheme)
200+}
201+
202+func TestManager_GetWebUIThemeCSS(t *testing.T) {
203+ ctx := context.Background()
204+ cfg := &config.Config{}
205+
206+ mockResolver := mocks.NewMockColorSchemeResolver(t)
207+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
208+ PrefersDark: true,
209+ Source: "test",
210+ })
211+
212+ manager := NewManager(ctx, cfg, mockResolver)
213+ css := manager.GetWebUIThemeCSS()
214+
215+ // Should contain both light and dark variables
216+ assert.Contains(t, css, ":root{")
217+ assert.Contains(t, css, ".dark{")
218+}
219+
220+func TestManager_GetBackgroundRGBA(t *testing.T) {
221+ ctx := context.Background()
222+ cfg := &config.Config{}
223+
224+ mockResolver := mocks.NewMockColorSchemeResolver(t)
225+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
226+ PrefersDark: true,
227+ Source: "test",
228+ })
229+
230+ manager := NewManager(ctx, cfg, mockResolver)
231+ r, g, b, a := manager.GetBackgroundRGBA()
232+
233+ // Should return valid RGBA values (0-1 range)
234+ assert.GreaterOrEqual(t, r, float32(0))
235+ assert.LessOrEqual(t, r, float32(1))
236+ assert.GreaterOrEqual(t, g, float32(0))
237+ assert.LessOrEqual(t, g, float32(1))
238+ assert.GreaterOrEqual(t, b, float32(0))
239+ assert.LessOrEqual(t, b, float32(1))
240+ assert.GreaterOrEqual(t, a, float32(0))
241+ assert.LessOrEqual(t, a, float32(1))
242+}
243+
244+func TestManager_CustomPalettes(t *testing.T) {
245+ ctx := context.Background()
246+ cfg := &config.Config{
247+ Appearance: config.AppearanceConfig{
248+ LightPalette: config.ColorPalette{
249+ Background: "#ffffff",
250+ Text: "#000000",
251+ },
252+ DarkPalette: config.ColorPalette{
253+ Background: "#000000",
254+ Text: "#ffffff",
255+ },
256+ },
257+ }
258+
259+ mockResolver := mocks.NewMockColorSchemeResolver(t)
260+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
261+ PrefersDark: true,
262+ Source: "test",
263+ })
264+
265+ manager := NewManager(ctx, cfg, mockResolver)
266+
267+ lightPalette := manager.GetLightPalette()
268+ darkPalette := manager.GetDarkPalette()
269+
270+ assert.Equal(t, "#ffffff", lightPalette.Background)
271+ assert.Equal(t, "#000000", lightPalette.Text)
272+ assert.Equal(t, "#000000", darkPalette.Background)
273+ assert.Equal(t, "#ffffff", darkPalette.Text)
274+}
275+
276+func TestManager_UIScale(t *testing.T) {
277+ ctx := context.Background()
278+ cfg := &config.Config{
279+ DefaultUIScale: 1.5,
280+ }
281+
282+ mockResolver := mocks.NewMockColorSchemeResolver(t)
283+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
284+ PrefersDark: true,
285+ Source: "test",
286+ })
287+
288+ manager := NewManager(ctx, cfg, mockResolver)
289+
290+ // UI scale should be stored (we can't directly access it, but we can verify
291+ // the manager was created without error)
292+ require.NotNil(t, manager)
293+}
294+
295+func TestManager_Fonts(t *testing.T) {
296+ ctx := context.Background()
297+ cfg := &config.Config{
298+ Appearance: config.AppearanceConfig{
299+ SansFont: "Inter",
300+ MonospaceFont: "JetBrains Mono",
301+ },
302+ }
303+
304+ mockResolver := mocks.NewMockColorSchemeResolver(t)
305+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
306+ PrefersDark: true,
307+ Source: "test",
308+ })
309+
310+ manager := NewManager(ctx, cfg, mockResolver)
311+
312+ // Fonts should be stored (manager creation should succeed)
313+ require.NotNil(t, manager)
314+}
315+
316+func TestManager_ModeColors(t *testing.T) {
317+ ctx := context.Background()
318+ cfg := &config.Config{
319+ Workspace: config.WorkspaceConfig{
320+ Styling: config.WorkspaceStylingConfig{
321+ PaneModeColor: "#ff0000",
322+ TabModeColor: "#00ff00",
323+ SessionModeColor: "#0000ff",
324+ ResizeModeColor: "#ffff00",
325+ },
326+ },
327+ }
328+
329+ mockResolver := mocks.NewMockColorSchemeResolver(t)
330+ mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
331+ PrefersDark: true,
332+ Source: "test",
333+ })
334+
335+ manager := NewManager(ctx, cfg, mockResolver)
336+ modeColors := manager.GetModeColors()
337+
338+ assert.Equal(t, "#ff0000", modeColors.PaneMode)
339+ assert.Equal(t, "#00ff00", modeColors.TabMode)
340+ assert.Equal(t, "#0000ff", modeColors.SessionMode)
341+ assert.Equal(t, "#ffff00", modeColors.ResizeMode)
342+}
D internal/ui/theme/system.go +0 -78
@@ -1,78 +0,0 @@
1-package theme
2-
3-import (
4- "os"
5- "os/exec"
6- "strings"
7-
8- "github.com/jwijenbergh/puregotk/v4/gtk"
9-)
10-
11-// DetectSystemDarkMode checks if the system prefers dark mode.
12-// It checks multiple sources in order of preference:
13-// 1. GTK_THEME environment variable (contains "dark")
14-// 2. GNOME gsettings color-scheme (prefer-dark/prefer-light)
15-// 3. GTK Settings gtk-application-prefer-dark-theme property
16-// Returns true if dark mode is preferred.
17-func DetectSystemDarkMode() bool {
18- // Check GTK_THEME environment variable first
19- gtkTheme := os.Getenv("GTK_THEME")
20- if gtkTheme != "" {
21- if strings.Contains(strings.ToLower(gtkTheme), "dark") {
22- return true
23- }
24- // If GTK_THEME is set but doesn't contain "dark", it's likely light
25- return false
26- }
27-
28- // Check GNOME gsettings color-scheme (modern GNOME/GTK4 method)
29- if colorScheme := getGsettingsColorScheme(); colorScheme != "" {
30- switch colorScheme {
31- case "prefer-dark":
32- return true
33- case "prefer-light":
34- return false
35- // "default" falls through to next check
36- }
37- }
38-
39- // Try GTK Settings
40- settings := gtk.SettingsGetDefault()
41- if settings != nil {
42- // gtk-application-prefer-dark-theme is a boolean property
43- preferDark := settings.GetPropertyGtkApplicationPreferDarkTheme()
44- return preferDark
45- }
46-
47- // Default to dark mode if detection fails (better for eyes)
48- return true
49-}
50-
51-// getGsettingsColorScheme queries GNOME gsettings for the color-scheme preference.
52-// Returns "prefer-dark", "prefer-light", "default", or empty string on error.
53-func getGsettingsColorScheme() string {
54- cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
55- output, err := cmd.Output()
56- if err != nil {
57- return ""
58- }
59- // Output is like "'prefer-dark'\n", strip quotes and whitespace
60- result := strings.TrimSpace(string(output))
61- result = strings.Trim(result, "'\"")
62- return result
63-}
64-
65-// ResolveColorScheme determines the effective dark mode preference.
66-// It takes the config value ("prefer-dark", "prefer-light", "default")
67-// and resolves "default" to the system preference.
68-func ResolveColorScheme(configScheme string) bool {
69- switch strings.ToLower(configScheme) {
70- case "prefer-dark", "dark":
71- return true
72- case "prefer-light", "light":
73- return false
74- default:
75- // "default" or empty - follow system
76- return DetectSystemDarkMode()
77- }
78-}
@@ -1,78 +0,0 @@
1-package theme
2-
3-import (
4- "os"
5- "os/exec"
6- "strings"
7-
8- "github.com/jwijenbergh/puregotk/v4/gtk"
9-)
10-
11-// DetectSystemDarkMode checks if the system prefers dark mode.
12-// It checks multiple sources in order of preference:
13-// 1. GTK_THEME environment variable (contains "dark")
14-// 2. GNOME gsettings color-scheme (prefer-dark/prefer-light)
15-// 3. GTK Settings gtk-application-prefer-dark-theme property
16-// Returns true if dark mode is preferred.
17-func DetectSystemDarkMode() bool {
18- // Check GTK_THEME environment variable first
19- gtkTheme := os.Getenv("GTK_THEME")
20- if gtkTheme != "" {
21- if strings.Contains(strings.ToLower(gtkTheme), "dark") {
22- return true
23- }
24- // If GTK_THEME is set but doesn't contain "dark", it's likely light
25- return false
26- }
27-
28- // Check GNOME gsettings color-scheme (modern GNOME/GTK4 method)
29- if colorScheme := getGsettingsColorScheme(); colorScheme != "" {
30- switch colorScheme {
31- case "prefer-dark":
32- return true
33- case "prefer-light":
34- return false
35- // "default" falls through to next check
36- }
37- }
38-
39- // Try GTK Settings
40- settings := gtk.SettingsGetDefault()
41- if settings != nil {
42- // gtk-application-prefer-dark-theme is a boolean property
43- preferDark := settings.GetPropertyGtkApplicationPreferDarkTheme()
44- return preferDark
45- }
46-
47- // Default to dark mode if detection fails (better for eyes)
48- return true
49-}
50-
51-// getGsettingsColorScheme queries GNOME gsettings for the color-scheme preference.
52-// Returns "prefer-dark", "prefer-light", "default", or empty string on error.
53-func getGsettingsColorScheme() string {
54- cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
55- output, err := cmd.Output()
56- if err != nil {
57- return ""
58- }
59- // Output is like "'prefer-dark'\n", strip quotes and whitespace
60- result := strings.TrimSpace(string(output))
61- result = strings.Trim(result, "'\"")
62- return result
63-}
64-
65-// ResolveColorScheme determines the effective dark mode preference.
66-// It takes the config value ("prefer-dark", "prefer-light", "default")
67-// and resolves "default" to the system preference.
68-func ResolveColorScheme(configScheme string) bool {
69- switch strings.ToLower(configScheme) {
70- case "prefer-dark", "dark":
71- return true
72- case "prefer-light", "light":
73- return false
74- default:
75- // "default" or empty - follow system
76- return DetectSystemDarkMode()
77- }
78-}

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