detached f422341

Merge pull request #71 from bnema/fix/color-scheme-detection

fix: dark mode detection on internal pages
commit
f4223410ed335bb54491afb79373a777e0a9b3b4
author
bnema · · 2025-12-30 04:16 UTC
committer
GitHub · · 2025-12-30 04:16 UTC
merge of
e9a1267 + c65109c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
29 files · +2296 -239
M .github/workflows/ci.yml +4 -1
@@ -77,7 +77,7 @@ jobs:
7777 - name: Install dependencies
7878 run: |
7979 pacman -Syu --noconfirm
80- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel
80+ pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
8181 - uses: actions/checkout@v6
8282 - name: Cache Go modules
8383 uses: actions/cache@v4
@@ -103,4 +103,7 @@ jobs:
103103 - name: Build frontend assets
104104 run: make build-frontend
105105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
106109 run: go test -v ./...
@@ -77,7 +77,7 @@ jobs:
77 - name: Install dependencies77 - name: Install dependencies
78 run: |78 run: |
79 pacman -Syu --noconfirm79 pacman -Syu --noconfirm
80- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel80+ pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
81 - uses: actions/checkout@v681 - uses: actions/checkout@v6
82 - name: Cache Go modules82 - name: Cache Go modules
83 uses: actions/cache@v483 uses: actions/cache@v4
@@ -103,4 +103,7 @@ jobs:
103 - name: Build frontend assets103 - name: Build frontend assets
104 run: make build-frontend104 run: make build-frontend
105 - name: Run tests105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
106 run: go test -v ./...109 run: go test -v ./...
M .mockery.yaml +2 -0
@@ -21,6 +21,8 @@ packages:
2121 MemoryPressureApplier: {}
2222 WebKitContextProvider: {}
2323 HardwareSurveyor: {}
24+ ColorSchemeDetector: {}
25+ ColorSchemeResolver: {}
2426 github.com/bnema/dumber/internal/domain/repository:
2527 config:
2628 dir: '{{.InterfaceDir}}/mocks'
@@ -21,6 +21,8 @@ packages:
21 MemoryPressureApplier: {}21 MemoryPressureApplier: {}
22 WebKitContextProvider: {}22 WebKitContextProvider: {}
23 HardwareSurveyor: {}23 HardwareSurveyor: {}
24+ ColorSchemeDetector: {}
25+ ColorSchemeResolver: {}
24 github.com/bnema/dumber/internal/domain/repository:26 github.com/bnema/dumber/internal/domain/repository:
25 config:27 config:
26 dir: '{{.InterfaceDir}}/mocks'28 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +3 -0
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Fixed
8+- **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.
9+
710 ## [0.22.0] - 2025-12-28
811
912 ### Added
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Fixed
8+- **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.
9+
7 ## [0.22.0] - 2025-12-2810 ## [0.22.0] - 2025-12-28
8 11
9 ### Added12 ### Added
M cmd/dumber/main.go +25 -7
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/dumber/internal/domain/repository"
1919 "github.com/bnema/dumber/internal/infrastructure/cache"
2020 "github.com/bnema/dumber/internal/infrastructure/clipboard"
21+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
2122 "github.com/bnema/dumber/internal/infrastructure/config"
2223 "github.com/bnema/dumber/internal/infrastructure/deps"
2324 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -130,7 +131,11 @@ func runGUI() int {
130131 timer.Mark("use_cases")
131132
132133 // Build UI
133- uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ uiDeps := buildUIDependencies(
135+ ctx, cfg, initResult.ThemeManager,
136+ initResult.ColorResolver, initResult.AdwaitaDetector,
137+ &stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
138+ )
134139 configureDeferredInit(uiDeps, cfg, browserSession)
135140 app, err := ui.New(uiDeps)
136141 if err != nil {
@@ -171,11 +176,12 @@ func initStackAndRepos(
171176 if needsEagerDB {
172177 // Parallel phase 2: Database + WebKit stack initialize concurrently
173178 dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174- Ctx: ctx,
175- Config: cfg,
176- DataDir: initResult.DataDir,
177- CacheDir: initResult.CacheDir,
178- ThemeManager: initResult.ThemeManager,
179+ Ctx: ctx,
180+ Config: cfg,
181+ DataDir: initResult.DataDir,
182+ CacheDir: initResult.CacheDir,
183+ ThemeManager: initResult.ThemeManager,
184+ ColorResolver: initResult.ColorResolver,
179185 })
180186 if err != nil {
181187 return bootstrap.WebKitStack{}, nil, nil, err
@@ -184,7 +190,15 @@ func initStackAndRepos(
184190 }
185191
186192 log := logging.FromContext(ctx)
187- stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
193+ stack := bootstrap.BuildWebKitStack(bootstrap.WebKitStackInput{
194+ Ctx: ctx,
195+ Config: cfg,
196+ DataDir: initResult.DataDir,
197+ CacheDir: initResult.CacheDir,
198+ ThemeManager: initResult.ThemeManager,
199+ ColorResolver: initResult.ColorResolver,
200+ Logger: *log,
201+ })
188202
189203 lazyDB, err := bootstrap.CreateLazyDatabase()
190204 if err != nil {
@@ -398,6 +412,8 @@ func buildUIDependencies(
398412 ctx context.Context,
399413 cfg *config.Config,
400414 themeManager *theme.Manager,
415+ colorResolver port.ColorSchemeResolver,
416+ adwaitaDetector *colorscheme.AdwaitaDetector,
401417 stack *bootstrap.WebKitStack,
402418 repos *repositories,
403419 uc *useCases,
@@ -410,6 +426,8 @@ func buildUIDependencies(
410426 InitialURL: initialURL,
411427 RestoreSessionID: restoreSessionID,
412428 Theme: themeManager,
429+ ColorResolver: colorResolver,
430+ AdwaitaDetector: adwaitaDetector,
413431 WebContext: stack.Context,
414432 Pool: stack.Pool,
415433 Settings: stack.Settings,
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/dumber/internal/domain/repository"18 "github.com/bnema/dumber/internal/domain/repository"
19 "github.com/bnema/dumber/internal/infrastructure/cache"19 "github.com/bnema/dumber/internal/infrastructure/cache"
20 "github.com/bnema/dumber/internal/infrastructure/clipboard"20 "github.com/bnema/dumber/internal/infrastructure/clipboard"
21+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
21 "github.com/bnema/dumber/internal/infrastructure/config"22 "github.com/bnema/dumber/internal/infrastructure/config"
22 "github.com/bnema/dumber/internal/infrastructure/deps"23 "github.com/bnema/dumber/internal/infrastructure/deps"
23 "github.com/bnema/dumber/internal/infrastructure/favicon"24 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -130,7 +131,11 @@ func runGUI() int {
130 timer.Mark("use_cases")131 timer.Mark("use_cases")
131 132
132 // Build UI133 // Build UI
133- uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)134+ uiDeps := buildUIDependencies(
135+ ctx, cfg, initResult.ThemeManager,
136+ initResult.ColorResolver, initResult.AdwaitaDetector,
137+ &stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
138+ )
134 configureDeferredInit(uiDeps, cfg, browserSession)139 configureDeferredInit(uiDeps, cfg, browserSession)
135 app, err := ui.New(uiDeps)140 app, err := ui.New(uiDeps)
136 if err != nil {141 if err != nil {
@@ -171,11 +176,12 @@ func initStackAndRepos(
171 if needsEagerDB {176 if needsEagerDB {
172 // Parallel phase 2: Database + WebKit stack initialize concurrently177 // Parallel phase 2: Database + WebKit stack initialize concurrently
173 dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{178 dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174- Ctx: ctx,179+ Ctx: ctx,
175- Config: cfg,180+ Config: cfg,
176- DataDir: initResult.DataDir,181+ DataDir: initResult.DataDir,
177- CacheDir: initResult.CacheDir,182+ CacheDir: initResult.CacheDir,
178- ThemeManager: initResult.ThemeManager,183+ ThemeManager: initResult.ThemeManager,
184+ ColorResolver: initResult.ColorResolver,
179 })185 })
180 if err != nil {186 if err != nil {
181 return bootstrap.WebKitStack{}, nil, nil, err187 return bootstrap.WebKitStack{}, nil, nil, err
@@ -184,7 +190,15 @@ func initStackAndRepos(
184 }190 }
185 191
186 log := logging.FromContext(ctx)192 log := logging.FromContext(ctx)
187- stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)193+ stack := bootstrap.BuildWebKitStack(bootstrap.WebKitStackInput{
194+ Ctx: ctx,
195+ Config: cfg,
196+ DataDir: initResult.DataDir,
197+ CacheDir: initResult.CacheDir,
198+ ThemeManager: initResult.ThemeManager,
199+ ColorResolver: initResult.ColorResolver,
200+ Logger: *log,
201+ })
188 202
189 lazyDB, err := bootstrap.CreateLazyDatabase()203 lazyDB, err := bootstrap.CreateLazyDatabase()
190 if err != nil {204 if err != nil {
@@ -398,6 +412,8 @@ func buildUIDependencies(
398 ctx context.Context,412 ctx context.Context,
399 cfg *config.Config,413 cfg *config.Config,
400 themeManager *theme.Manager,414 themeManager *theme.Manager,
415+ colorResolver port.ColorSchemeResolver,
416+ adwaitaDetector *colorscheme.AdwaitaDetector,
401 stack *bootstrap.WebKitStack,417 stack *bootstrap.WebKitStack,
402 repos *repositories,418 repos *repositories,
403 uc *useCases,419 uc *useCases,
@@ -410,6 +426,8 @@ func buildUIDependencies(
410 InitialURL: initialURL,426 InitialURL: initialURL,
411 RestoreSessionID: restoreSessionID,427 RestoreSessionID: restoreSessionID,
412 Theme: themeManager,428 Theme: themeManager,
429+ ColorResolver: colorResolver,
430+ AdwaitaDetector: adwaitaDetector,
413 WebContext: stack.Context,431 WebContext: stack.Context,
414 Pool: stack.Pool,432 Pool: stack.Pool,
415 Settings: stack.Settings,433 Settings: stack.Settings,
A internal/application/port/color_scheme.go +57 -0
@@ -0,0 +1,57 @@
1+package port
2+
3+// ColorSchemePreference represents the resolved color scheme preference.
4+type ColorSchemePreference struct {
5+ // PrefersDark indicates whether dark mode is preferred.
6+ PrefersDark bool
7+
8+ // Source identifies which detector provided this preference.
9+ // Empty string means fallback was used.
10+ Source string
11+}
12+
13+// ColorSchemeDetector detects the system's color scheme preference.
14+// Multiple detectors can be registered with different priorities.
15+type ColorSchemeDetector interface {
16+ // Name returns a human-readable name for this detector.
17+ Name() string
18+
19+ // Priority returns the detector's priority.
20+ // Higher values = higher priority (checked first).
21+ // Recommended ranges:
22+ // - 100+: Runtime detectors (libadwaita, GTK Settings)
23+ // - 50+: Config file detectors
24+ // - 10+: Fallback detectors (gsettings, env vars)
25+ Priority() int
26+
27+ // Available returns true if this detector can be used.
28+ // For example, libadwaita detector returns false before adw.Init().
29+ Available() bool
30+
31+ // Detect returns the detected preference and whether detection succeeded.
32+ // Returns (preference, true) on success, (_, false) if unavailable or detection failed.
33+ Detect() (prefersDark bool, ok bool)
34+}
35+
36+// ColorSchemeResolver resolves the effective color scheme preference.
37+// It manages multiple detectors and respects config overrides.
38+type ColorSchemeResolver interface {
39+ // Resolve returns the current color scheme preference.
40+ // It checks config for explicit overrides, then queries detectors by priority.
41+ // If all detectors fail, defaults to dark mode.
42+ Resolve() ColorSchemePreference
43+
44+ // RegisterDetector adds a detector to the resolver.
45+ // Safe to call at any time; the resolver re-evaluates on next Resolve().
46+ RegisterDetector(detector ColorSchemeDetector)
47+
48+ // Refresh forces re-evaluation of the color scheme.
49+ // Call this after registering new detectors or when system preferences change.
50+ // Returns the new preference.
51+ Refresh() ColorSchemePreference
52+
53+ // OnChange registers a callback for color scheme changes.
54+ // The callback is invoked when Refresh() results in a different preference.
55+ // Returns a function to unregister the callback.
56+ OnChange(callback func(ColorSchemePreference)) func()
57+}
@@ -0,0 +1,57 @@
1+package port
2+
3+// ColorSchemePreference represents the resolved color scheme preference.
4+type ColorSchemePreference struct {
5+ // PrefersDark indicates whether dark mode is preferred.
6+ PrefersDark bool
7+
8+ // Source identifies which detector provided this preference.
9+ // Empty string means fallback was used.
10+ Source string
11+}
12+
13+// ColorSchemeDetector detects the system's color scheme preference.
14+// Multiple detectors can be registered with different priorities.
15+type ColorSchemeDetector interface {
16+ // Name returns a human-readable name for this detector.
17+ Name() string
18+
19+ // Priority returns the detector's priority.
20+ // Higher values = higher priority (checked first).
21+ // Recommended ranges:
22+ // - 100+: Runtime detectors (libadwaita, GTK Settings)
23+ // - 50+: Config file detectors
24+ // - 10+: Fallback detectors (gsettings, env vars)
25+ Priority() int
26+
27+ // Available returns true if this detector can be used.
28+ // For example, libadwaita detector returns false before adw.Init().
29+ Available() bool
30+
31+ // Detect returns the detected preference and whether detection succeeded.
32+ // Returns (preference, true) on success, (_, false) if unavailable or detection failed.
33+ Detect() (prefersDark bool, ok bool)
34+}
35+
36+// ColorSchemeResolver resolves the effective color scheme preference.
37+// It manages multiple detectors and respects config overrides.
38+type ColorSchemeResolver interface {
39+ // Resolve returns the current color scheme preference.
40+ // It checks config for explicit overrides, then queries detectors by priority.
41+ // If all detectors fail, defaults to dark mode.
42+ Resolve() ColorSchemePreference
43+
44+ // RegisterDetector adds a detector to the resolver.
45+ // Safe to call at any time; the resolver re-evaluates on next Resolve().
46+ RegisterDetector(detector ColorSchemeDetector)
47+
48+ // Refresh forces re-evaluation of the color scheme.
49+ // Call this after registering new detectors or when system preferences change.
50+ // Returns the new preference.
51+ Refresh() ColorSchemePreference
52+
53+ // OnChange registers a callback for color scheme changes.
54+ // The callback is invoked when Refresh() results in a different preference.
55+ // Returns a function to unregister the callback.
56+ OnChange(callback func(ColorSchemePreference)) func()
57+}
M internal/application/port/mocks/mocks.go +420 -0
@@ -14,6 +14,426 @@ import (
1414 mock "github.com/stretchr/testify/mock"
1515 )
1616
17+// NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18+// The first argument is typically a *testing.T value.
19+func NewMockColorSchemeDetector(t interface {
20+ mock.TestingT
21+ Cleanup(func())
22+}) *MockColorSchemeDetector {
23+ mock := &MockColorSchemeDetector{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() { mock.AssertExpectations(t) })
27+
28+ return mock
29+}
30+
31+// MockColorSchemeDetector is an autogenerated mock type for the ColorSchemeDetector type
32+type MockColorSchemeDetector struct {
33+ mock.Mock
34+}
35+
36+type MockColorSchemeDetector_Expecter struct {
37+ mock *mock.Mock
38+}
39+
40+func (_m *MockColorSchemeDetector) EXPECT() *MockColorSchemeDetector_Expecter {
41+ return &MockColorSchemeDetector_Expecter{mock: &_m.Mock}
42+}
43+
44+// Available provides a mock function for the type MockColorSchemeDetector
45+func (_mock *MockColorSchemeDetector) Available() bool {
46+ ret := _mock.Called()
47+
48+ if len(ret) == 0 {
49+ panic("no return value specified for Available")
50+ }
51+
52+ var r0 bool
53+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
54+ r0 = returnFunc()
55+ } else {
56+ r0 = ret.Get(0).(bool)
57+ }
58+ return r0
59+}
60+
61+// MockColorSchemeDetector_Available_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Available'
62+type MockColorSchemeDetector_Available_Call struct {
63+ *mock.Call
64+}
65+
66+// Available is a helper method to define mock.On call
67+func (_e *MockColorSchemeDetector_Expecter) Available() *MockColorSchemeDetector_Available_Call {
68+ return &MockColorSchemeDetector_Available_Call{Call: _e.mock.On("Available")}
69+}
70+
71+func (_c *MockColorSchemeDetector_Available_Call) Run(run func()) *MockColorSchemeDetector_Available_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ run()
74+ })
75+ return _c
76+}
77+
78+func (_c *MockColorSchemeDetector_Available_Call) Return(b bool) *MockColorSchemeDetector_Available_Call {
79+ _c.Call.Return(b)
80+ return _c
81+}
82+
83+func (_c *MockColorSchemeDetector_Available_Call) RunAndReturn(run func() bool) *MockColorSchemeDetector_Available_Call {
84+ _c.Call.Return(run)
85+ return _c
86+}
87+
88+// Detect provides a mock function for the type MockColorSchemeDetector
89+func (_mock *MockColorSchemeDetector) Detect() (bool, bool) {
90+ ret := _mock.Called()
91+
92+ if len(ret) == 0 {
93+ panic("no return value specified for Detect")
94+ }
95+
96+ var r0 bool
97+ var r1 bool
98+ if returnFunc, ok := ret.Get(0).(func() (bool, bool)); ok {
99+ return returnFunc()
100+ }
101+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
102+ r0 = returnFunc()
103+ } else {
104+ r0 = ret.Get(0).(bool)
105+ }
106+ if returnFunc, ok := ret.Get(1).(func() bool); ok {
107+ r1 = returnFunc()
108+ } else {
109+ r1 = ret.Get(1).(bool)
110+ }
111+ return r0, r1
112+}
113+
114+// MockColorSchemeDetector_Detect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detect'
115+type MockColorSchemeDetector_Detect_Call struct {
116+ *mock.Call
117+}
118+
119+// Detect is a helper method to define mock.On call
120+func (_e *MockColorSchemeDetector_Expecter) Detect() *MockColorSchemeDetector_Detect_Call {
121+ return &MockColorSchemeDetector_Detect_Call{Call: _e.mock.On("Detect")}
122+}
123+
124+func (_c *MockColorSchemeDetector_Detect_Call) Run(run func()) *MockColorSchemeDetector_Detect_Call {
125+ _c.Call.Run(func(args mock.Arguments) {
126+ run()
127+ })
128+ return _c
129+}
130+
131+func (_c *MockColorSchemeDetector_Detect_Call) Return(prefersDark bool, ok bool) *MockColorSchemeDetector_Detect_Call {
132+ _c.Call.Return(prefersDark, ok)
133+ return _c
134+}
135+
136+func (_c *MockColorSchemeDetector_Detect_Call) RunAndReturn(run func() (bool, bool)) *MockColorSchemeDetector_Detect_Call {
137+ _c.Call.Return(run)
138+ return _c
139+}
140+
141+// Name provides a mock function for the type MockColorSchemeDetector
142+func (_mock *MockColorSchemeDetector) Name() string {
143+ ret := _mock.Called()
144+
145+ if len(ret) == 0 {
146+ panic("no return value specified for Name")
147+ }
148+
149+ var r0 string
150+ if returnFunc, ok := ret.Get(0).(func() string); ok {
151+ r0 = returnFunc()
152+ } else {
153+ r0 = ret.Get(0).(string)
154+ }
155+ return r0
156+}
157+
158+// MockColorSchemeDetector_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'
159+type MockColorSchemeDetector_Name_Call struct {
160+ *mock.Call
161+}
162+
163+// Name is a helper method to define mock.On call
164+func (_e *MockColorSchemeDetector_Expecter) Name() *MockColorSchemeDetector_Name_Call {
165+ return &MockColorSchemeDetector_Name_Call{Call: _e.mock.On("Name")}
166+}
167+
168+func (_c *MockColorSchemeDetector_Name_Call) Run(run func()) *MockColorSchemeDetector_Name_Call {
169+ _c.Call.Run(func(args mock.Arguments) {
170+ run()
171+ })
172+ return _c
173+}
174+
175+func (_c *MockColorSchemeDetector_Name_Call) Return(s string) *MockColorSchemeDetector_Name_Call {
176+ _c.Call.Return(s)
177+ return _c
178+}
179+
180+func (_c *MockColorSchemeDetector_Name_Call) RunAndReturn(run func() string) *MockColorSchemeDetector_Name_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
184+
185+// Priority provides a mock function for the type MockColorSchemeDetector
186+func (_mock *MockColorSchemeDetector) Priority() int {
187+ ret := _mock.Called()
188+
189+ if len(ret) == 0 {
190+ panic("no return value specified for Priority")
191+ }
192+
193+ var r0 int
194+ if returnFunc, ok := ret.Get(0).(func() int); ok {
195+ r0 = returnFunc()
196+ } else {
197+ r0 = ret.Get(0).(int)
198+ }
199+ return r0
200+}
201+
202+// MockColorSchemeDetector_Priority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Priority'
203+type MockColorSchemeDetector_Priority_Call struct {
204+ *mock.Call
205+}
206+
207+// Priority is a helper method to define mock.On call
208+func (_e *MockColorSchemeDetector_Expecter) Priority() *MockColorSchemeDetector_Priority_Call {
209+ return &MockColorSchemeDetector_Priority_Call{Call: _e.mock.On("Priority")}
210+}
211+
212+func (_c *MockColorSchemeDetector_Priority_Call) Run(run func()) *MockColorSchemeDetector_Priority_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ run()
215+ })
216+ return _c
217+}
218+
219+func (_c *MockColorSchemeDetector_Priority_Call) Return(n int) *MockColorSchemeDetector_Priority_Call {
220+ _c.Call.Return(n)
221+ return _c
222+}
223+
224+func (_c *MockColorSchemeDetector_Priority_Call) RunAndReturn(run func() int) *MockColorSchemeDetector_Priority_Call {
225+ _c.Call.Return(run)
226+ return _c
227+}
228+
229+// NewMockColorSchemeResolver creates a new instance of MockColorSchemeResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
230+// The first argument is typically a *testing.T value.
231+func NewMockColorSchemeResolver(t interface {
232+ mock.TestingT
233+ Cleanup(func())
234+}) *MockColorSchemeResolver {
235+ mock := &MockColorSchemeResolver{}
236+ mock.Mock.Test(t)
237+
238+ t.Cleanup(func() { mock.AssertExpectations(t) })
239+
240+ return mock
241+}
242+
243+// MockColorSchemeResolver is an autogenerated mock type for the ColorSchemeResolver type
244+type MockColorSchemeResolver struct {
245+ mock.Mock
246+}
247+
248+type MockColorSchemeResolver_Expecter struct {
249+ mock *mock.Mock
250+}
251+
252+func (_m *MockColorSchemeResolver) EXPECT() *MockColorSchemeResolver_Expecter {
253+ return &MockColorSchemeResolver_Expecter{mock: &_m.Mock}
254+}
255+
256+// OnChange provides a mock function for the type MockColorSchemeResolver
257+func (_mock *MockColorSchemeResolver) OnChange(callback func(port.ColorSchemePreference)) func() {
258+ ret := _mock.Called(callback)
259+
260+ if len(ret) == 0 {
261+ panic("no return value specified for OnChange")
262+ }
263+
264+ var r0 func()
265+ if returnFunc, ok := ret.Get(0).(func(func(port.ColorSchemePreference)) func()); ok {
266+ r0 = returnFunc(callback)
267+ } else {
268+ if ret.Get(0) != nil {
269+ r0 = ret.Get(0).(func())
270+ }
271+ }
272+ return r0
273+}
274+
275+// MockColorSchemeResolver_OnChange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnChange'
276+type MockColorSchemeResolver_OnChange_Call struct {
277+ *mock.Call
278+}
279+
280+// OnChange is a helper method to define mock.On call
281+// - callback func(port.ColorSchemePreference)
282+func (_e *MockColorSchemeResolver_Expecter) OnChange(callback interface{}) *MockColorSchemeResolver_OnChange_Call {
283+ return &MockColorSchemeResolver_OnChange_Call{Call: _e.mock.On("OnChange", callback)}
284+}
285+
286+func (_c *MockColorSchemeResolver_OnChange_Call) Run(run func(callback func(port.ColorSchemePreference))) *MockColorSchemeResolver_OnChange_Call {
287+ _c.Call.Run(func(args mock.Arguments) {
288+ var arg0 func(port.ColorSchemePreference)
289+ if args[0] != nil {
290+ arg0 = args[0].(func(port.ColorSchemePreference))
291+ }
292+ run(
293+ arg0,
294+ )
295+ })
296+ return _c
297+}
298+
299+func (_c *MockColorSchemeResolver_OnChange_Call) Return(fn func()) *MockColorSchemeResolver_OnChange_Call {
300+ _c.Call.Return(fn)
301+ return _c
302+}
303+
304+func (_c *MockColorSchemeResolver_OnChange_Call) RunAndReturn(run func(callback func(port.ColorSchemePreference)) func()) *MockColorSchemeResolver_OnChange_Call {
305+ _c.Call.Return(run)
306+ return _c
307+}
308+
309+// Refresh provides a mock function for the type MockColorSchemeResolver
310+func (_mock *MockColorSchemeResolver) Refresh() port.ColorSchemePreference {
311+ ret := _mock.Called()
312+
313+ if len(ret) == 0 {
314+ panic("no return value specified for Refresh")
315+ }
316+
317+ var r0 port.ColorSchemePreference
318+ if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok {
319+ r0 = returnFunc()
320+ } else {
321+ r0 = ret.Get(0).(port.ColorSchemePreference)
322+ }
323+ return r0
324+}
325+
326+// MockColorSchemeResolver_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh'
327+type MockColorSchemeResolver_Refresh_Call struct {
328+ *mock.Call
329+}
330+
331+// Refresh is a helper method to define mock.On call
332+func (_e *MockColorSchemeResolver_Expecter) Refresh() *MockColorSchemeResolver_Refresh_Call {
333+ return &MockColorSchemeResolver_Refresh_Call{Call: _e.mock.On("Refresh")}
334+}
335+
336+func (_c *MockColorSchemeResolver_Refresh_Call) Run(run func()) *MockColorSchemeResolver_Refresh_Call {
337+ _c.Call.Run(func(args mock.Arguments) {
338+ run()
339+ })
340+ return _c
341+}
342+
343+func (_c *MockColorSchemeResolver_Refresh_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call {
344+ _c.Call.Return(colorSchemePreference)
345+ return _c
346+}
347+
348+func (_c *MockColorSchemeResolver_Refresh_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call {
349+ _c.Call.Return(run)
350+ return _c
351+}
352+
353+// RegisterDetector provides a mock function for the type MockColorSchemeResolver
354+func (_mock *MockColorSchemeResolver) RegisterDetector(detector port.ColorSchemeDetector) {
355+ _mock.Called(detector)
356+ return
357+}
358+
359+// MockColorSchemeResolver_RegisterDetector_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterDetector'
360+type MockColorSchemeResolver_RegisterDetector_Call struct {
361+ *mock.Call
362+}
363+
364+// RegisterDetector is a helper method to define mock.On call
365+// - detector port.ColorSchemeDetector
366+func (_e *MockColorSchemeResolver_Expecter) RegisterDetector(detector interface{}) *MockColorSchemeResolver_RegisterDetector_Call {
367+ return &MockColorSchemeResolver_RegisterDetector_Call{Call: _e.mock.On("RegisterDetector", detector)}
368+}
369+
370+func (_c *MockColorSchemeResolver_RegisterDetector_Call) Run(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call {
371+ _c.Call.Run(func(args mock.Arguments) {
372+ var arg0 port.ColorSchemeDetector
373+ if args[0] != nil {
374+ arg0 = args[0].(port.ColorSchemeDetector)
375+ }
376+ run(
377+ arg0,
378+ )
379+ })
380+ return _c
381+}
382+
383+func (_c *MockColorSchemeResolver_RegisterDetector_Call) Return() *MockColorSchemeResolver_RegisterDetector_Call {
384+ _c.Call.Return()
385+ return _c
386+}
387+
388+func (_c *MockColorSchemeResolver_RegisterDetector_Call) RunAndReturn(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call {
389+ _c.Run(run)
390+ return _c
391+}
392+
393+// Resolve provides a mock function for the type MockColorSchemeResolver
394+func (_mock *MockColorSchemeResolver) Resolve() port.ColorSchemePreference {
395+ ret := _mock.Called()
396+
397+ if len(ret) == 0 {
398+ panic("no return value specified for Resolve")
399+ }
400+
401+ var r0 port.ColorSchemePreference
402+ if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok {
403+ r0 = returnFunc()
404+ } else {
405+ r0 = ret.Get(0).(port.ColorSchemePreference)
406+ }
407+ return r0
408+}
409+
410+// MockColorSchemeResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
411+type MockColorSchemeResolver_Resolve_Call struct {
412+ *mock.Call
413+}
414+
415+// Resolve is a helper method to define mock.On call
416+func (_e *MockColorSchemeResolver_Expecter) Resolve() *MockColorSchemeResolver_Resolve_Call {
417+ return &MockColorSchemeResolver_Resolve_Call{Call: _e.mock.On("Resolve")}
418+}
419+
420+func (_c *MockColorSchemeResolver_Resolve_Call) Run(run func()) *MockColorSchemeResolver_Resolve_Call {
421+ _c.Call.Run(func(args mock.Arguments) {
422+ run()
423+ })
424+ return _c
425+}
426+
427+func (_c *MockColorSchemeResolver_Resolve_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call {
428+ _c.Call.Return(colorSchemePreference)
429+ return _c
430+}
431+
432+func (_c *MockColorSchemeResolver_Resolve_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call {
433+ _c.Call.Return(run)
434+ return _c
435+}
436+
17437 // NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18438 // The first argument is typically a *testing.T value.
19439 func NewMockConfigMigrator(t interface {
@@ -14,6 +14,426 @@ import (
14 mock "github.com/stretchr/testify/mock"14 mock "github.com/stretchr/testify/mock"
15 )15 )
16 16
17+// NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18+// The first argument is typically a *testing.T value.
19+func NewMockColorSchemeDetector(t interface {
20+ mock.TestingT
21+ Cleanup(func())
22+}) *MockColorSchemeDetector {
23+ mock := &MockColorSchemeDetector{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() { mock.AssertExpectations(t) })
27+
28+ return mock
29+}
30+
31+// MockColorSchemeDetector is an autogenerated mock type for the ColorSchemeDetector type
32+type MockColorSchemeDetector struct {
33+ mock.Mock
34+}
35+
36+type MockColorSchemeDetector_Expecter struct {
37+ mock *mock.Mock
38+}
39+
40+func (_m *MockColorSchemeDetector) EXPECT() *MockColorSchemeDetector_Expecter {
41+ return &MockColorSchemeDetector_Expecter{mock: &_m.Mock}
42+}
43+
44+// Available provides a mock function for the type MockColorSchemeDetector
45+func (_mock *MockColorSchemeDetector) Available() bool {
46+ ret := _mock.Called()
47+
48+ if len(ret) == 0 {
49+ panic("no return value specified for Available")
50+ }
51+
52+ var r0 bool
53+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
54+ r0 = returnFunc()
55+ } else {
56+ r0 = ret.Get(0).(bool)
57+ }
58+ return r0
59+}
60+
61+// MockColorSchemeDetector_Available_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Available'
62+type MockColorSchemeDetector_Available_Call struct {
63+ *mock.Call
64+}
65+
66+// Available is a helper method to define mock.On call
67+func (_e *MockColorSchemeDetector_Expecter) Available() *MockColorSchemeDetector_Available_Call {
68+ return &MockColorSchemeDetector_Available_Call{Call: _e.mock.On("Available")}
69+}
70+
71+func (_c *MockColorSchemeDetector_Available_Call) Run(run func()) *MockColorSchemeDetector_Available_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ run()
74+ })
75+ return _c
76+}
77+
78+func (_c *MockColorSchemeDetector_Available_Call) Return(b bool) *MockColorSchemeDetector_Available_Call {
79+ _c.Call.Return(b)
80+ return _c
81+}
82+
83+func (_c *MockColorSchemeDetector_Available_Call) RunAndReturn(run func() bool) *MockColorSchemeDetector_Available_Call {
84+ _c.Call.Return(run)
85+ return _c
86+}
87+
88+// Detect provides a mock function for the type MockColorSchemeDetector
89+func (_mock *MockColorSchemeDetector) Detect() (bool, bool) {
90+ ret := _mock.Called()
91+
92+ if len(ret) == 0 {
93+ panic("no return value specified for Detect")
94+ }
95+
96+ var r0 bool
97+ var r1 bool
98+ if returnFunc, ok := ret.Get(0).(func() (bool, bool)); ok {
99+ return returnFunc()
100+ }
101+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
102+ r0 = returnFunc()
103+ } else {
104+ r0 = ret.Get(0).(bool)
105+ }
106+ if returnFunc, ok := ret.Get(1).(func() bool); ok {
107+ r1 = returnFunc()
108+ } else {
109+ r1 = ret.Get(1).(bool)
110+ }
111+ return r0, r1
112+}
113+
114+// MockColorSchemeDetector_Detect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detect'
115+type MockColorSchemeDetector_Detect_Call struct {
116+ *mock.Call
117+}
118+
119+// Detect is a helper method to define mock.On call
120+func (_e *MockColorSchemeDetector_Expecter) Detect() *MockColorSchemeDetector_Detect_Call {
121+ return &MockColorSchemeDetector_Detect_Call{Call: _e.mock.On("Detect")}
122+}
123+
124+func (_c *MockColorSchemeDetector_Detect_Call) Run(run func()) *MockColorSchemeDetector_Detect_Call {
125+ _c.Call.Run(func(args mock.Arguments) {
126+ run()
127+ })
128+ return _c
129+}
130+
131+func (_c *MockColorSchemeDetector_Detect_Call) Return(prefersDark bool, ok bool) *MockColorSchemeDetector_Detect_Call {
132+ _c.Call.Return(prefersDark, ok)
133+ return _c
134+}
135+
136+func (_c *MockColorSchemeDetector_Detect_Call) RunAndReturn(run func() (bool, bool)) *MockColorSchemeDetector_Detect_Call {
137+ _c.Call.Return(run)
138+ return _c
139+}
140+
141+// Name provides a mock function for the type MockColorSchemeDetector
142+func (_mock *MockColorSchemeDetector) Name() string {
143+ ret := _mock.Called()
144+
145+ if len(ret) == 0 {
146+ panic("no return value specified for Name")
147+ }
148+
149+ var r0 string
150+ if returnFunc, ok := ret.Get(0).(func() string); ok {
151+ r0 = returnFunc()
152+ } else {
153+ r0 = ret.Get(0).(string)
154+ }
155+ return r0
156+}
157+
158+// MockColorSchemeDetector_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'
159+type MockColorSchemeDetector_Name_Call struct {
160+ *mock.Call
161+}
162+
163+// Name is a helper method to define mock.On call
164+func (_e *MockColorSchemeDetector_Expecter) Name() *MockColorSchemeDetector_Name_Call {
165+ return &MockColorSchemeDetector_Name_Call{Call: _e.mock.On("Name")}
166+}
167+
168+func (_c *MockColorSchemeDetector_Name_Call) Run(run func()) *MockColorSchemeDetector_Name_Call {
169+ _c.Call.Run(func(args mock.Arguments) {
170+ run()
171+ })
172+ return _c
173+}
174+
175+func (_c *MockColorSchemeDetector_Name_Call) Return(s string) *MockColorSchemeDetector_Name_Call {
176+ _c.Call.Return(s)
177+ return _c
178+}
179+
180+func (_c *MockColorSchemeDetector_Name_Call) RunAndReturn(run func() string) *MockColorSchemeDetector_Name_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
184+
185+// Priority provides a mock function for the type MockColorSchemeDetector
186+func (_mock *MockColorSchemeDetector) Priority() int {
187+ ret := _mock.Called()
188+
189+ if len(ret) == 0 {
190+ panic("no return value specified for Priority")
191+ }
192+
193+ var r0 int
194+ if returnFunc, ok := ret.Get(0).(func() int); ok {
195+ r0 = returnFunc()
196+ } else {
197+ r0 = ret.Get(0).(int)
198+ }
199+ return r0
200+}
201+
202+// MockColorSchemeDetector_Priority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Priority'
203+type MockColorSchemeDetector_Priority_Call struct {
204+ *mock.Call
205+}
206+
207+// Priority is a helper method to define mock.On call
208+func (_e *MockColorSchemeDetector_Expecter) Priority() *MockColorSchemeDetector_Priority_Call {
209+ return &MockColorSchemeDetector_Priority_Call{Call: _e.mock.On("Priority")}
210+}
211+
212+func (_c *MockColorSchemeDetector_Priority_Call) Run(run func()) *MockColorSchemeDetector_Priority_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ run()
215+ })
216+ return _c
217+}
218+
219+func (_c *MockColorSchemeDetector_Priority_Call) Return(n int) *MockColorSchemeDetector_Priority_Call {
220+ _c.Call.Return(n)
221+ return _c
222+}
223+
224+func (_c *MockColorSchemeDetector_Priority_Call) RunAndReturn(run func() int) *MockColorSchemeDetector_Priority_Call {
225+ _c.Call.Return(run)
226+ return _c
227+}
228+
229+// NewMockColorSchemeResolver creates a new instance of MockColorSchemeResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
230+// The first argument is typically a *testing.T value.
231+func NewMockColorSchemeResolver(t interface {
232+ mock.TestingT
233+ Cleanup(func())
234+}) *MockColorSchemeResolver {
235+ mock := &MockColorSchemeResolver{}
236+ mock.Mock.Test(t)
237+
238+ t.Cleanup(func() { mock.AssertExpectations(t) })
239+
240+ return mock
241+}
242+
243+// MockColorSchemeResolver is an autogenerated mock type for the ColorSchemeResolver type
244+type MockColorSchemeResolver struct {
245+ mock.Mock
246+}
247+
248+type MockColorSchemeResolver_Expecter struct {
249+ mock *mock.Mock
250+}
251+
252+func (_m *MockColorSchemeResolver) EXPECT() *MockColorSchemeResolver_Expecter {
253+ return &MockColorSchemeResolver_Expecter{mock: &_m.Mock}
254+}
255+
256+// OnChange provides a mock function for the type MockColorSchemeResolver
257+func (_mock *MockColorSchemeResolver) OnChange(callback func(port.ColorSchemePreference)) func() {
258+ ret := _mock.Called(callback)
259+
260+ if len(ret) == 0 {
261+ panic("no return value specified for OnChange")
262+ }
263+
264+ var r0 func()
265+ if returnFunc, ok := ret.Get(0).(func(func(port.ColorSchemePreference)) func()); ok {
266+ r0 = returnFunc(callback)
267+ } else {
268+ if ret.Get(0) != nil {
269+ r0 = ret.Get(0).(func())
270+ }
271+ }
272+ return r0
273+}
274+
275+// MockColorSchemeResolver_OnChange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnChange'
276+type MockColorSchemeResolver_OnChange_Call struct {
277+ *mock.Call
278+}
279+
280+// OnChange is a helper method to define mock.On call
281+// - callback func(port.ColorSchemePreference)
282+func (_e *MockColorSchemeResolver_Expecter) OnChange(callback interface{}) *MockColorSchemeResolver_OnChange_Call {
283+ return &MockColorSchemeResolver_OnChange_Call{Call: _e.mock.On("OnChange", callback)}
284+}
285+
286+func (_c *MockColorSchemeResolver_OnChange_Call) Run(run func(callback func(port.ColorSchemePreference))) *MockColorSchemeResolver_OnChange_Call {
287+ _c.Call.Run(func(args mock.Arguments) {
288+ var arg0 func(port.ColorSchemePreference)
289+ if args[0] != nil {
290+ arg0 = args[0].(func(port.ColorSchemePreference))
291+ }
292+ run(
293+ arg0,
294+ )
295+ })
296+ return _c
297+}
298+
299+func (_c *MockColorSchemeResolver_OnChange_Call) Return(fn func()) *MockColorSchemeResolver_OnChange_Call {
300+ _c.Call.Return(fn)
301+ return _c
302+}
303+
304+func (_c *MockColorSchemeResolver_OnChange_Call) RunAndReturn(run func(callback func(port.ColorSchemePreference)) func()) *MockColorSchemeResolver_OnChange_Call {
305+ _c.Call.Return(run)
306+ return _c
307+}
308+
309+// Refresh provides a mock function for the type MockColorSchemeResolver
310+func (_mock *MockColorSchemeResolver) Refresh() port.ColorSchemePreference {
311+ ret := _mock.Called()
312+
313+ if len(ret) == 0 {
314+ panic("no return value specified for Refresh")
315+ }
316+
317+ var r0 port.ColorSchemePreference
318+ if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok {
319+ r0 = returnFunc()
320+ } else {
321+ r0 = ret.Get(0).(port.ColorSchemePreference)
322+ }
323+ return r0
324+}
325+
326+// MockColorSchemeResolver_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh'
327+type MockColorSchemeResolver_Refresh_Call struct {
328+ *mock.Call
329+}
330+
331+// Refresh is a helper method to define mock.On call
332+func (_e *MockColorSchemeResolver_Expecter) Refresh() *MockColorSchemeResolver_Refresh_Call {
333+ return &MockColorSchemeResolver_Refresh_Call{Call: _e.mock.On("Refresh")}
334+}
335+
336+func (_c *MockColorSchemeResolver_Refresh_Call) Run(run func()) *MockColorSchemeResolver_Refresh_Call {
337+ _c.Call.Run(func(args mock.Arguments) {
338+ run()
339+ })
340+ return _c
341+}
342+
343+func (_c *MockColorSchemeResolver_Refresh_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call {
344+ _c.Call.Return(colorSchemePreference)
345+ return _c
346+}
347+
348+func (_c *MockColorSchemeResolver_Refresh_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call {
349+ _c.Call.Return(run)
350+ return _c
351+}
352+
353+// RegisterDetector provides a mock function for the type MockColorSchemeResolver
354+func (_mock *MockColorSchemeResolver) RegisterDetector(detector port.ColorSchemeDetector) {
355+ _mock.Called(detector)
356+ return
357+}
358+
359+// MockColorSchemeResolver_RegisterDetector_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterDetector'
360+type MockColorSchemeResolver_RegisterDetector_Call struct {
361+ *mock.Call
362+}
363+
364+// RegisterDetector is a helper method to define mock.On call
365+// - detector port.ColorSchemeDetector
366+func (_e *MockColorSchemeResolver_Expecter) RegisterDetector(detector interface{}) *MockColorSchemeResolver_RegisterDetector_Call {
367+ return &MockColorSchemeResolver_RegisterDetector_Call{Call: _e.mock.On("RegisterDetector", detector)}
368+}
369+
370+func (_c *MockColorSchemeResolver_RegisterDetector_Call) Run(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call {
371+ _c.Call.Run(func(args mock.Arguments) {
372+ var arg0 port.ColorSchemeDetector
373+ if args[0] != nil {
374+ arg0 = args[0].(port.ColorSchemeDetector)
375+ }
376+ run(
377+ arg0,
378+ )
379+ })
380+ return _c
381+}
382+
383+func (_c *MockColorSchemeResolver_RegisterDetector_Call) Return() *MockColorSchemeResolver_RegisterDetector_Call {
384+ _c.Call.Return()
385+ return _c
386+}
387+
388+func (_c *MockColorSchemeResolver_RegisterDetector_Call) RunAndReturn(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call {
389+ _c.Run(run)
390+ return _c
391+}
392+
393+// Resolve provides a mock function for the type MockColorSchemeResolver
394+func (_mock *MockColorSchemeResolver) Resolve() port.ColorSchemePreference {
395+ ret := _mock.Called()
396+
397+ if len(ret) == 0 {
398+ panic("no return value specified for Resolve")
399+ }
400+
401+ var r0 port.ColorSchemePreference
402+ if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok {
403+ r0 = returnFunc()
404+ } else {
405+ r0 = ret.Get(0).(port.ColorSchemePreference)
406+ }
407+ return r0
408+}
409+
410+// MockColorSchemeResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
411+type MockColorSchemeResolver_Resolve_Call struct {
412+ *mock.Call
413+}
414+
415+// Resolve is a helper method to define mock.On call
416+func (_e *MockColorSchemeResolver_Expecter) Resolve() *MockColorSchemeResolver_Resolve_Call {
417+ return &MockColorSchemeResolver_Resolve_Call{Call: _e.mock.On("Resolve")}
418+}
419+
420+func (_c *MockColorSchemeResolver_Resolve_Call) Run(run func()) *MockColorSchemeResolver_Resolve_Call {
421+ _c.Call.Run(func(args mock.Arguments) {
422+ run()
423+ })
424+ return _c
425+}
426+
427+func (_c *MockColorSchemeResolver_Resolve_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call {
428+ _c.Call.Return(colorSchemePreference)
429+ return _c
430+}
431+
432+func (_c *MockColorSchemeResolver_Resolve_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call {
433+ _c.Call.Return(run)
434+ return _c
435+}
436+
17 // NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.437 // NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18 // The first argument is typically a *testing.T value.438 // The first argument is typically a *testing.T value.
19 func NewMockConfigMigrator(t interface {439 func NewMockConfigMigrator(t interface {
M internal/bootstrap/gui.go +46 -17
@@ -10,7 +10,9 @@ import (
1010 "sync"
1111 "time"
1212
13+ "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1416 "github.com/bnema/dumber/internal/infrastructure/config"
1517 "github.com/bnema/dumber/internal/infrastructure/deps"
1618 "github.com/bnema/dumber/internal/infrastructure/media"
@@ -28,10 +30,12 @@ type DatabaseResult struct {
2830
2931 // ParallelInitResult holds the results of parallel initialization phase.
3032 type ParallelInitResult struct {
31- DataDir string
32- CacheDir string
33- ThemeManager *theme.Manager
34- Duration time.Duration
33+ DataDir string
34+ CacheDir string
35+ ThemeManager *theme.Manager
36+ ColorResolver port.ColorSchemeResolver
37+ AdwaitaDetector *colorscheme.AdwaitaDetector
38+ Duration time.Duration
3539 }
3640
3741 // DeferredInitResult holds results from deferred initialization checks.
@@ -84,17 +88,30 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
8488 }
8589
8690 // RunParallelInit runs the essential parallel initialization phase.
87-// This includes directory resolution and theme creation.
91+// This includes directory resolution, color scheme resolver creation, and theme creation.
8892 // Returns the first fatal error encountered, or nil with the results.
8993 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
9094 var (
9195 dataDir, cacheDir string
92- themeManager *theme.Manager
9396 dirsErr error
9497 wg sync.WaitGroup
9598 )
9699
97100 start := time.Now()
101+
102+ // Create color scheme resolver with initial detectors.
103+ // AdwaitaDetector is created but NOT marked available yet (requires adw.Init()).
104+ configAdapter := colorscheme.NewConfigAdapter(input.Config)
105+ resolver := colorscheme.NewResolver(configAdapter)
106+
107+ // Register detectors that are available before GTK init
108+ resolver.RegisterDetector(colorscheme.NewEnvDetector())
109+ resolver.RegisterDetector(colorscheme.NewGsettingsDetector())
110+
111+ // Create adwaita detector (will be marked available after adw.Init())
112+ adwaitaDetector := colorscheme.NewAdwaitaDetector()
113+ resolver.RegisterDetector(adwaitaDetector)
114+
98115 wg.Add(2)
99116
100117 // Resolve directories
@@ -104,9 +121,10 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
104121 }()
105122
106123 // Theme manager (CPU-bound, no I/O)
124+ var themeManager *theme.Manager
107125 go func() {
108126 defer wg.Done()
109- themeManager = theme.NewManager(input.Ctx, input.Config)
127+ themeManager = theme.NewManager(input.Ctx, input.Config, resolver)
110128 }()
111129
112130 wg.Wait()
@@ -118,10 +136,12 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
118136 }
119137
120138 return &ParallelInitResult{
121- DataDir: dataDir,
122- CacheDir: cacheDir,
123- ThemeManager: themeManager,
124- Duration: duration,
139+ DataDir: dataDir,
140+ CacheDir: cacheDir,
141+ ThemeManager: themeManager,
142+ ColorResolver: resolver,
143+ AdwaitaDetector: adwaitaDetector,
144+ Duration: duration,
125145 }, nil
126146 }
127147
@@ -253,11 +273,12 @@ type ParallelDBWebKitResult struct {
253273
254274 // ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
255275 type ParallelDBWebKitInput struct {
256- Ctx context.Context
257- Config *config.Config
258- DataDir string // For WebKit context
259- CacheDir string // For WebKit cache
260- ThemeManager *theme.Manager
276+ Ctx context.Context
277+ Config *config.Config
278+ DataDir string // For WebKit context
279+ CacheDir string // For WebKit cache
280+ ThemeManager *theme.Manager
281+ ColorResolver port.ColorSchemeResolver
261282 }
262283
263284 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -291,7 +312,15 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult,
291312 }()
292313
293314 // WebKit stack on main thread (GTK requirement)
294- stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
315+ stack := BuildWebKitStack(WebKitStackInput{
316+ Ctx: input.Ctx,
317+ Config: input.Config,
318+ DataDir: input.DataDir,
319+ CacheDir: input.CacheDir,
320+ ThemeManager: input.ThemeManager,
321+ ColorResolver: input.ColorResolver,
322+ Logger: *log,
323+ })
295324
296325 // Wait for database
297326 dbRes := <-dbCh
@@ -10,7 +10,9 @@ import (
10 "sync"10 "sync"
11 "time"11 "time"
12 12
13+ "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
14 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/deps"17 "github.com/bnema/dumber/internal/infrastructure/deps"
16 "github.com/bnema/dumber/internal/infrastructure/media"18 "github.com/bnema/dumber/internal/infrastructure/media"
@@ -28,10 +30,12 @@ type DatabaseResult struct {
28 30
29 // ParallelInitResult holds the results of parallel initialization phase.31 // ParallelInitResult holds the results of parallel initialization phase.
30 type ParallelInitResult struct {32 type ParallelInitResult struct {
31- DataDir string33+ DataDir string
32- CacheDir string34+ CacheDir string
33- ThemeManager *theme.Manager35+ ThemeManager *theme.Manager
34- Duration time.Duration36+ ColorResolver port.ColorSchemeResolver
37+ AdwaitaDetector *colorscheme.AdwaitaDetector
38+ Duration time.Duration
35 }39 }
36 40
37 // DeferredInitResult holds results from deferred initialization checks.41 // DeferredInitResult holds results from deferred initialization checks.
@@ -84,17 +88,30 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
84 }88 }
85 89
86 // RunParallelInit runs the essential parallel initialization phase.90 // RunParallelInit runs the essential parallel initialization phase.
87-// This includes directory resolution and theme creation.91+// This includes directory resolution, color scheme resolver creation, and theme creation.
88 // Returns the first fatal error encountered, or nil with the results.92 // Returns the first fatal error encountered, or nil with the results.
89 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {93 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
90 var (94 var (
91 dataDir, cacheDir string95 dataDir, cacheDir string
92- themeManager *theme.Manager
93 dirsErr error96 dirsErr error
94 wg sync.WaitGroup97 wg sync.WaitGroup
95 )98 )
96 99
97 start := time.Now()100 start := time.Now()
101+
102+ // Create color scheme resolver with initial detectors.
103+ // AdwaitaDetector is created but NOT marked available yet (requires adw.Init()).
104+ configAdapter := colorscheme.NewConfigAdapter(input.Config)
105+ resolver := colorscheme.NewResolver(configAdapter)
106+
107+ // Register detectors that are available before GTK init
108+ resolver.RegisterDetector(colorscheme.NewEnvDetector())
109+ resolver.RegisterDetector(colorscheme.NewGsettingsDetector())
110+
111+ // Create adwaita detector (will be marked available after adw.Init())
112+ adwaitaDetector := colorscheme.NewAdwaitaDetector()
113+ resolver.RegisterDetector(adwaitaDetector)
114+
98 wg.Add(2)115 wg.Add(2)
99 116
100 // Resolve directories117 // Resolve directories
@@ -104,9 +121,10 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
104 }()121 }()
105 122
106 // Theme manager (CPU-bound, no I/O)123 // Theme manager (CPU-bound, no I/O)
124+ var themeManager *theme.Manager
107 go func() {125 go func() {
108 defer wg.Done()126 defer wg.Done()
109- themeManager = theme.NewManager(input.Ctx, input.Config)127+ themeManager = theme.NewManager(input.Ctx, input.Config, resolver)
110 }()128 }()
111 129
112 wg.Wait()130 wg.Wait()
@@ -118,10 +136,12 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
118 }136 }
119 137
120 return &ParallelInitResult{138 return &ParallelInitResult{
121- DataDir: dataDir,139+ DataDir: dataDir,
122- CacheDir: cacheDir,140+ CacheDir: cacheDir,
123- ThemeManager: themeManager,141+ ThemeManager: themeManager,
124- Duration: duration,142+ ColorResolver: resolver,
143+ AdwaitaDetector: adwaitaDetector,
144+ Duration: duration,
125 }, nil145 }, nil
126 }146 }
127 147
@@ -253,11 +273,12 @@ type ParallelDBWebKitResult struct {
253 273
254 // ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.274 // ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
255 type ParallelDBWebKitInput struct {275 type ParallelDBWebKitInput struct {
256- Ctx context.Context276+ Ctx context.Context
257- Config *config.Config277+ Config *config.Config
258- DataDir string // For WebKit context278+ DataDir string // For WebKit context
259- CacheDir string // For WebKit cache279+ CacheDir string // For WebKit cache
260- ThemeManager *theme.Manager280+ ThemeManager *theme.Manager
281+ ColorResolver port.ColorSchemeResolver
261 }282 }
262 283
263 // Note: Database path is resolved via config.GetDatabaseFile() internally.284 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -291,7 +312,15 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult,
291 }()312 }()
292 313
293 // WebKit stack on main thread (GTK requirement)314 // WebKit stack on main thread (GTK requirement)
294- stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)315+ stack := BuildWebKitStack(WebKitStackInput{
316+ Ctx: input.Ctx,
317+ Config: input.Config,
318+ DataDir: input.DataDir,
319+ CacheDir: input.CacheDir,
320+ ThemeManager: input.ThemeManager,
321+ ColorResolver: input.ColorResolver,
322+ Logger: *log,
323+ })
295 324
296 // Wait for database325 // Wait for database
297 dbRes := <-dbCh326 dbRes := <-dbCh
M internal/bootstrap/webkit_stack.go +69 -48
@@ -15,6 +15,17 @@ import (
1515 "github.com/rs/zerolog"
1616 )
1717
18+// WebKitStackInput holds the input for BuildWebKitStack.
19+type WebKitStackInput struct {
20+ Ctx context.Context
21+ Config *config.Config
22+ DataDir string
23+ CacheDir string
24+ ThemeManager *theme.Manager
25+ ColorResolver port.ColorSchemeResolver
26+ Logger zerolog.Logger
27+}
28+
1829 type WebKitStack struct {
1930 Context *webkit.WebKitContext
2031 Settings *webkit.SettingsManager
@@ -24,14 +35,14 @@ type WebKitStack struct {
2435 FilterManager *filtering.Manager
2536 }
2637
27-func BuildWebKitStack(
28- ctx context.Context,
29- cfg *config.Config,
30- dataDir string,
31- cacheDir string,
32- themeManager *theme.Manager,
33- logger zerolog.Logger,
34-) WebKitStack {
38+func BuildWebKitStack(input WebKitStackInput) WebKitStack {
39+ ctx := input.Ctx
40+ cfg := input.Config
41+ dataDir := input.DataDir
42+ cacheDir := input.CacheDir
43+ themeManager := input.ThemeManager
44+ colorResolver := input.ColorResolver
45+ logger := input.Logger
3546 // Detect hardware for performance profile scaling
3647 hwSurveyor := env.NewHardwareSurveyor()
3748 hwInfo := hwSurveyor.Survey(ctx)
@@ -52,45 +63,8 @@ func BuildWebKitStack(
5263 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
5364 Msg("resolved performance profile")
5465
55- // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization.
56- // Environment variables must be set before GTK/WebKit/GStreamer initializes.
57- renderEnv := env.NewManager()
58- gpuVendor := renderEnv.DetectGPUVendor(ctx)
59- renderSettings := port.RenderingEnvSettings{
60- // GStreamer settings
61- ForceVSync: cfg.Media.ForceVSync,
62- GLRenderingMode: string(cfg.Media.GLRenderingMode),
63- GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
64- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
65- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
66-
67- // WebKit compositor settings
68- DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
69- ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
70- DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
71-
72- // GTK/GSK settings
73- GSKRenderer: string(cfg.Rendering.GSKRenderer),
74- DisableMipmaps: cfg.Rendering.DisableMipmaps,
75- PreferGL: cfg.Rendering.PreferGL,
76-
77- // Debug settings
78- ShowFPS: cfg.Rendering.ShowFPS,
79- SampleMemory: cfg.Rendering.SampleMemory,
80- DebugFrames: cfg.Rendering.DebugFrames,
81-
82- // Skia rendering thread settings (from resolved profile)
83- SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
84- SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
85- SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
86- }
87- if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
88- logger.Warn().Err(err).Msg("failed to apply rendering environment")
89- }
90- logger.Info().
91- Str("gpu", string(gpuVendor)).
92- Interface("vars", renderEnv.GetAppliedVars()).
93- Msg("rendering environment configured")
66+ // Configure rendering environment (must happen before WebKit/GTK init)
67+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
9468
9569 // Build WebKitContext options with memory pressure settings
9670 wkOpts := port.WebKitContextOptions{
@@ -146,7 +120,7 @@ func BuildWebKitStack(
146120 schemeHandler.RegisterWithContext(wkCtx)
147121
148122 settings := webkit.NewSettingsManager(ctx, cfg)
149- injector := webkit.NewContentInjector(themeManager.PrefersDark())
123+ injector := webkit.NewContentInjector(colorResolver)
150124
151125 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
152126 themeCSSText := themeManager.GetWebUIThemeCSS()
@@ -203,3 +177,50 @@ func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
203177 p.NetworkProcessMemoryStrictThreshold > 0 ||
204178 p.NetworkProcessMemoryKillThreshold >= 0
205179 }
180+
181+// configureRenderingEnvironment sets up the rendering environment before WebKit/GTK initialization.
182+// Environment variables must be set before GTK/WebKit/GStreamer initializes.
183+func configureRenderingEnvironment(
184+ ctx context.Context,
185+ cfg *config.Config,
186+ perfSettings *config.ResolvedPerformanceSettings,
187+ logger zerolog.Logger,
188+) {
189+ renderEnv := env.NewManager()
190+ gpuVendor := renderEnv.DetectGPUVendor(ctx)
191+ renderSettings := port.RenderingEnvSettings{
192+ // GStreamer settings
193+ ForceVSync: cfg.Media.ForceVSync,
194+ GLRenderingMode: string(cfg.Media.GLRenderingMode),
195+ GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
196+ VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
197+ QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
198+
199+ // WebKit compositor settings
200+ DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
201+ ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
202+ DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
203+
204+ // GTK/GSK settings
205+ GSKRenderer: string(cfg.Rendering.GSKRenderer),
206+ DisableMipmaps: cfg.Rendering.DisableMipmaps,
207+ PreferGL: cfg.Rendering.PreferGL,
208+
209+ // Debug settings
210+ ShowFPS: cfg.Rendering.ShowFPS,
211+ SampleMemory: cfg.Rendering.SampleMemory,
212+ DebugFrames: cfg.Rendering.DebugFrames,
213+
214+ // Skia rendering thread settings (from resolved profile)
215+ SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
216+ SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
217+ SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
218+ }
219+ if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
220+ logger.Warn().Err(err).Msg("failed to apply rendering environment")
221+ }
222+ logger.Info().
223+ Str("gpu", string(gpuVendor)).
224+ Interface("vars", renderEnv.GetAppliedVars()).
225+ Msg("rendering environment configured")
226+}
@@ -15,6 +15,17 @@ import (
15 "github.com/rs/zerolog"15 "github.com/rs/zerolog"
16 )16 )
17 17
18+// WebKitStackInput holds the input for BuildWebKitStack.
19+type WebKitStackInput struct {
20+ Ctx context.Context
21+ Config *config.Config
22+ DataDir string
23+ CacheDir string
24+ ThemeManager *theme.Manager
25+ ColorResolver port.ColorSchemeResolver
26+ Logger zerolog.Logger
27+}
28+
18 type WebKitStack struct {29 type WebKitStack struct {
19 Context *webkit.WebKitContext30 Context *webkit.WebKitContext
20 Settings *webkit.SettingsManager31 Settings *webkit.SettingsManager
@@ -24,14 +35,14 @@ type WebKitStack struct {
24 FilterManager *filtering.Manager35 FilterManager *filtering.Manager
25 }36 }
26 37
27-func BuildWebKitStack(38+func BuildWebKitStack(input WebKitStackInput) WebKitStack {
28- ctx context.Context,39+ ctx := input.Ctx
29- cfg *config.Config,40+ cfg := input.Config
30- dataDir string,41+ dataDir := input.DataDir
31- cacheDir string,42+ cacheDir := input.CacheDir
32- themeManager *theme.Manager,43+ themeManager := input.ThemeManager
33- logger zerolog.Logger,44+ colorResolver := input.ColorResolver
34-) WebKitStack {45+ logger := input.Logger
35 // Detect hardware for performance profile scaling46 // Detect hardware for performance profile scaling
36 hwSurveyor := env.NewHardwareSurveyor()47 hwSurveyor := env.NewHardwareSurveyor()
37 hwInfo := hwSurveyor.Survey(ctx)48 hwInfo := hwSurveyor.Survey(ctx)
@@ -52,45 +63,8 @@ func BuildWebKitStack(
52 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).63 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
53 Msg("resolved performance profile")64 Msg("resolved performance profile")
54 65
55- // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization.66+ // Configure rendering environment (must happen before WebKit/GTK init)
56- // Environment variables must be set before GTK/WebKit/GStreamer initializes.67+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
57- renderEnv := env.NewManager()
58- gpuVendor := renderEnv.DetectGPUVendor(ctx)
59- renderSettings := port.RenderingEnvSettings{
60- // GStreamer settings
61- ForceVSync: cfg.Media.ForceVSync,
62- GLRenderingMode: string(cfg.Media.GLRenderingMode),
63- GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
64- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
65- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
66-
67- // WebKit compositor settings
68- DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
69- ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
70- DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
71-
72- // GTK/GSK settings
73- GSKRenderer: string(cfg.Rendering.GSKRenderer),
74- DisableMipmaps: cfg.Rendering.DisableMipmaps,
75- PreferGL: cfg.Rendering.PreferGL,
76-
77- // Debug settings
78- ShowFPS: cfg.Rendering.ShowFPS,
79- SampleMemory: cfg.Rendering.SampleMemory,
80- DebugFrames: cfg.Rendering.DebugFrames,
81-
82- // Skia rendering thread settings (from resolved profile)
83- SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
84- SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
85- SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
86- }
87- if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
88- logger.Warn().Err(err).Msg("failed to apply rendering environment")
89- }
90- logger.Info().
91- Str("gpu", string(gpuVendor)).
92- Interface("vars", renderEnv.GetAppliedVars()).
93- Msg("rendering environment configured")
94 68
95 // Build WebKitContext options with memory pressure settings69 // Build WebKitContext options with memory pressure settings
96 wkOpts := port.WebKitContextOptions{70 wkOpts := port.WebKitContextOptions{
@@ -146,7 +120,7 @@ func BuildWebKitStack(
146 schemeHandler.RegisterWithContext(wkCtx)120 schemeHandler.RegisterWithContext(wkCtx)
147 121
148 settings := webkit.NewSettingsManager(ctx, cfg)122 settings := webkit.NewSettingsManager(ctx, cfg)
149- injector := webkit.NewContentInjector(themeManager.PrefersDark())123+ injector := webkit.NewContentInjector(colorResolver)
150 124
151 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)125 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
152 themeCSSText := themeManager.GetWebUIThemeCSS()126 themeCSSText := themeManager.GetWebUIThemeCSS()
@@ -203,3 +177,50 @@ func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
203 p.NetworkProcessMemoryStrictThreshold > 0 ||177 p.NetworkProcessMemoryStrictThreshold > 0 ||
204 p.NetworkProcessMemoryKillThreshold >= 0178 p.NetworkProcessMemoryKillThreshold >= 0
205 }179 }
180+
181+// configureRenderingEnvironment sets up the rendering environment before WebKit/GTK initialization.
182+// Environment variables must be set before GTK/WebKit/GStreamer initializes.
183+func configureRenderingEnvironment(
184+ ctx context.Context,
185+ cfg *config.Config,
186+ perfSettings *config.ResolvedPerformanceSettings,
187+ logger zerolog.Logger,
188+) {
189+ renderEnv := env.NewManager()
190+ gpuVendor := renderEnv.DetectGPUVendor(ctx)
191+ renderSettings := port.RenderingEnvSettings{
192+ // GStreamer settings
193+ ForceVSync: cfg.Media.ForceVSync,
194+ GLRenderingMode: string(cfg.Media.GLRenderingMode),
195+ GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
196+ VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
197+ QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
198+
199+ // WebKit compositor settings
200+ DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
201+ ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
202+ DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
203+
204+ // GTK/GSK settings
205+ GSKRenderer: string(cfg.Rendering.GSKRenderer),
206+ DisableMipmaps: cfg.Rendering.DisableMipmaps,
207+ PreferGL: cfg.Rendering.PreferGL,
208+
209+ // Debug settings
210+ ShowFPS: cfg.Rendering.ShowFPS,
211+ SampleMemory: cfg.Rendering.SampleMemory,
212+ DebugFrames: cfg.Rendering.DebugFrames,
213+
214+ // Skia rendering thread settings (from resolved profile)
215+ SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
216+ SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
217+ SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
218+ }
219+ if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
220+ logger.Warn().Err(err).Msg("failed to apply rendering environment")
221+ }
222+ logger.Info().
223+ Str("gpu", string(gpuVendor)).
224+ Interface("vars", renderEnv.GetAppliedVars()).
225+ Msg("rendering environment configured")
226+}
A internal/infrastructure/colorscheme/config_adapter.go +23 -0
@@ -0,0 +1,23 @@
1+package colorscheme
2+
3+import (
4+ "github.com/bnema/dumber/internal/infrastructure/config"
5+)
6+
7+// ConfigAdapter adapts config.Config to the ConfigProvider interface.
8+type ConfigAdapter struct {
9+ cfg *config.Config
10+}
11+
12+// NewConfigAdapter creates a new config adapter.
13+func NewConfigAdapter(cfg *config.Config) *ConfigAdapter {
14+ return &ConfigAdapter{cfg: cfg}
15+}
16+
17+// GetColorScheme implements ConfigProvider.
18+func (a *ConfigAdapter) GetColorScheme() string {
19+ if a.cfg == nil {
20+ return ""
21+ }
22+ return a.cfg.Appearance.ColorScheme
23+}
@@ -0,0 +1,23 @@
1+package colorscheme
2+
3+import (
4+ "github.com/bnema/dumber/internal/infrastructure/config"
5+)
6+
7+// ConfigAdapter adapts config.Config to the ConfigProvider interface.
8+type ConfigAdapter struct {
9+ cfg *config.Config
10+}
11+
12+// NewConfigAdapter creates a new config adapter.
13+func NewConfigAdapter(cfg *config.Config) *ConfigAdapter {
14+ return &ConfigAdapter{cfg: cfg}
15+}
16+
17+// GetColorScheme implements ConfigProvider.
18+func (a *ConfigAdapter) GetColorScheme() string {
19+ if a.cfg == nil {
20+ return ""
21+ }
22+ return a.cfg.Appearance.ColorScheme
23+}
A internal/infrastructure/colorscheme/detector_adwaita.go +62 -0
@@ -0,0 +1,62 @@
1+package colorscheme
2+
3+import (
4+ "sync/atomic"
5+
6+ "github.com/jwijenbergh/puregotk/v4/adw"
7+)
8+
9+const (
10+ detectorNameAdwaita = "libadwaita"
11+ priorityAdwaita = 100
12+)
13+
14+// AdwaitaDetector detects color scheme from libadwaita's StyleManager.
15+// This is the most accurate detector after adw.Init() has been called,
16+// as it reflects the same state that WebKit uses for prefers-color-scheme.
17+type AdwaitaDetector struct {
18+ available atomic.Bool
19+}
20+
21+// NewAdwaitaDetector creates a new libadwaita-based detector.
22+// Initially unavailable until MarkAvailable() is called after adw.Init().
23+func NewAdwaitaDetector() *AdwaitaDetector {
24+ return &AdwaitaDetector{}
25+}
26+
27+// Name implements port.ColorSchemeDetector.
28+func (*AdwaitaDetector) Name() string {
29+ return detectorNameAdwaita
30+}
31+
32+// Priority implements port.ColorSchemeDetector.
33+func (*AdwaitaDetector) Priority() int {
34+ return priorityAdwaita
35+}
36+
37+// Available implements port.ColorSchemeDetector.
38+// Returns true only after MarkAvailable() has been called.
39+func (d *AdwaitaDetector) Available() bool {
40+ return d.available.Load()
41+}
42+
43+// MarkAvailable should be called after adw.Init() completes.
44+// This enables the detector to query StyleManager.
45+func (d *AdwaitaDetector) MarkAvailable() {
46+ d.available.Store(true)
47+}
48+
49+// Detect implements port.ColorSchemeDetector.
50+// Queries libadwaita's StyleManager for the dark mode preference.
51+func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) {
52+ if !d.Available() {
53+ return false, false
54+ }
55+
56+ styleMgr := adw.StyleManagerGetDefault()
57+ if styleMgr == nil {
58+ return false, false
59+ }
60+
61+ return styleMgr.GetDark(), true
62+}
@@ -0,0 +1,62 @@
1+package colorscheme
2+
3+import (
4+ "sync/atomic"
5+
6+ "github.com/jwijenbergh/puregotk/v4/adw"
7+)
8+
9+const (
10+ detectorNameAdwaita = "libadwaita"
11+ priorityAdwaita = 100
12+)
13+
14+// AdwaitaDetector detects color scheme from libadwaita's StyleManager.
15+// This is the most accurate detector after adw.Init() has been called,
16+// as it reflects the same state that WebKit uses for prefers-color-scheme.
17+type AdwaitaDetector struct {
18+ available atomic.Bool
19+}
20+
21+// NewAdwaitaDetector creates a new libadwaita-based detector.
22+// Initially unavailable until MarkAvailable() is called after adw.Init().
23+func NewAdwaitaDetector() *AdwaitaDetector {
24+ return &AdwaitaDetector{}
25+}
26+
27+// Name implements port.ColorSchemeDetector.
28+func (*AdwaitaDetector) Name() string {
29+ return detectorNameAdwaita
30+}
31+
32+// Priority implements port.ColorSchemeDetector.
33+func (*AdwaitaDetector) Priority() int {
34+ return priorityAdwaita
35+}
36+
37+// Available implements port.ColorSchemeDetector.
38+// Returns true only after MarkAvailable() has been called.
39+func (d *AdwaitaDetector) Available() bool {
40+ return d.available.Load()
41+}
42+
43+// MarkAvailable should be called after adw.Init() completes.
44+// This enables the detector to query StyleManager.
45+func (d *AdwaitaDetector) MarkAvailable() {
46+ d.available.Store(true)
47+}
48+
49+// Detect implements port.ColorSchemeDetector.
50+// Queries libadwaita's StyleManager for the dark mode preference.
51+func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) {
52+ if !d.Available() {
53+ return false, false
54+ }
55+
56+ styleMgr := adw.StyleManagerGetDefault()
57+ if styleMgr == nil {
58+ return false, false
59+ }
60+
61+ return styleMgr.GetDark(), true
62+}
A internal/infrastructure/colorscheme/detector_env.go +50 -0
@@ -0,0 +1,50 @@
1+package colorscheme
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameEnv = "GTK_THEME"
10+ priorityEnv = 20
11+)
12+
13+// EnvDetector detects color scheme from GTK_THEME environment variable.
14+// This is useful when users explicitly set their theme via environment.
15+type EnvDetector struct{}
16+
17+// NewEnvDetector creates a new environment variable-based detector.
18+func NewEnvDetector() *EnvDetector {
19+ return &EnvDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*EnvDetector) Name() string {
24+ return detectorNameEnv
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*EnvDetector) Priority() int {
29+ return priorityEnv
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if GTK_THEME environment variable is set.
34+func (*EnvDetector) Available() bool {
35+ return os.Getenv("GTK_THEME") != ""
36+}
37+
38+// Detect implements port.ColorSchemeDetector.
39+// Checks if GTK_THEME contains "dark" (case-insensitive).
40+func (*EnvDetector) Detect() (prefersDark, ok bool) {
41+ gtkTheme := os.Getenv("GTK_THEME")
42+ if gtkTheme == "" {
43+ return false, false
44+ }
45+
46+ // If GTK_THEME contains "dark", prefer dark mode
47+ // Otherwise, assume light mode
48+ prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
49+ return prefersDark, true
50+}
@@ -0,0 +1,50 @@
1+package colorscheme
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameEnv = "GTK_THEME"
10+ priorityEnv = 20
11+)
12+
13+// EnvDetector detects color scheme from GTK_THEME environment variable.
14+// This is useful when users explicitly set their theme via environment.
15+type EnvDetector struct{}
16+
17+// NewEnvDetector creates a new environment variable-based detector.
18+func NewEnvDetector() *EnvDetector {
19+ return &EnvDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*EnvDetector) Name() string {
24+ return detectorNameEnv
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*EnvDetector) Priority() int {
29+ return priorityEnv
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if GTK_THEME environment variable is set.
34+func (*EnvDetector) Available() bool {
35+ return os.Getenv("GTK_THEME") != ""
36+}
37+
38+// Detect implements port.ColorSchemeDetector.
39+// Checks if GTK_THEME contains "dark" (case-insensitive).
40+func (*EnvDetector) Detect() (prefersDark, ok bool) {
41+ gtkTheme := os.Getenv("GTK_THEME")
42+ if gtkTheme == "" {
43+ return false, false
44+ }
45+
46+ // If GTK_THEME contains "dark", prefer dark mode
47+ // Otherwise, assume light mode
48+ prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
49+ return prefersDark, true
50+}
A internal/infrastructure/colorscheme/detector_gsettings.go +63 -0
@@ -0,0 +1,63 @@
1+package colorscheme
2+
3+import (
4+ "os/exec"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameGsettings = "gsettings"
10+ priorityGsettings = 10
11+)
12+
13+// GsettingsDetector detects color scheme from GNOME gsettings.
14+// This is the most reliable method for GNOME-based desktops.
15+type GsettingsDetector struct{}
16+
17+// NewGsettingsDetector creates a new gsettings-based detector.
18+func NewGsettingsDetector() *GsettingsDetector {
19+ return &GsettingsDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*GsettingsDetector) Name() string {
24+ return detectorNameGsettings
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*GsettingsDetector) Priority() int {
29+ return priorityGsettings
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if gsettings command is available.
34+func (*GsettingsDetector) Available() bool {
35+ _, err := exec.LookPath("gsettings")
36+ return err == nil
37+}
38+
39+// Detect implements port.ColorSchemeDetector.
40+// Queries org.gnome.desktop.interface color-scheme.
41+func (*GsettingsDetector) Detect() (prefersDark, ok bool) {
42+ cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
43+ output, err := cmd.Output()
44+ if err != nil {
45+ return false, false
46+ }
47+
48+ // Output is like "'prefer-dark'\n", strip quotes and whitespace
49+ result := strings.TrimSpace(string(output))
50+ result = strings.Trim(result, "'\"")
51+
52+ switch result {
53+ case "prefer-dark":
54+ return true, true
55+ case "prefer-light":
56+ return false, true
57+ case "default":
58+ // "default" means follow system, which we can't determine here
59+ return false, false
60+ default:
61+ return false, false
62+ }
63+}
@@ -0,0 +1,63 @@
1+package colorscheme
2+
3+import (
4+ "os/exec"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameGsettings = "gsettings"
10+ priorityGsettings = 10
11+)
12+
13+// GsettingsDetector detects color scheme from GNOME gsettings.
14+// This is the most reliable method for GNOME-based desktops.
15+type GsettingsDetector struct{}
16+
17+// NewGsettingsDetector creates a new gsettings-based detector.
18+func NewGsettingsDetector() *GsettingsDetector {
19+ return &GsettingsDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*GsettingsDetector) Name() string {
24+ return detectorNameGsettings
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*GsettingsDetector) Priority() int {
29+ return priorityGsettings
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if gsettings command is available.
34+func (*GsettingsDetector) Available() bool {
35+ _, err := exec.LookPath("gsettings")
36+ return err == nil
37+}
38+
39+// Detect implements port.ColorSchemeDetector.
40+// Queries org.gnome.desktop.interface color-scheme.
41+func (*GsettingsDetector) Detect() (prefersDark, ok bool) {
42+ cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
43+ output, err := cmd.Output()
44+ if err != nil {
45+ return false, false
46+ }
47+
48+ // Output is like "'prefer-dark'\n", strip quotes and whitespace
49+ result := strings.TrimSpace(string(output))
50+ result = strings.Trim(result, "'\"")
51+
52+ switch result {
53+ case "prefer-dark":
54+ return true, true
55+ case "prefer-light":
56+ return false, true
57+ case "default":
58+ // "default" means follow system, which we can't determine here
59+ return false, false
60+ default:
61+ return false, false
62+ }
63+}
A internal/infrastructure/colorscheme/resolver.go +170 -0
@@ -0,0 +1,170 @@
1+package colorscheme
2+
3+import (
4+ "cmp"
5+ "slices"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ // sourceFallback indicates no detector provided the preference.
14+ sourceFallback = "fallback"
15+ // sourceConfig indicates the preference came from user config.
16+ sourceConfig = "config"
17+)
18+
19+// ConfigProvider provides access to the color scheme configuration.
20+type ConfigProvider interface {
21+ // GetColorScheme returns the configured color scheme preference.
22+ // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light"
23+ GetColorScheme() string
24+}
25+
26+// callbackWrapper wraps a callback function to enable pointer comparison for removal.
27+type callbackWrapper struct {
28+ fn func(port.ColorSchemePreference)
29+}
30+
31+// Resolver implements port.ColorSchemeResolver.
32+// It manages multiple detectors and respects config overrides.
33+type Resolver struct {
34+ mu sync.RWMutex
35+ config ConfigProvider
36+ detectors []port.ColorSchemeDetector
37+ sortedDetectors []port.ColorSchemeDetector // cached sorted detectors
38+ current port.ColorSchemePreference
39+ callbacks []*callbackWrapper
40+}
41+
42+// NewResolver creates a new color scheme resolver.
43+// The config provider is used to check for explicit user preferences.
44+func NewResolver(config ConfigProvider) *Resolver {
45+ return &Resolver{
46+ config: config,
47+ detectors: make([]port.ColorSchemeDetector, 0),
48+ current: port.ColorSchemePreference{
49+ PrefersDark: true, // Default to dark until first Resolve()
50+ Source: sourceFallback,
51+ },
52+ }
53+}
54+
55+// Resolve implements port.ColorSchemeResolver.
56+func (r *Resolver) Resolve() port.ColorSchemePreference {
57+ r.mu.RLock()
58+ defer r.mu.RUnlock()
59+ return r.resolveInternal()
60+}
61+
62+// resolveInternal performs the actual resolution without locking.
63+// Caller must hold at least a read lock.
64+func (r *Resolver) resolveInternal() port.ColorSchemePreference {
65+ // Check config for explicit override first
66+ if r.config != nil {
67+ scheme := r.config.GetColorScheme()
68+ switch strings.ToLower(scheme) {
69+ case "prefer-dark", "dark":
70+ return port.ColorSchemePreference{
71+ PrefersDark: true,
72+ Source: sourceConfig,
73+ }
74+ case "prefer-light", "light":
75+ return port.ColorSchemePreference{
76+ PrefersDark: false,
77+ Source: sourceConfig,
78+ }
79+ // "default" or empty falls through to detector chain
80+ }
81+ }
82+
83+ // Try each detector in priority order (already sorted)
84+ for _, detector := range r.sortedDetectors {
85+ if !detector.Available() {
86+ continue
87+ }
88+ if prefersDark, ok := detector.Detect(); ok {
89+ return port.ColorSchemePreference{
90+ PrefersDark: prefersDark,
91+ Source: detector.Name(),
92+ }
93+ }
94+ }
95+
96+ // Fallback to dark mode if all detectors fail
97+ return port.ColorSchemePreference{
98+ PrefersDark: true,
99+ Source: sourceFallback,
100+ }
101+}
102+
103+// RegisterDetector implements port.ColorSchemeResolver.
104+func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
105+ r.mu.Lock()
106+ defer r.mu.Unlock()
107+ r.detectors = append(r.detectors, detector)
108+ r.rebuildSortedDetectors()
109+}
110+
111+// rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first).
112+// Caller must hold the write lock.
113+func (r *Resolver) rebuildSortedDetectors() {
114+ r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors))
115+ copy(r.sortedDetectors, r.detectors)
116+ slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int {
117+ return cmp.Compare(b.Priority(), a.Priority()) // descending order
118+ })
119+}
120+
121+// Refresh implements port.ColorSchemeResolver.
122+func (r *Resolver) Refresh() port.ColorSchemePreference {
123+ r.mu.Lock()
124+
125+ newPref := r.resolveInternal()
126+
127+ // Prepare callbacks to invoke if preference changed
128+ var callbacks []*callbackWrapper
129+ if newPref.PrefersDark != r.current.PrefersDark {
130+ r.current = newPref
131+ callbacks = make([]*callbackWrapper, len(r.callbacks))
132+ copy(callbacks, r.callbacks)
133+ } else {
134+ r.current = newPref
135+ }
136+
137+ // Release lock before invoking callbacks to avoid deadlocks
138+ r.mu.Unlock()
139+
140+ // Invoke callbacks outside of lock
141+ for _, cb := range callbacks {
142+ cb.fn(newPref)
143+ }
144+
145+ return newPref
146+}
147+
148+// OnChange implements port.ColorSchemeResolver.
149+func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+
153+ // Wrap callback to enable pointer comparison for removal
154+ wrapper := &callbackWrapper{fn: callback}
155+ r.callbacks = append(r.callbacks, wrapper)
156+
157+ // Return unregister function
158+ return func() {
159+ r.mu.Lock()
160+ defer r.mu.Unlock()
161+
162+ // Find and remove callback by pointer equality
163+ for i, cb := range r.callbacks {
164+ if cb == wrapper {
165+ r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...)
166+ return
167+ }
168+ }
169+ }
170+}
@@ -0,0 +1,170 @@
1+package colorscheme
2+
3+import (
4+ "cmp"
5+ "slices"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ // sourceFallback indicates no detector provided the preference.
14+ sourceFallback = "fallback"
15+ // sourceConfig indicates the preference came from user config.
16+ sourceConfig = "config"
17+)
18+
19+// ConfigProvider provides access to the color scheme configuration.
20+type ConfigProvider interface {
21+ // GetColorScheme returns the configured color scheme preference.
22+ // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light"
23+ GetColorScheme() string
24+}
25+
26+// callbackWrapper wraps a callback function to enable pointer comparison for removal.
27+type callbackWrapper struct {
28+ fn func(port.ColorSchemePreference)
29+}
30+
31+// Resolver implements port.ColorSchemeResolver.
32+// It manages multiple detectors and respects config overrides.
33+type Resolver struct {
34+ mu sync.RWMutex
35+ config ConfigProvider
36+ detectors []port.ColorSchemeDetector
37+ sortedDetectors []port.ColorSchemeDetector // cached sorted detectors
38+ current port.ColorSchemePreference
39+ callbacks []*callbackWrapper
40+}
41+
42+// NewResolver creates a new color scheme resolver.
43+// The config provider is used to check for explicit user preferences.
44+func NewResolver(config ConfigProvider) *Resolver {
45+ return &Resolver{
46+ config: config,
47+ detectors: make([]port.ColorSchemeDetector, 0),
48+ current: port.ColorSchemePreference{
49+ PrefersDark: true, // Default to dark until first Resolve()
50+ Source: sourceFallback,
51+ },
52+ }
53+}
54+
55+// Resolve implements port.ColorSchemeResolver.
56+func (r *Resolver) Resolve() port.ColorSchemePreference {
57+ r.mu.RLock()
58+ defer r.mu.RUnlock()
59+ return r.resolveInternal()
60+}
61+
62+// resolveInternal performs the actual resolution without locking.
63+// Caller must hold at least a read lock.
64+func (r *Resolver) resolveInternal() port.ColorSchemePreference {
65+ // Check config for explicit override first
66+ if r.config != nil {
67+ scheme := r.config.GetColorScheme()
68+ switch strings.ToLower(scheme) {
69+ case "prefer-dark", "dark":
70+ return port.ColorSchemePreference{
71+ PrefersDark: true,
72+ Source: sourceConfig,
73+ }
74+ case "prefer-light", "light":
75+ return port.ColorSchemePreference{
76+ PrefersDark: false,
77+ Source: sourceConfig,
78+ }
79+ // "default" or empty falls through to detector chain
80+ }
81+ }
82+
83+ // Try each detector in priority order (already sorted)
84+ for _, detector := range r.sortedDetectors {
85+ if !detector.Available() {
86+ continue
87+ }
88+ if prefersDark, ok := detector.Detect(); ok {
89+ return port.ColorSchemePreference{
90+ PrefersDark: prefersDark,
91+ Source: detector.Name(),
92+ }
93+ }
94+ }
95+
96+ // Fallback to dark mode if all detectors fail
97+ return port.ColorSchemePreference{
98+ PrefersDark: true,
99+ Source: sourceFallback,
100+ }
101+}
102+
103+// RegisterDetector implements port.ColorSchemeResolver.
104+func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
105+ r.mu.Lock()
106+ defer r.mu.Unlock()
107+ r.detectors = append(r.detectors, detector)
108+ r.rebuildSortedDetectors()
109+}
110+
111+// rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first).
112+// Caller must hold the write lock.
113+func (r *Resolver) rebuildSortedDetectors() {
114+ r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors))
115+ copy(r.sortedDetectors, r.detectors)
116+ slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int {
117+ return cmp.Compare(b.Priority(), a.Priority()) // descending order
118+ })
119+}
120+
121+// Refresh implements port.ColorSchemeResolver.
122+func (r *Resolver) Refresh() port.ColorSchemePreference {
123+ r.mu.Lock()
124+
125+ newPref := r.resolveInternal()
126+
127+ // Prepare callbacks to invoke if preference changed
128+ var callbacks []*callbackWrapper
129+ if newPref.PrefersDark != r.current.PrefersDark {
130+ r.current = newPref
131+ callbacks = make([]*callbackWrapper, len(r.callbacks))
132+ copy(callbacks, r.callbacks)
133+ } else {
134+ r.current = newPref
135+ }
136+
137+ // Release lock before invoking callbacks to avoid deadlocks
138+ r.mu.Unlock()
139+
140+ // Invoke callbacks outside of lock
141+ for _, cb := range callbacks {
142+ cb.fn(newPref)
143+ }
144+
145+ return newPref
146+}
147+
148+// OnChange implements port.ColorSchemeResolver.
149+func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+
153+ // Wrap callback to enable pointer comparison for removal
154+ wrapper := &callbackWrapper{fn: callback}
155+ r.callbacks = append(r.callbacks, wrapper)
156+
157+ // Return unregister function
158+ return func() {
159+ r.mu.Lock()
160+ defer r.mu.Unlock()
161+
162+ // Find and remove callback by pointer equality
163+ for i, cb := range r.callbacks {
164+ if cb == wrapper {
165+ r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...)
166+ return
167+ }
168+ }
169+ }
170+}
A internal/infrastructure/colorscheme/resolver_test.go +425 -0
@@ -0,0 +1,425 @@
1+package colorscheme
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// mockConfigProvider implements ConfigProvider for testing.
13+type mockConfigProvider struct {
14+ scheme string
15+}
16+
17+func (m *mockConfigProvider) GetColorScheme() string {
18+ return m.scheme
19+}
20+
21+// mockDetector implements port.ColorSchemeDetector for testing.
22+type mockDetector struct {
23+ name string
24+ priority int
25+ available bool
26+ prefersDark bool
27+ detectOk bool
28+}
29+
30+func (m *mockDetector) Name() string { return m.name }
31+func (m *mockDetector) Priority() int { return m.priority }
32+func (m *mockDetector) Available() bool { return m.available }
33+func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk }
34+
35+func TestResolver_ConfigOverride(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ configValue string
39+ wantDark bool
40+ wantSource string
41+ }{
42+ {
43+ name: "prefer-dark from config",
44+ configValue: "prefer-dark",
45+ wantDark: true,
46+ wantSource: "config",
47+ },
48+ {
49+ name: "dark from config",
50+ configValue: "dark",
51+ wantDark: true,
52+ wantSource: "config",
53+ },
54+ {
55+ name: "prefer-light from config",
56+ configValue: "prefer-light",
57+ wantDark: false,
58+ wantSource: "config",
59+ },
60+ {
61+ name: "light from config",
62+ configValue: "light",
63+ wantDark: false,
64+ wantSource: "config",
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ config := &mockConfigProvider{scheme: tt.configValue}
71+ resolver := NewResolver(config)
72+
73+ pref := resolver.Resolve()
74+
75+ assert.Equal(t, tt.wantDark, pref.PrefersDark)
76+ assert.Equal(t, tt.wantSource, pref.Source)
77+ })
78+ }
79+}
80+
81+func TestResolver_DefaultFallsThrough(t *testing.T) {
82+ config := &mockConfigProvider{scheme: "default"}
83+ resolver := NewResolver(config)
84+
85+ // Add a detector that returns light
86+ detector := &mockDetector{
87+ name: "test",
88+ priority: 50,
89+ available: true,
90+ prefersDark: false,
91+ detectOk: true,
92+ }
93+ resolver.RegisterDetector(detector)
94+
95+ pref := resolver.Resolve()
96+
97+ assert.False(t, pref.PrefersDark)
98+ assert.Equal(t, "test", pref.Source)
99+}
100+
101+func TestResolver_DetectorPriority(t *testing.T) {
102+ config := &mockConfigProvider{scheme: "default"}
103+ resolver := NewResolver(config)
104+
105+ // Low priority detector returns dark
106+ lowPriority := &mockDetector{
107+ name: "low",
108+ priority: 10,
109+ available: true,
110+ prefersDark: true,
111+ detectOk: true,
112+ }
113+ // High priority detector returns light
114+ highPriority := &mockDetector{
115+ name: "high",
116+ priority: 100,
117+ available: true,
118+ prefersDark: false,
119+ detectOk: true,
120+ }
121+
122+ // Register low first, high second (order shouldn't matter)
123+ resolver.RegisterDetector(lowPriority)
124+ resolver.RegisterDetector(highPriority)
125+
126+ pref := resolver.Resolve()
127+
128+ // Should use high priority detector
129+ assert.False(t, pref.PrefersDark)
130+ assert.Equal(t, "high", pref.Source)
131+}
132+
133+func TestResolver_SkipsUnavailableDetector(t *testing.T) {
134+ config := &mockConfigProvider{scheme: "default"}
135+ resolver := NewResolver(config)
136+
137+ // High priority but unavailable
138+ unavailable := &mockDetector{
139+ name: "unavailable",
140+ priority: 100,
141+ available: false,
142+ prefersDark: false,
143+ detectOk: true,
144+ }
145+ // Low priority but available
146+ available := &mockDetector{
147+ name: "available",
148+ priority: 10,
149+ available: true,
150+ prefersDark: true,
151+ detectOk: true,
152+ }
153+
154+ resolver.RegisterDetector(unavailable)
155+ resolver.RegisterDetector(available)
156+
157+ pref := resolver.Resolve()
158+
159+ // Should use available detector
160+ assert.True(t, pref.PrefersDark)
161+ assert.Equal(t, "available", pref.Source)
162+}
163+
164+func TestResolver_SkipsFailedDetection(t *testing.T) {
165+ config := &mockConfigProvider{scheme: "default"}
166+ resolver := NewResolver(config)
167+
168+ // High priority but detection fails
169+ failing := &mockDetector{
170+ name: "failing",
171+ priority: 100,
172+ available: true,
173+ prefersDark: false,
174+ detectOk: false, // Detection fails
175+ }
176+ // Low priority but succeeds
177+ succeeding := &mockDetector{
178+ name: "succeeding",
179+ priority: 10,
180+ available: true,
181+ prefersDark: true,
182+ detectOk: true,
183+ }
184+
185+ resolver.RegisterDetector(failing)
186+ resolver.RegisterDetector(succeeding)
187+
188+ pref := resolver.Resolve()
189+
190+ // Should use succeeding detector
191+ assert.True(t, pref.PrefersDark)
192+ assert.Equal(t, "succeeding", pref.Source)
193+}
194+
195+func TestResolver_FallbackWhenNoDetectors(t *testing.T) {
196+ config := &mockConfigProvider{scheme: "default"}
197+ resolver := NewResolver(config)
198+
199+ // No detectors registered
200+ pref := resolver.Resolve()
201+
202+ // Should fallback to dark
203+ assert.True(t, pref.PrefersDark)
204+ assert.Equal(t, "fallback", pref.Source)
205+}
206+
207+func TestResolver_FallbackWhenAllFail(t *testing.T) {
208+ config := &mockConfigProvider{scheme: "default"}
209+ resolver := NewResolver(config)
210+
211+ // All detectors fail
212+ resolver.RegisterDetector(&mockDetector{
213+ name: "fail1",
214+ priority: 100,
215+ available: true,
216+ detectOk: false,
217+ })
218+ resolver.RegisterDetector(&mockDetector{
219+ name: "fail2",
220+ priority: 50,
221+ available: false,
222+ })
223+
224+ pref := resolver.Resolve()
225+
226+ // Should fallback to dark
227+ assert.True(t, pref.PrefersDark)
228+ assert.Equal(t, "fallback", pref.Source)
229+}
230+
231+func TestResolver_NilConfig(t *testing.T) {
232+ resolver := NewResolver(nil)
233+
234+ detector := &mockDetector{
235+ name: "test",
236+ priority: 50,
237+ available: true,
238+ prefersDark: false,
239+ detectOk: true,
240+ }
241+ resolver.RegisterDetector(detector)
242+
243+ pref := resolver.Resolve()
244+
245+ // Should use detector since config is nil
246+ assert.False(t, pref.PrefersDark)
247+ assert.Equal(t, "test", pref.Source)
248+}
249+
250+func TestResolver_Refresh(t *testing.T) {
251+ config := &mockConfigProvider{scheme: "default"}
252+ resolver := NewResolver(config)
253+
254+ // Start with light-preferring detector
255+ detector := &mockDetector{
256+ name: "test",
257+ priority: 50,
258+ available: true,
259+ prefersDark: false,
260+ detectOk: true,
261+ }
262+ resolver.RegisterDetector(detector)
263+
264+ pref1 := resolver.Refresh()
265+ assert.False(t, pref1.PrefersDark)
266+
267+ // Change detector preference
268+ detector.prefersDark = true
269+
270+ pref2 := resolver.Refresh()
271+ assert.True(t, pref2.PrefersDark)
272+}
273+
274+func TestResolver_OnChange(t *testing.T) {
275+ config := &mockConfigProvider{scheme: "default"}
276+ resolver := NewResolver(config)
277+
278+ detector := &mockDetector{
279+ name: "test",
280+ priority: 50,
281+ available: true,
282+ prefersDark: false,
283+ detectOk: true,
284+ }
285+ resolver.RegisterDetector(detector)
286+
287+ var callbackPref port.ColorSchemePreference
288+ var callbackCount int
289+ resolver.OnChange(func(pref port.ColorSchemePreference) {
290+ callbackPref = pref
291+ callbackCount++
292+ })
293+
294+ // Initial refresh - callback should be called (default was dark, now light)
295+ resolver.Refresh()
296+ assert.Equal(t, 1, callbackCount)
297+ assert.False(t, callbackPref.PrefersDark)
298+
299+ // Same preference - callback should NOT be called
300+ resolver.Refresh()
301+ assert.Equal(t, 1, callbackCount)
302+
303+ // Change preference - callback should be called
304+ detector.prefersDark = true
305+ resolver.Refresh()
306+ assert.Equal(t, 2, callbackCount)
307+ assert.True(t, callbackPref.PrefersDark)
308+}
309+
310+func TestResolver_OnChangeUnregister(t *testing.T) {
311+ config := &mockConfigProvider{scheme: "default"}
312+ resolver := NewResolver(config)
313+
314+ detector := &mockDetector{
315+ name: "test",
316+ priority: 50,
317+ available: true,
318+ prefersDark: false,
319+ detectOk: true,
320+ }
321+ resolver.RegisterDetector(detector)
322+
323+ var callbackCount int
324+ unregister := resolver.OnChange(func(_ port.ColorSchemePreference) {
325+ callbackCount++
326+ })
327+
328+ // Initial refresh triggers callback
329+ resolver.Refresh()
330+ assert.Equal(t, 1, callbackCount)
331+
332+ // Unregister
333+ unregister()
334+
335+ // Change preference - callback should NOT be called
336+ detector.prefersDark = true
337+ resolver.Refresh()
338+ assert.Equal(t, 1, callbackCount) // Still 1, not 2
339+}
340+
341+func TestResolver_ConcurrentAccess(_ *testing.T) {
342+ config := &mockConfigProvider{scheme: "default"}
343+ resolver := NewResolver(config)
344+
345+ detector := &mockDetector{
346+ name: "test",
347+ priority: 50,
348+ available: true,
349+ prefersDark: false,
350+ detectOk: true,
351+ }
352+ resolver.RegisterDetector(detector)
353+
354+ var wg sync.WaitGroup
355+ const goroutines = 10
356+
357+ // Concurrent Resolve calls
358+ for i := 0; i < goroutines; i++ {
359+ wg.Add(1)
360+ go func() {
361+ defer wg.Done()
362+ for j := 0; j < 100; j++ {
363+ resolver.Resolve()
364+ }
365+ }()
366+ }
367+
368+ // Concurrent Refresh calls
369+ for i := 0; i < goroutines; i++ {
370+ wg.Add(1)
371+ go func() {
372+ defer wg.Done()
373+ for j := 0; j < 100; j++ {
374+ resolver.Refresh()
375+ }
376+ }()
377+ }
378+
379+ // Concurrent RegisterDetector calls
380+ for i := 0; i < goroutines; i++ {
381+ wg.Add(1)
382+ go func(id int) {
383+ defer wg.Done()
384+ resolver.RegisterDetector(&mockDetector{
385+ name: "concurrent",
386+ priority: id,
387+ available: true,
388+ prefersDark: id%2 == 0,
389+ detectOk: true,
390+ })
391+ }(i)
392+ }
393+
394+ wg.Wait()
395+ // Test passes if no race conditions detected
396+}
397+
398+func TestResolver_EmptyConfigScheme(t *testing.T) {
399+ config := &mockConfigProvider{scheme: ""}
400+ resolver := NewResolver(config)
401+
402+ detector := &mockDetector{
403+ name: "test",
404+ priority: 50,
405+ available: true,
406+ prefersDark: false,
407+ detectOk: true,
408+ }
409+ resolver.RegisterDetector(detector)
410+
411+ pref := resolver.Resolve()
412+
413+ // Empty config should fall through to detector
414+ assert.False(t, pref.PrefersDark)
415+ assert.Equal(t, "test", pref.Source)
416+}
417+
418+func TestResolver_ImplementsInterface(t *testing.T) {
419+ config := &mockConfigProvider{scheme: "default"}
420+ resolver := NewResolver(config)
421+
422+ // Verify Resolver implements port.ColorSchemeResolver
423+ var _ port.ColorSchemeResolver = resolver
424+ require.NotNil(t, resolver)
425+}
@@ -0,0 +1,425 @@
1+package colorscheme
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// mockConfigProvider implements ConfigProvider for testing.
13+type mockConfigProvider struct {
14+ scheme string
15+}
16+
17+func (m *mockConfigProvider) GetColorScheme() string {
18+ return m.scheme
19+}
20+
21+// mockDetector implements port.ColorSchemeDetector for testing.
22+type mockDetector struct {
23+ name string
24+ priority int
25+ available bool
26+ prefersDark bool
27+ detectOk bool
28+}
29+
30+func (m *mockDetector) Name() string { return m.name }
31+func (m *mockDetector) Priority() int { return m.priority }
32+func (m *mockDetector) Available() bool { return m.available }
33+func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk }
34+
35+func TestResolver_ConfigOverride(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ configValue string
39+ wantDark bool
40+ wantSource string
41+ }{
42+ {
43+ name: "prefer-dark from config",
44+ configValue: "prefer-dark",
45+ wantDark: true,
46+ wantSource: "config",
47+ },
48+ {
49+ name: "dark from config",
50+ configValue: "dark",
51+ wantDark: true,
52+ wantSource: "config",
53+ },
54+ {
55+ name: "prefer-light from config",
56+ configValue: "prefer-light",
57+ wantDark: false,
58+ wantSource: "config",
59+ },
60+ {
61+ name: "light from config",
62+ configValue: "light",
63+ wantDark: false,
64+ wantSource: "config",
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ config := &mockConfigProvider{scheme: tt.configValue}
71+ resolver := NewResolver(config)
72+
73+ pref := resolver.Resolve()
74+
75+ assert.Equal(t, tt.wantDark, pref.PrefersDark)
76+ assert.Equal(t, tt.wantSource, pref.Source)
77+ })
78+ }
79+}
80+
81+func TestResolver_DefaultFallsThrough(t *testing.T) {
82+ config := &mockConfigProvider{scheme: "default"}
83+ resolver := NewResolver(config)
84+
85+ // Add a detector that returns light
86+ detector := &mockDetector{
87+ name: "test",
88+ priority: 50,
89+ available: true,
90+ prefersDark: false,
91+ detectOk: true,
92+ }
93+ resolver.RegisterDetector(detector)
94+
95+ pref := resolver.Resolve()
96+
97+ assert.False(t, pref.PrefersDark)
98+ assert.Equal(t, "test", pref.Source)
99+}
100+
101+func TestResolver_DetectorPriority(t *testing.T) {
102+ config := &mockConfigProvider{scheme: "default"}
103+ resolver := NewResolver(config)
104+
105+ // Low priority detector returns dark
106+ lowPriority := &mockDetector{
107+ name: "low",
108+ priority: 10,
109+ available: true,
110+ prefersDark: true,
111+ detectOk: true,
112+ }
113+ // High priority detector returns light
114+ highPriority := &mockDetector{
115+ name: "high",
116+ priority: 100,
117+ available: true,
118+ prefersDark: false,
119+ detectOk: true,
120+ }
121+
122+ // Register low first, high second (order shouldn't matter)
123+ resolver.RegisterDetector(lowPriority)
124+ resolver.RegisterDetector(highPriority)
125+
126+ pref := resolver.Resolve()
127+
128+ // Should use high priority detector
129+ assert.False(t, pref.PrefersDark)
130+ assert.Equal(t, "high", pref.Source)
131+}
132+
133+func TestResolver_SkipsUnavailableDetector(t *testing.T) {
134+ config := &mockConfigProvider{scheme: "default"}
135+ resolver := NewResolver(config)
136+
137+ // High priority but unavailable
138+ unavailable := &mockDetector{
139+ name: "unavailable",
140+ priority: 100,
141+ available: false,
142+ prefersDark: false,
143+ detectOk: true,
144+ }
145+ // Low priority but available
146+ available := &mockDetector{
147+ name: "available",
148+ priority: 10,
149+ available: true,
150+ prefersDark: true,
151+ detectOk: true,
152+ }
153+
154+ resolver.RegisterDetector(unavailable)
155+ resolver.RegisterDetector(available)
156+
157+ pref := resolver.Resolve()
158+
159+ // Should use available detector
160+ assert.True(t, pref.PrefersDark)
161+ assert.Equal(t, "available", pref.Source)
162+}
163+
164+func TestResolver_SkipsFailedDetection(t *testing.T) {
165+ config := &mockConfigProvider{scheme: "default"}
166+ resolver := NewResolver(config)
167+
168+ // High priority but detection fails
169+ failing := &mockDetector{
170+ name: "failing",
171+ priority: 100,
172+ available: true,
173+ prefersDark: false,
174+ detectOk: false, // Detection fails
175+ }
176+ // Low priority but succeeds
177+ succeeding := &mockDetector{
178+ name: "succeeding",
179+ priority: 10,
180+ available: true,
181+ prefersDark: true,
182+ detectOk: true,
183+ }
184+
185+ resolver.RegisterDetector(failing)
186+ resolver.RegisterDetector(succeeding)
187+
188+ pref := resolver.Resolve()
189+
190+ // Should use succeeding detector
191+ assert.True(t, pref.PrefersDark)
192+ assert.Equal(t, "succeeding", pref.Source)
193+}
194+
195+func TestResolver_FallbackWhenNoDetectors(t *testing.T) {
196+ config := &mockConfigProvider{scheme: "default"}
197+ resolver := NewResolver(config)
198+
199+ // No detectors registered
200+ pref := resolver.Resolve()
201+
202+ // Should fallback to dark
203+ assert.True(t, pref.PrefersDark)
204+ assert.Equal(t, "fallback", pref.Source)
205+}
206+
207+func TestResolver_FallbackWhenAllFail(t *testing.T) {
208+ config := &mockConfigProvider{scheme: "default"}
209+ resolver := NewResolver(config)
210+
211+ // All detectors fail
212+ resolver.RegisterDetector(&mockDetector{
213+ name: "fail1",
214+ priority: 100,
215+ available: true,
216+ detectOk: false,
217+ })
218+ resolver.RegisterDetector(&mockDetector{
219+ name: "fail2",
220+ priority: 50,
221+ available: false,
222+ })
223+
224+ pref := resolver.Resolve()
225+
226+ // Should fallback to dark
227+ assert.True(t, pref.PrefersDark)
228+ assert.Equal(t, "fallback", pref.Source)
229+}
230+
231+func TestResolver_NilConfig(t *testing.T) {
232+ resolver := NewResolver(nil)
233+
234+ detector := &mockDetector{
235+ name: "test",
236+ priority: 50,
237+ available: true,
238+ prefersDark: false,
239+ detectOk: true,
240+ }
241+ resolver.RegisterDetector(detector)
242+
243+ pref := resolver.Resolve()
244+
245+ // Should use detector since config is nil
246+ assert.False(t, pref.PrefersDark)
247+ assert.Equal(t, "test", pref.Source)
248+}
249+
250+func TestResolver_Refresh(t *testing.T) {
251+ config := &mockConfigProvider{scheme: "default"}
252+ resolver := NewResolver(config)
253+
254+ // Start with light-preferring detector
255+ detector := &mockDetector{
256+ name: "test",
257+ priority: 50,
258+ available: true,
259+ prefersDark: false,
260+ detectOk: true,
261+ }
262+ resolver.RegisterDetector(detector)
263+
264+ pref1 := resolver.Refresh()
265+ assert.False(t, pref1.PrefersDark)
266+
267+ // Change detector preference
268+ detector.prefersDark = true
269+
270+ pref2 := resolver.Refresh()
271+ assert.True(t, pref2.PrefersDark)
272+}
273+
274+func TestResolver_OnChange(t *testing.T) {
275+ config := &mockConfigProvider{scheme: "default"}
276+ resolver := NewResolver(config)
277+
278+ detector := &mockDetector{
279+ name: "test",
280+ priority: 50,
281+ available: true,
282+ prefersDark: false,
283+ detectOk: true,
284+ }
285+ resolver.RegisterDetector(detector)
286+
287+ var callbackPref port.ColorSchemePreference
288+ var callbackCount int
289+ resolver.OnChange(func(pref port.ColorSchemePreference) {
290+ callbackPref = pref
291+ callbackCount++
292+ })
293+
294+ // Initial refresh - callback should be called (default was dark, now light)
295+ resolver.Refresh()
296+ assert.Equal(t, 1, callbackCount)
297+ assert.False(t, callbackPref.PrefersDark)
298+
299+ // Same preference - callback should NOT be called
300+ resolver.Refresh()
301+ assert.Equal(t, 1, callbackCount)
302+
303+ // Change preference - callback should be called
304+ detector.prefersDark = true
305+ resolver.Refresh()
306+ assert.Equal(t, 2, callbackCount)
307+ assert.True(t, callbackPref.PrefersDark)
308+}
309+
310+func TestResolver_OnChangeUnregister(t *testing.T) {
311+ config := &mockConfigProvider{scheme: "default"}
312+ resolver := NewResolver(config)
313+
314+ detector := &mockDetector{
315+ name: "test",
316+ priority: 50,
317+ available: true,
318+ prefersDark: false,
319+ detectOk: true,
320+ }
321+ resolver.RegisterDetector(detector)
322+
323+ var callbackCount int
324+ unregister := resolver.OnChange(func(_ port.ColorSchemePreference) {
325+ callbackCount++
326+ })
327+
328+ // Initial refresh triggers callback
329+ resolver.Refresh()
330+ assert.Equal(t, 1, callbackCount)
331+
332+ // Unregister
333+ unregister()
334+
335+ // Change preference - callback should NOT be called
336+ detector.prefersDark = true
337+ resolver.Refresh()
338+ assert.Equal(t, 1, callbackCount) // Still 1, not 2
339+}
340+
341+func TestResolver_ConcurrentAccess(_ *testing.T) {
342+ config := &mockConfigProvider{scheme: "default"}
343+ resolver := NewResolver(config)
344+
345+ detector := &mockDetector{
346+ name: "test",
347+ priority: 50,
348+ available: true,
349+ prefersDark: false,
350+ detectOk: true,
351+ }
352+ resolver.RegisterDetector(detector)
353+
354+ var wg sync.WaitGroup
355+ const goroutines = 10
356+
357+ // Concurrent Resolve calls
358+ for i := 0; i < goroutines; i++ {
359+ wg.Add(1)
360+ go func() {
361+ defer wg.Done()
362+ for j := 0; j < 100; j++ {
363+ resolver.Resolve()
364+ }
365+ }()
366+ }
367+
368+ // Concurrent Refresh calls
369+ for i := 0; i < goroutines; i++ {
370+ wg.Add(1)
371+ go func() {
372+ defer wg.Done()
373+ for j := 0; j < 100; j++ {
374+ resolver.Refresh()
375+ }
376+ }()
377+ }
378+
379+ // Concurrent RegisterDetector calls
380+ for i := 0; i < goroutines; i++ {
381+ wg.Add(1)
382+ go func(id int) {
383+ defer wg.Done()
384+ resolver.RegisterDetector(&mockDetector{
385+ name: "concurrent",
386+ priority: id,
387+ available: true,
388+ prefersDark: id%2 == 0,
389+ detectOk: true,
390+ })
391+ }(i)
392+ }
393+
394+ wg.Wait()
395+ // Test passes if no race conditions detected
396+}
397+
398+func TestResolver_EmptyConfigScheme(t *testing.T) {
399+ config := &mockConfigProvider{scheme: ""}
400+ resolver := NewResolver(config)
401+
402+ detector := &mockDetector{
403+ name: "test",
404+ priority: 50,
405+ available: true,
406+ prefersDark: false,
407+ detectOk: true,
408+ }
409+ resolver.RegisterDetector(detector)
410+
411+ pref := resolver.Resolve()
412+
413+ // Empty config should fall through to detector
414+ assert.False(t, pref.PrefersDark)
415+ assert.Equal(t, "test", pref.Source)
416+}
417+
418+func TestResolver_ImplementsInterface(t *testing.T) {
419+ config := &mockConfigProvider{scheme: "default"}
420+ resolver := NewResolver(config)
421+
422+ // Verify Resolver implements port.ColorSchemeResolver
423+ var _ port.ColorSchemeResolver = resolver
424+ require.NotNil(t, resolver)
425+}
M internal/infrastructure/webkit/injector.go +23 -34
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "strings"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/logging"
910 "github.com/bnema/puregotk-webkit/webkit"
1011 )
@@ -25,7 +26,7 @@ const themeCSSScript = `(function() {
2526 (document.head || document.documentElement).appendChild(style);
2627 })();`
2728
28-// internalDarkModeScript is injected ONLY on internal pages (dumb://*).
29+// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
2930 // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
3031 // for JS-based dark mode detection in WebUI components.
3132 //
@@ -37,8 +38,9 @@ const themeCSSScript = `(function() {
3738 // - (prefers-color-scheme: dark)
3839 // - (prefers-color-scheme:dark) -- no space
3940 // - screen and (prefers-color-scheme: dark)
40-const internalDarkModeScript = `(function() {
41- var prefersDark = window.__dumber_gtk_prefers_dark || false;
41+const internalDarkModeScriptTemplate = `(function() {
42+ var prefersDark = %t;
43+ window.__dumber_gtk_prefers_dark = prefersDark;
4244 var originalMatchMedia = window.matchMedia.bind(window);
4345
4446 // Apply dark/light class to document element for Tailwind CSS theming
@@ -118,16 +120,16 @@ var internalPageAllowList = []string{"dumb://*"}
118120 // External pages receive dark mode preference via libadwaita's StyleManager.
119121 // Implements port.ContentInjector interface.
120122 type ContentInjector struct {
121- prefersDark bool
122- themeCSSVars string // CSS custom property declarations for WebUI
123- findCSS string // CSS for find-in-page highlight styling
123+ colorResolver port.ColorSchemeResolver
124+ themeCSSVars string // CSS custom property declarations for WebUI
125+ findCSS string // CSS for find-in-page highlight styling
124126 }
125127
126128 // NewContentInjector creates a new injector instance.
127-// The prefersDark parameter should come from ThemeManager.PrefersDark().
128-func NewContentInjector(prefersDark bool) *ContentInjector {
129+// The resolver is used to dynamically determine dark mode preference.
130+func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
129131 return &ContentInjector{
130- prefersDark: prefersDark,
132+ colorResolver: resolver,
131133 }
132134 }
133135
@@ -149,15 +151,9 @@ func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css strin
149151 return nil
150152 }
151153
152-// PrefersDark returns the current dark mode preference.
154+// PrefersDark returns the current dark mode preference from the resolver.
153155 func (ci *ContentInjector) PrefersDark() bool {
154- return ci.prefersDark
155-}
156-
157-// SetPrefersDark updates the dark mode preference.
158-// Call this when theme changes at runtime.
159-func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
160- ci.prefersDark = prefersDark
156+ return ci.colorResolver.Resolve().PrefersDark
161157 }
162158
163159 // InjectScripts adds scripts to the given content manager.
@@ -185,20 +181,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
185181 log.Debug().Str("script", label).Msg("injected user script")
186182 }
187183
188- // 1. Inject GTK dark mode preference for internal pages only
189- darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
190- addScript(
191- webkit.NewUserScript(
192- darkModePrefScript,
193- webkit.UserContentInjectTopFrameValue,
194- webkit.UserScriptInjectAtDocumentStartValue,
195- internalPageAllowList,
196- nil,
197- ),
198- "gtk-dark-mode-pref",
199- )
184+ prefersDark := ci.PrefersDark()
200185
201- // 2. Inject WebView ID for debugging (internal pages only)
186+ // 1. Inject WebView ID for debugging (internal pages only)
202187 if webviewID != 0 {
203188 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
204189 addScript(
@@ -211,10 +196,14 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
211196 ),
212197 "webview-id",
213198 )
199+ log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200+ } else {
201+ log.Warn().Msg("webview ID is 0, skipping ID injection")
214202 }
215203
216- // 3. Inject dark mode handler for internal pages only
204+ // 2. Inject dark mode handler for internal pages only
217205 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
206+ internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
218207 addScript(
219208 webkit.NewUserScript(
220209 internalDarkModeScript,
@@ -226,7 +215,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
226215 "internal-dark-mode-handler",
227216 )
228217
229- // 4. Inject theme CSS for internal pages (dumb://* only)
218+ // 3. Inject theme CSS for internal pages (dumb://* only)
230219 if ci.themeCSSVars != "" {
231220 // Escape for JS string literal
232221 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -246,7 +235,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
246235 log.Debug().Msg("theme CSS vars injection configured for internal pages")
247236 }
248237
249- // 5. Inject find highlight CSS for all pages
238+ // 4. Inject find highlight CSS for all pages
250239 if ci.findCSS != "" {
251240 stylesheet := webkit.NewUserStyleSheet(
252241 ci.findCSS,
@@ -263,5 +252,5 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
263252 }
264253 }
265254
266- log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("scripts injected")
255+ log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
267256 }
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "strings"6 "strings"
7 7
8+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/puregotk-webkit/webkit"10 "github.com/bnema/puregotk-webkit/webkit"
10 )11 )
@@ -25,7 +26,7 @@ const themeCSSScript = `(function() {
25 (document.head || document.documentElement).appendChild(style);26 (document.head || document.documentElement).appendChild(style);
26 })();`27 })();`
27 28
28-// internalDarkModeScript is injected ONLY on internal pages (dumb://*).29+// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
29 // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia30 // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
30 // for JS-based dark mode detection in WebUI components.31 // for JS-based dark mode detection in WebUI components.
31 //32 //
@@ -37,8 +38,9 @@ const themeCSSScript = `(function() {
37 // - (prefers-color-scheme: dark)38 // - (prefers-color-scheme: dark)
38 // - (prefers-color-scheme:dark) -- no space39 // - (prefers-color-scheme:dark) -- no space
39 // - screen and (prefers-color-scheme: dark)40 // - screen and (prefers-color-scheme: dark)
40-const internalDarkModeScript = `(function() {41+const internalDarkModeScriptTemplate = `(function() {
41- var prefersDark = window.__dumber_gtk_prefers_dark || false;42+ var prefersDark = %t;
43+ window.__dumber_gtk_prefers_dark = prefersDark;
42 var originalMatchMedia = window.matchMedia.bind(window);44 var originalMatchMedia = window.matchMedia.bind(window);
43 45
44 // Apply dark/light class to document element for Tailwind CSS theming46 // Apply dark/light class to document element for Tailwind CSS theming
@@ -118,16 +120,16 @@ var internalPageAllowList = []string{"dumb://*"}
118 // External pages receive dark mode preference via libadwaita's StyleManager.120 // External pages receive dark mode preference via libadwaita's StyleManager.
119 // Implements port.ContentInjector interface.121 // Implements port.ContentInjector interface.
120 type ContentInjector struct {122 type ContentInjector struct {
121- prefersDark bool123+ colorResolver port.ColorSchemeResolver
122- themeCSSVars string // CSS custom property declarations for WebUI124+ themeCSSVars string // CSS custom property declarations for WebUI
123- findCSS string // CSS for find-in-page highlight styling125+ findCSS string // CSS for find-in-page highlight styling
124 }126 }
125 127
126 // NewContentInjector creates a new injector instance.128 // NewContentInjector creates a new injector instance.
127-// The prefersDark parameter should come from ThemeManager.PrefersDark().129+// The resolver is used to dynamically determine dark mode preference.
128-func NewContentInjector(prefersDark bool) *ContentInjector {130+func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
129 return &ContentInjector{131 return &ContentInjector{
130- prefersDark: prefersDark,132+ colorResolver: resolver,
131 }133 }
132 }134 }
133 135
@@ -149,15 +151,9 @@ func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css strin
149 return nil151 return nil
150 }152 }
151 153
152-// PrefersDark returns the current dark mode preference.154+// PrefersDark returns the current dark mode preference from the resolver.
153 func (ci *ContentInjector) PrefersDark() bool {155 func (ci *ContentInjector) PrefersDark() bool {
154- return ci.prefersDark156+ return ci.colorResolver.Resolve().PrefersDark
155-}
156-
157-// SetPrefersDark updates the dark mode preference.
158-// Call this when theme changes at runtime.
159-func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
160- ci.prefersDark = prefersDark
161 }157 }
162 158
163 // InjectScripts adds scripts to the given content manager.159 // InjectScripts adds scripts to the given content manager.
@@ -185,20 +181,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
185 log.Debug().Str("script", label).Msg("injected user script")181 log.Debug().Str("script", label).Msg("injected user script")
186 }182 }
187 183
188- // 1. Inject GTK dark mode preference for internal pages only184+ prefersDark := ci.PrefersDark()
189- darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
190- addScript(
191- webkit.NewUserScript(
192- darkModePrefScript,
193- webkit.UserContentInjectTopFrameValue,
194- webkit.UserScriptInjectAtDocumentStartValue,
195- internalPageAllowList,
196- nil,
197- ),
198- "gtk-dark-mode-pref",
199- )
200 185
201- // 2. Inject WebView ID for debugging (internal pages only)186+ // 1. Inject WebView ID for debugging (internal pages only)
202 if webviewID != 0 {187 if webviewID != 0 {
203 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))188 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
204 addScript(189 addScript(
@@ -211,10 +196,14 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
211 ),196 ),
212 "webview-id",197 "webview-id",
213 )198 )
199+ log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200+ } else {
201+ log.Warn().Msg("webview ID is 0, skipping ID injection")
214 }202 }
215 203
216- // 3. Inject dark mode handler for internal pages only204+ // 2. Inject dark mode handler for internal pages only
217 // This sets .dark/.light class on <html> and patches matchMedia for WebUI205 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
206+ internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
218 addScript(207 addScript(
219 webkit.NewUserScript(208 webkit.NewUserScript(
220 internalDarkModeScript,209 internalDarkModeScript,
@@ -226,7 +215,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
226 "internal-dark-mode-handler",215 "internal-dark-mode-handler",
227 )216 )
228 217
229- // 4. Inject theme CSS for internal pages (dumb://* only)218+ // 3. Inject theme CSS for internal pages (dumb://* only)
230 if ci.themeCSSVars != "" {219 if ci.themeCSSVars != "" {
231 // Escape for JS string literal220 // Escape for JS string literal
232 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")221 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -246,7 +235,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
246 log.Debug().Msg("theme CSS vars injection configured for internal pages")235 log.Debug().Msg("theme CSS vars injection configured for internal pages")
247 }236 }
248 237
249- // 5. Inject find highlight CSS for all pages238+ // 4. Inject find highlight CSS for all pages
250 if ci.findCSS != "" {239 if ci.findCSS != "" {
251 stylesheet := webkit.NewUserStyleSheet(240 stylesheet := webkit.NewUserStyleSheet(
252 ci.findCSS,241 ci.findCSS,
@@ -263,5 +252,5 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
263 }252 }
264 }253 }
265 254
266- log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("scripts injected")255+ log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
267 }256 }
M internal/infrastructure/webkit/messaging.go +42 -3
@@ -48,6 +48,9 @@ type MessageRouter struct {
4848 mu sync.RWMutex
4949 callbacks []interface{}
5050 signals []uint32
51+
52+ idMu sync.Mutex
53+ syncedIDs map[WebViewID]bool
5154 }
5255
5356 // NewMessageRouter creates a new message router.
@@ -59,6 +62,7 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
5962 return &MessageRouter{
6063 handlers: make(map[string]handlerEntry),
6164 baseCtx: ctx,
65+ syncedIDs: make(map[WebViewID]bool),
6266 }
6367 }
6468
@@ -131,9 +135,9 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
131135
132136 // Connect to signal BEFORE registering handler to avoid race conditions
133137 // (as recommended by WebKit documentation)
134- cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
138+ cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
135139 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136- r.handleScriptMessage(valuePtr)
140+ r.handleScriptMessage(ucm, valuePtr)
137141 }
138142
139143 r.mu.Lock()
@@ -167,7 +171,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
167171 }
168172
169173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
170-func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
174+func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
171175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
172176
173177 if valuePtr == 0 {
@@ -195,6 +199,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
195199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196200 msg.WebViewID = msg.WebViewIDAlt
197201 }
202+ if msg.WebViewID == 0 {
203+ if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204+ msg.WebViewID = uint64(senderWV.ID())
205+ r.syncWebViewID(senderWV)
206+ }
207+ }
198208
199209 if msg.Type == "" {
200210 log.Warn().Msg("script message missing type")
@@ -234,6 +244,35 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
234244 }
235245 }
236246
247+func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248+ ptr := senderUCM.GoPointer()
249+ if ptr == 0 {
250+ return nil
251+ }
252+ return LookupWebViewByUCMPointer(ptr)
253+}
254+
255+func (r *MessageRouter) syncWebViewID(wv *WebView) {
256+ if wv == nil {
257+ return
258+ }
259+ if !r.markWebViewIDSynced(wv.ID()) {
260+ return
261+ }
262+ script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263+ wv.RunJavaScript(r.baseCtx, script, "")
264+}
265+
266+func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267+ r.idMu.Lock()
268+ defer r.idMu.Unlock()
269+ if r.syncedIDs[id] {
270+ return false
271+ }
272+ r.syncedIDs[id] = true
273+ return true
274+}
275+
237276 // getHandler retrieves a handler by message type.
238277 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239278 r.mu.RLock()
@@ -48,6 +48,9 @@ type MessageRouter struct {
48 mu sync.RWMutex48 mu sync.RWMutex
49 callbacks []interface{}49 callbacks []interface{}
50 signals []uint3250 signals []uint32
51+
52+ idMu sync.Mutex
53+ syncedIDs map[WebViewID]bool
51 }54 }
52 55
53 // NewMessageRouter creates a new message router.56 // NewMessageRouter creates a new message router.
@@ -59,6 +62,7 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
59 return &MessageRouter{62 return &MessageRouter{
60 handlers: make(map[string]handlerEntry),63 handlers: make(map[string]handlerEntry),
61 baseCtx: ctx,64 baseCtx: ctx,
65+ syncedIDs: make(map[WebViewID]bool),
62 }66 }
63 }67 }
64 68
@@ -131,9 +135,9 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
131 135
132 // Connect to signal BEFORE registering handler to avoid race conditions136 // Connect to signal BEFORE registering handler to avoid race conditions
133 // (as recommended by WebKit documentation)137 // (as recommended by WebKit documentation)
134- cb := func(_ webkit.UserContentManager, valuePtr uintptr) {138+ cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
135 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")139 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136- r.handleScriptMessage(valuePtr)140+ r.handleScriptMessage(ucm, valuePtr)
137 }141 }
138 142
139 r.mu.Lock()143 r.mu.Lock()
@@ -167,7 +171,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
167 }171 }
168 172
169 // handleScriptMessage decodes the JSC value and routes it to the correct handler.173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
170-func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {174+func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
171 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
172 176
173 if valuePtr == 0 {177 if valuePtr == 0 {
@@ -195,6 +199,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
195 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196 msg.WebViewID = msg.WebViewIDAlt200 msg.WebViewID = msg.WebViewIDAlt
197 }201 }
202+ if msg.WebViewID == 0 {
203+ if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204+ msg.WebViewID = uint64(senderWV.ID())
205+ r.syncWebViewID(senderWV)
206+ }
207+ }
198 208
199 if msg.Type == "" {209 if msg.Type == "" {
200 log.Warn().Msg("script message missing type")210 log.Warn().Msg("script message missing type")
@@ -234,6 +244,35 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
234 }244 }
235 }245 }
236 246
247+func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248+ ptr := senderUCM.GoPointer()
249+ if ptr == 0 {
250+ return nil
251+ }
252+ return LookupWebViewByUCMPointer(ptr)
253+}
254+
255+func (r *MessageRouter) syncWebViewID(wv *WebView) {
256+ if wv == nil {
257+ return
258+ }
259+ if !r.markWebViewIDSynced(wv.ID()) {
260+ return
261+ }
262+ script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263+ wv.RunJavaScript(r.baseCtx, script, "")
264+}
265+
266+func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267+ r.idMu.Lock()
268+ defer r.idMu.Unlock()
269+ if r.syncedIDs[id] {
270+ return false
271+ }
272+ r.syncedIDs[id] = true
273+ return true
274+}
275+
237 // getHandler retrieves a handler by message type.276 // getHandler retrieves a handler by message type.
238 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {277 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239 r.mu.RLock()278 r.mu.RLock()
M internal/infrastructure/webkit/pool.go +51 -0
@@ -353,6 +353,57 @@ func (p *WebViewPool) Size() int {
353353 return len(p.pool)
354354 }
355355
356+// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357+// This is needed after adw.Init() to update dark mode preference that was
358+// injected with the wrong value during early prewarming.
359+func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360+ if p.closed.Load() || p.injector == nil {
361+ return
362+ }
363+
364+ log := logging.FromContext(ctx)
365+
366+ // Drain and refresh each WebView, then put it back
367+ count := len(p.pool)
368+ if count == 0 {
369+ return
370+ }
371+
372+ refreshed := 0
373+refreshLoop:
374+ for i := 0; i < count; i++ {
375+ select {
376+ case wv := <-p.pool:
377+ if wv == nil || wv.IsDestroyed() {
378+ continue
379+ }
380+
381+ // Clear existing scripts and re-inject with updated values
382+ if wv.ucm != nil {
383+ wv.ucm.RemoveAllScripts()
384+ wv.ucm.RemoveAllStyleSheets()
385+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386+ }
387+
388+ // Put back in pool
389+ select {
390+ case p.pool <- wv:
391+ refreshed++
392+ default:
393+ wv.Destroy()
394+ }
395+ default:
396+ // Pool was modified during iteration, stop
397+ break refreshLoop
398+ }
399+ }
400+
401+ log.Debug().
402+ Int("refreshed", refreshed).
403+ Bool("prefers_dark", p.injector.PrefersDark()).
404+ Msg("refreshed scripts in pooled webviews")
405+}
406+
356407 // Close shuts down the pool and destroys all pooled WebViews.
357408 func (p *WebViewPool) Close(ctx context.Context) {
358409 if p.closed.Swap(true) {
@@ -353,6 +353,57 @@ func (p *WebViewPool) Size() int {
353 return len(p.pool)353 return len(p.pool)
354 }354 }
355 355
356+// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357+// This is needed after adw.Init() to update dark mode preference that was
358+// injected with the wrong value during early prewarming.
359+func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360+ if p.closed.Load() || p.injector == nil {
361+ return
362+ }
363+
364+ log := logging.FromContext(ctx)
365+
366+ // Drain and refresh each WebView, then put it back
367+ count := len(p.pool)
368+ if count == 0 {
369+ return
370+ }
371+
372+ refreshed := 0
373+refreshLoop:
374+ for i := 0; i < count; i++ {
375+ select {
376+ case wv := <-p.pool:
377+ if wv == nil || wv.IsDestroyed() {
378+ continue
379+ }
380+
381+ // Clear existing scripts and re-inject with updated values
382+ if wv.ucm != nil {
383+ wv.ucm.RemoveAllScripts()
384+ wv.ucm.RemoveAllStyleSheets()
385+ p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386+ }
387+
388+ // Put back in pool
389+ select {
390+ case p.pool <- wv:
391+ refreshed++
392+ default:
393+ wv.Destroy()
394+ }
395+ default:
396+ // Pool was modified during iteration, stop
397+ break refreshLoop
398+ }
399+ }
400+
401+ log.Debug().
402+ Int("refreshed", refreshed).
403+ Bool("prefers_dark", p.injector.PrefersDark()).
404+ Msg("refreshed scripts in pooled webviews")
405+}
406+
356 // Close shuts down the pool and destroys all pooled WebViews.407 // Close shuts down the pool and destroys all pooled WebViews.
357 func (p *WebViewPool) Close(ctx context.Context) {408 func (p *WebViewPool) Close(ctx context.Context) {
358 if p.closed.Swap(true) {409 if p.closed.Swap(true) {
M internal/infrastructure/webkit/webview.go +27 -0
@@ -46,25 +46,34 @@ type PopupRequest struct {
4646 // webViewRegistry tracks all active WebViews.
4747 type webViewRegistry struct {
4848 views map[WebViewID]*WebView
49+ byUCM map[uintptr]WebViewID
4950 counter atomic.Uint64
5051 mu sync.RWMutex
5152 }
5253
5354 var globalRegistry = &webViewRegistry{
5455 views: make(map[WebViewID]*WebView),
56+ byUCM: make(map[uintptr]WebViewID),
5557 }
5658
5759 func (r *webViewRegistry) register(wv *WebView) WebViewID {
5860 id := WebViewID(r.counter.Add(1))
5961 r.mu.Lock()
6062 r.views[id] = wv
63+ if wv != nil && wv.ucm != nil {
64+ r.byUCM[wv.ucm.GoPointer()] = id
65+ }
6166 r.mu.Unlock()
6267 return id
6368 }
6469
6570 func (r *webViewRegistry) unregister(id WebViewID) {
6671 r.mu.Lock()
72+ wv := r.views[id]
6773 delete(r.views, id)
74+ if wv != nil && wv.ucm != nil {
75+ delete(r.byUCM, wv.ucm.GoPointer())
76+ }
6877 r.mu.Unlock()
6978 }
7079
@@ -75,11 +84,29 @@ func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
7584 return r.views[id]
7685 }
7786
87+func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
88+ if ptr == 0 {
89+ return nil
90+ }
91+ r.mu.RLock()
92+ defer r.mu.RUnlock()
93+ id, ok := r.byUCM[ptr]
94+ if !ok {
95+ return nil
96+ }
97+ return r.views[id]
98+}
99+
78100 // LookupWebView returns a WebView by ID from the global registry.
79101 func LookupWebView(id WebViewID) *WebView {
80102 return globalRegistry.Lookup(id)
81103 }
82104
105+// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
106+func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
107+ return globalRegistry.LookupByUCMPointer(ptr)
108+}
109+
83110 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.
84111 type WebView struct {
85112 id WebViewID
@@ -46,25 +46,34 @@ type PopupRequest struct {
46 // webViewRegistry tracks all active WebViews.46 // webViewRegistry tracks all active WebViews.
47 type webViewRegistry struct {47 type webViewRegistry struct {
48 views map[WebViewID]*WebView48 views map[WebViewID]*WebView
49+ byUCM map[uintptr]WebViewID
49 counter atomic.Uint6450 counter atomic.Uint64
50 mu sync.RWMutex51 mu sync.RWMutex
51 }52 }
52 53
53 var globalRegistry = &webViewRegistry{54 var globalRegistry = &webViewRegistry{
54 views: make(map[WebViewID]*WebView),55 views: make(map[WebViewID]*WebView),
56+ byUCM: make(map[uintptr]WebViewID),
55 }57 }
56 58
57 func (r *webViewRegistry) register(wv *WebView) WebViewID {59 func (r *webViewRegistry) register(wv *WebView) WebViewID {
58 id := WebViewID(r.counter.Add(1))60 id := WebViewID(r.counter.Add(1))
59 r.mu.Lock()61 r.mu.Lock()
60 r.views[id] = wv62 r.views[id] = wv
63+ if wv != nil && wv.ucm != nil {
64+ r.byUCM[wv.ucm.GoPointer()] = id
65+ }
61 r.mu.Unlock()66 r.mu.Unlock()
62 return id67 return id
63 }68 }
64 69
65 func (r *webViewRegistry) unregister(id WebViewID) {70 func (r *webViewRegistry) unregister(id WebViewID) {
66 r.mu.Lock()71 r.mu.Lock()
72+ wv := r.views[id]
67 delete(r.views, id)73 delete(r.views, id)
74+ if wv != nil && wv.ucm != nil {
75+ delete(r.byUCM, wv.ucm.GoPointer())
76+ }
68 r.mu.Unlock()77 r.mu.Unlock()
69 }78 }
70 79
@@ -75,11 +84,29 @@ func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
75 return r.views[id]84 return r.views[id]
76 }85 }
77 86
87+func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
88+ if ptr == 0 {
89+ return nil
90+ }
91+ r.mu.RLock()
92+ defer r.mu.RUnlock()
93+ id, ok := r.byUCM[ptr]
94+ if !ok {
95+ return nil
96+ }
97+ return r.views[id]
98+}
99+
78 // LookupWebView returns a WebView by ID from the global registry.100 // LookupWebView returns a WebView by ID from the global registry.
79 func LookupWebView(id WebViewID) *WebView {101 func LookupWebView(id WebViewID) *WebView {
80 return globalRegistry.Lookup(id)102 return globalRegistry.Lookup(id)
81 }103 }
82 104
105+// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
106+func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
107+ return globalRegistry.LookupByUCMPointer(ptr)
108+}
109+
83 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.110 // WebView wraps webkit.WebView with Go-level state tracking and callbacks.
84 type WebView struct {111 type WebView struct {
85 id WebViewID112 id WebViewID
M internal/ui/app.go +28 -3
@@ -160,6 +160,19 @@ func (a *App) Run(ctx context.Context, args []string) int {
160160 // This also initializes GTK implicitly.
161161 adw.Init()
162162
163+ // Mark adwaita detector as available now that adw.Init() is complete.
164+ // This enables the highest-priority color scheme detector.
165+ if a.deps != nil && a.deps.AdwaitaDetector != nil {
166+ a.deps.AdwaitaDetector.MarkAvailable()
167+ log.Debug().Msg("adwaita detector marked available")
168+
169+ // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
170+ // They have the wrong dark mode preference injected; re-inject with correct value.
171+ if a.pool != nil {
172+ a.pool.RefreshScripts(ctx)
173+ }
174+ }
175+
163176 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
164177 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
165178 if a.gtkApp == nil {
@@ -249,11 +262,24 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
249262 }
250263
251264 styleMgr.SetColorScheme(adwScheme)
252- prefersDark := styleMgr.GetDark()
265+
266+ // Refresh the color resolver now that adwaita detector is available.
267+ // This ensures all components get the correct preference.
268+ var pref string
269+ var prefersDark bool
270+ if a.deps != nil && a.deps.ColorResolver != nil {
271+ result := a.deps.ColorResolver.Refresh()
272+ prefersDark = result.PrefersDark
273+ pref = result.Source
274+ } else {
275+ prefersDark = styleMgr.GetDark()
276+ pref = "adw.StyleManager"
277+ }
253278
254279 log.Debug().
255280 Str("scheme", scheme).
256281 Bool("prefers_dark", prefersDark).
282+ Str("source", pref).
257283 Msg("applied color scheme via adw.StyleManager")
258284 }
259285
@@ -1732,9 +1758,8 @@ func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
17321758 }
17331759 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
17341760
1735- // Keep injector's dark-mode flag in sync for future navigations
1761+ // Update find highlight CSS for future navigations
17361762 if a.injector != nil {
1737- a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
17381763 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
17391764 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
17401765 log.Warn().Err(err).Msg("failed to update find highlight CSS")
@@ -160,6 +160,19 @@ func (a *App) Run(ctx context.Context, args []string) int {
160 // This also initializes GTK implicitly.160 // This also initializes GTK implicitly.
161 adw.Init()161 adw.Init()
162 162
163+ // Mark adwaita detector as available now that adw.Init() is complete.
164+ // This enables the highest-priority color scheme detector.
165+ if a.deps != nil && a.deps.AdwaitaDetector != nil {
166+ a.deps.AdwaitaDetector.MarkAvailable()
167+ log.Debug().Msg("adwaita detector marked available")
168+
169+ // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
170+ // They have the wrong dark mode preference injected; re-inject with correct value.
171+ if a.pool != nil {
172+ a.pool.RefreshScripts(ctx)
173+ }
174+ }
175+
163 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)176 // TODO: Use AppID once puregotk GC bug is fixed (nullable-string-gc-memory-corruption)
164 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)177 a.gtkApp = gtk.NewApplication(nil, gio.GApplicationFlagsNoneValue)
165 if a.gtkApp == nil {178 if a.gtkApp == nil {
@@ -249,11 +262,24 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
249 }262 }
250 263
251 styleMgr.SetColorScheme(adwScheme)264 styleMgr.SetColorScheme(adwScheme)
252- prefersDark := styleMgr.GetDark()265+
266+ // Refresh the color resolver now that adwaita detector is available.
267+ // This ensures all components get the correct preference.
268+ var pref string
269+ var prefersDark bool
270+ if a.deps != nil && a.deps.ColorResolver != nil {
271+ result := a.deps.ColorResolver.Refresh()
272+ prefersDark = result.PrefersDark
273+ pref = result.Source
274+ } else {
275+ prefersDark = styleMgr.GetDark()
276+ pref = "adw.StyleManager"
277+ }
253 278
254 log.Debug().279 log.Debug().
255 Str("scheme", scheme).280 Str("scheme", scheme).
256 Bool("prefers_dark", prefersDark).281 Bool("prefers_dark", prefersDark).
282+ Str("source", pref).
257 Msg("applied color scheme via adw.StyleManager")283 Msg("applied color scheme via adw.StyleManager")
258 }284 }
259 285
@@ -1732,9 +1758,8 @@ func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
1732 }1758 }
1733 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)1759 a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
1734 1760
1735- // Keep injector's dark-mode flag in sync for future navigations1761+ // Update find highlight CSS for future navigations
1736 if a.injector != nil {1762 if a.injector != nil {
1737- a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
1738 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())1763 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
1739 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {1764 if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
1740 log.Warn().Err(err).Msg("failed to update find highlight CSS")1765 log.Warn().Err(err).Msg("failed to update find highlight CSS")
M internal/ui/coordinator/content.go +259 -22
@@ -21,12 +21,15 @@ import (
2121 "github.com/jwijenbergh/puregotk/v4/gdk"
2222 )
2323
24+const aboutBlankURI = "about:blank"
25+
2426 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
2527 type ContentCoordinator struct {
2628 pool *webkit.WebViewPool
2729 widgetFactory layout.WidgetFactory
2830 faviconAdapter *adapter.FaviconAdapter
2931 zoomUC *usecase.ManageZoomUseCase
32+ injector *webkit.ContentInjector
3033
3134 webViews map[entity.PaneID]*webkit.WebView
3235 paneTitles map[entity.PaneID]string
@@ -58,6 +61,14 @@ type ContentCoordinator struct {
5861 revealMu sync.Mutex
5962 pendingReveal map[entity.PaneID]bool
6063
64+ appearanceMu sync.Mutex
65+ pendingScriptRefresh map[entity.PaneID]bool
66+ pendingThemePanes map[entity.PaneID]bool
67+ pendingThemeUpdate pendingThemeUpdate
68+ hasPendingThemeUpdate bool
69+ currentTheme pendingThemeUpdate
70+ hasCurrentTheme bool
71+
6172 // Gesture action handler for mouse button navigation
6273 gestureActionHandler input.ActionHandler
6374
@@ -83,6 +94,11 @@ type ContentCoordinator struct {
8394 onFullscreenChanged func(entering bool)
8495 }
8596
97+type pendingThemeUpdate struct {
98+ prefersDark bool
99+ cssText string
100+}
101+
86102 // NewContentCoordinator creates a new ContentCoordinator.
87103 func NewContentCoordinator(
88104 ctx context.Context,
@@ -186,7 +202,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
186202 case webkit.LoadCommitted:
187203 c.onLoadCommitted(ctx, paneID, wv)
188204 case webkit.LoadFinished:
189- c.onLoadFinished(paneID)
205+ c.onLoadFinished(ctx, paneID, wv)
190206 }
191207 }
192208
@@ -234,6 +250,7 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
234250 return
235251 }
236252 delete(c.webViews, paneID)
253+ c.clearPendingAppearance(paneID)
237254
238255 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
239256 // we must release the inhibition before destroying the webview.
@@ -403,24 +420,26 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.
403420 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
404421 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
405422 // the scripts so future navigations pick up the latest values.
423+// Script refresh is deferred for any WebView that is currently loading to avoid
424+// removing scripts mid-navigation.
406425 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
407426 log := logging.FromContext(ctx)
408427 if injector == nil {
409428 return
410429 }
411430
431+ c.injector = injector
412432 for paneID, wv := range c.webViews {
413433 if wv == nil || wv.IsDestroyed() {
414434 continue
415435 }
416- ucm := wv.UserContentManager()
417- if ucm == nil {
436+ if c.shouldDeferAppearance(wv) {
437+ c.queueScriptRefresh(paneID)
438+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
418439 continue
419440 }
420441
421- ucm.RemoveAllScripts()
422- injector.InjectScripts(ctx, ucm, wv.ID())
423- log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
442+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
424443 }
425444 }
426445
@@ -432,13 +451,33 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
432451 return
433452 }
434453
435- // Safely embed CSS in JS
436- cssJSON, err := json.Marshal(cssText)
454+ c.setCurrentTheme(prefersDark, cssText)
455+
456+ script, err := buildWebUIThemeScript(prefersDark, cssText)
437457 if err != nil {
438- log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
458+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
439459 return
440460 }
441461
462+ for paneID, wv := range c.webViews {
463+ if wv == nil || wv.IsDestroyed() {
464+ continue
465+ }
466+ if c.shouldDeferAppearance(wv) {
467+ c.queueThemeApply(paneID, prefersDark, cssText)
468+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
469+ continue
470+ }
471+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
472+ }
473+}
474+
475+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
476+ cssJSON, err := json.Marshal(cssText)
477+ if err != nil {
478+ return "", err
479+ }
480+
442481 script := fmt.Sprintf(`(function(){
443482 try {
444483 var cssText = %s;
@@ -487,17 +526,185 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
487526 }
488527 })();`, string(cssJSON), prefersDark)
489528
490- for paneID, wv := range c.webViews {
491- if wv == nil || wv.IsDestroyed() {
492- continue
493- }
494- uri := wv.URI()
495- if !strings.HasPrefix(uri, "dumb://") {
496- continue
529+ return script, nil
530+}
531+
532+func (c *ContentCoordinator) applyWebUITheme(
533+ ctx context.Context,
534+ paneID entity.PaneID,
535+ wv *webkit.WebView,
536+ script string,
537+ prefersDark bool,
538+) {
539+ if wv == nil || wv.IsDestroyed() {
540+ return
541+ }
542+ uri := wv.URI()
543+ if !strings.HasPrefix(uri, "dumb://") {
544+ return
545+ }
546+ wv.RunJavaScript(ctx, script, "")
547+ logging.FromContext(ctx).
548+ Debug().
549+ Str("pane_id", string(paneID)).
550+ Str("uri", uri).
551+ Bool("prefers_dark", prefersDark).
552+ Msg("applied WebUI theme")
553+}
554+
555+func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
556+ c.appearanceMu.Lock()
557+ if c.pendingThemePanes == nil {
558+ c.pendingThemePanes = make(map[entity.PaneID]bool)
559+ }
560+ c.pendingThemePanes[paneID] = true
561+ c.pendingThemeUpdate = pendingThemeUpdate{
562+ prefersDark: prefersDark,
563+ cssText: cssText,
564+ }
565+ c.hasPendingThemeUpdate = true
566+ c.appearanceMu.Unlock()
567+}
568+
569+func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
570+ c.appearanceMu.Lock()
571+ c.currentTheme = pendingThemeUpdate{
572+ prefersDark: prefersDark,
573+ cssText: cssText,
574+ }
575+ c.hasCurrentTheme = true
576+ c.appearanceMu.Unlock()
577+}
578+
579+func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
580+ c.appearanceMu.Lock()
581+ defer c.appearanceMu.Unlock()
582+
583+ if !c.hasCurrentTheme {
584+ return pendingThemeUpdate{}, false
585+ }
586+ return c.currentTheme, true
587+}
588+
589+func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
590+ c.appearanceMu.Lock()
591+ defer c.appearanceMu.Unlock()
592+
593+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
594+ return pendingThemeUpdate{}, false
595+ }
596+ delete(c.pendingThemePanes, paneID)
597+ update := c.pendingThemeUpdate
598+ if len(c.pendingThemePanes) == 0 {
599+ c.hasPendingThemeUpdate = false
600+ }
601+ return update, true
602+}
603+
604+func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
605+ update, ok := c.takePendingThemeApply(paneID)
606+ if !ok {
607+ return false
608+ }
609+
610+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
611+ if err != nil {
612+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
613+ return false
614+ }
615+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
616+ return true
617+}
618+
619+func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
620+ update, ok := c.getCurrentTheme()
621+ if !ok || update.cssText == "" {
622+ return false
623+ }
624+
625+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
626+ if err != nil {
627+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
628+ return false
629+ }
630+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
631+ return true
632+}
633+
634+func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
635+ c.appearanceMu.Lock()
636+ if c.pendingScriptRefresh == nil {
637+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
638+ }
639+ c.pendingScriptRefresh[paneID] = true
640+ c.appearanceMu.Unlock()
641+}
642+
643+func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
644+ c.appearanceMu.Lock()
645+ defer c.appearanceMu.Unlock()
646+
647+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
648+ return false
649+ }
650+ delete(c.pendingScriptRefresh, paneID)
651+ return true
652+}
653+
654+func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
655+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
656+ return
657+ }
658+ if !c.takePendingScriptRefresh(paneID) {
659+ return
660+ }
661+ if c.injector == nil {
662+ return
663+ }
664+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
665+}
666+
667+func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
668+ if wv == nil || wv.IsDestroyed() {
669+ return false
670+ }
671+ if wv.IsLoading() {
672+ return true
673+ }
674+ return wv.EstimatedProgress() < 1.0
675+}
676+
677+func (c *ContentCoordinator) refreshInjectedScripts(
678+ ctx context.Context,
679+ injector *webkit.ContentInjector,
680+ paneID entity.PaneID,
681+ wv *webkit.WebView,
682+) {
683+ if injector == nil || wv == nil || wv.IsDestroyed() {
684+ return
685+ }
686+ ucm := wv.UserContentManager()
687+ if ucm == nil {
688+ return
689+ }
690+ ucm.RemoveAllScripts()
691+ ucm.RemoveAllStyleSheets()
692+ injector.InjectScripts(ctx, ucm, wv.ID())
693+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
694+}
695+
696+func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
697+ c.appearanceMu.Lock()
698+ if c.pendingScriptRefresh != nil {
699+ delete(c.pendingScriptRefresh, paneID)
700+ }
701+ if c.pendingThemePanes != nil {
702+ delete(c.pendingThemePanes, paneID)
703+ if len(c.pendingThemePanes) == 0 {
704+ c.hasPendingThemeUpdate = false
497705 }
498- wv.RunJavaScript(ctx, script, "")
499- log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
500706 }
707+ c.appearanceMu.Unlock()
501708 }
502709
503710 // GetTitle returns the current title for a pane.
@@ -704,13 +911,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
704911 // Skip showing if this is about:blank but the pane is loading a different URL
705912 // This prevents the brief flash of about:blank during initial navigation
706913 shouldShow := true
707- if url == "about:blank" {
914+ if url == aboutBlankURI {
708915 // Get the pane's intended URI from the workspace
709916 ws, _ := c.getActiveWS()
710917 if ws != nil {
711918 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712919 // Don't show about:blank if the pane is supposed to load a different URL
713- if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {
920+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
714921 shouldShow = false
715922 log.Debug().
716923 Str("pane_id", string(paneID)).
@@ -729,6 +936,10 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
729936 return
730937 }
731938
939+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
940+ c.applyCurrentTheme(ctx, paneID, wv)
941+ }
942+
732943 c.markPendingReveal(paneID)
733944 if wv.EstimatedProgress() > 0 {
734945 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
@@ -755,6 +966,27 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
755966 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
756967 }
757968
969+func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
970+ if wv == nil || wv.IsDestroyed() {
971+ return false
972+ }
973+ if wv.URI() != aboutBlankURI {
974+ return false
975+ }
976+ ws, _ := c.getActiveWS()
977+ if ws == nil {
978+ return false
979+ }
980+ paneNode := ws.FindPane(paneID)
981+ if paneNode == nil || paneNode.Pane == nil {
982+ return false
983+ }
984+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
985+ return true
986+ }
987+ return false
988+}
989+
758990 // onSPANavigation records history when URL changes via JavaScript (History API).
759991 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
760992 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
@@ -792,7 +1024,7 @@ func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
7921024 }
7931025
7941026 // onLoadFinished hides the progress bar when page loading completes.
795-func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
1027+func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
7961028 _, wsView := c.getActiveWS()
7971029 if wsView == nil {
7981030 return
@@ -804,6 +1036,11 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
8041036 }
8051037
8061038 c.revealIfPending(context.Background(), paneID, "", "load-finished")
1039+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1040+ return
1041+ }
1042+ c.applyPendingThemeUpdate(ctx, paneID, wv)
1043+ c.refreshPendingScripts(ctx, paneID, wv)
8071044 }
8081045
8091046 // onProgressChanged updates the progress bar with current load progress.
@@ -1150,7 +1387,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
11501387 case webkit.LoadCommitted:
11511388 c.onLoadCommitted(ctx, paneID, wv)
11521389 case webkit.LoadFinished:
1153- c.onLoadFinished(paneID)
1390+ c.onLoadFinished(ctx, paneID, wv)
11541391 }
11551392 }
11561393
@@ -21,12 +21,15 @@ import (
21 "github.com/jwijenbergh/puregotk/v4/gdk"21 "github.com/jwijenbergh/puregotk/v4/gdk"
22 )22 )
23 23
24+const aboutBlankURI = "about:blank"
25+
24 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.26 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
25 type ContentCoordinator struct {27 type ContentCoordinator struct {
26 pool *webkit.WebViewPool28 pool *webkit.WebViewPool
27 widgetFactory layout.WidgetFactory29 widgetFactory layout.WidgetFactory
28 faviconAdapter *adapter.FaviconAdapter30 faviconAdapter *adapter.FaviconAdapter
29 zoomUC *usecase.ManageZoomUseCase31 zoomUC *usecase.ManageZoomUseCase
32+ injector *webkit.ContentInjector
30 33
31 webViews map[entity.PaneID]*webkit.WebView34 webViews map[entity.PaneID]*webkit.WebView
32 paneTitles map[entity.PaneID]string35 paneTitles map[entity.PaneID]string
@@ -58,6 +61,14 @@ type ContentCoordinator struct {
58 revealMu sync.Mutex61 revealMu sync.Mutex
59 pendingReveal map[entity.PaneID]bool62 pendingReveal map[entity.PaneID]bool
60 63
64+ appearanceMu sync.Mutex
65+ pendingScriptRefresh map[entity.PaneID]bool
66+ pendingThemePanes map[entity.PaneID]bool
67+ pendingThemeUpdate pendingThemeUpdate
68+ hasPendingThemeUpdate bool
69+ currentTheme pendingThemeUpdate
70+ hasCurrentTheme bool
71+
61 // Gesture action handler for mouse button navigation72 // Gesture action handler for mouse button navigation
62 gestureActionHandler input.ActionHandler73 gestureActionHandler input.ActionHandler
63 74
@@ -83,6 +94,11 @@ type ContentCoordinator struct {
83 onFullscreenChanged func(entering bool)94 onFullscreenChanged func(entering bool)
84 }95 }
85 96
97+type pendingThemeUpdate struct {
98+ prefersDark bool
99+ cssText string
100+}
101+
86 // NewContentCoordinator creates a new ContentCoordinator.102 // NewContentCoordinator creates a new ContentCoordinator.
87 func NewContentCoordinator(103 func NewContentCoordinator(
88 ctx context.Context,104 ctx context.Context,
@@ -186,7 +202,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
186 case webkit.LoadCommitted:202 case webkit.LoadCommitted:
187 c.onLoadCommitted(ctx, paneID, wv)203 c.onLoadCommitted(ctx, paneID, wv)
188 case webkit.LoadFinished:204 case webkit.LoadFinished:
189- c.onLoadFinished(paneID)205+ c.onLoadFinished(ctx, paneID, wv)
190 }206 }
191 }207 }
192 208
@@ -234,6 +250,7 @@ func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.P
234 return250 return
235 }251 }
236 delete(c.webViews, paneID)252 delete(c.webViews, paneID)
253+ c.clearPendingAppearance(paneID)
237 254
238 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),255 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
239 // we must release the inhibition before destroying the webview.256 // we must release the inhibition before destroying the webview.
@@ -403,24 +420,26 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.
403 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when420 // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
404 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh421 // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
405 // the scripts so future navigations pick up the latest values.422 // the scripts so future navigations pick up the latest values.
423+// Script refresh is deferred for any WebView that is currently loading to avoid
424+// removing scripts mid-navigation.
406 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {425 func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
407 log := logging.FromContext(ctx)426 log := logging.FromContext(ctx)
408 if injector == nil {427 if injector == nil {
409 return428 return
410 }429 }
411 430
431+ c.injector = injector
412 for paneID, wv := range c.webViews {432 for paneID, wv := range c.webViews {
413 if wv == nil || wv.IsDestroyed() {433 if wv == nil || wv.IsDestroyed() {
414 continue434 continue
415 }435 }
416- ucm := wv.UserContentManager()436+ if c.shouldDeferAppearance(wv) {
417- if ucm == nil {437+ c.queueScriptRefresh(paneID)
438+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
418 continue439 continue
419 }440 }
420 441
421- ucm.RemoveAllScripts()442+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
422- injector.InjectScripts(ctx, ucm, wv.ID())
423- log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
424 }443 }
425 }444 }
426 445
@@ -432,13 +451,33 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
432 return451 return
433 }452 }
434 453
435- // Safely embed CSS in JS454+ c.setCurrentTheme(prefersDark, cssText)
436- cssJSON, err := json.Marshal(cssText)455+
456+ script, err := buildWebUIThemeScript(prefersDark, cssText)
437 if err != nil {457 if err != nil {
438- log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")458+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
439 return459 return
440 }460 }
441 461
462+ for paneID, wv := range c.webViews {
463+ if wv == nil || wv.IsDestroyed() {
464+ continue
465+ }
466+ if c.shouldDeferAppearance(wv) {
467+ c.queueThemeApply(paneID, prefersDark, cssText)
468+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
469+ continue
470+ }
471+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
472+ }
473+}
474+
475+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
476+ cssJSON, err := json.Marshal(cssText)
477+ if err != nil {
478+ return "", err
479+ }
480+
442 script := fmt.Sprintf(`(function(){481 script := fmt.Sprintf(`(function(){
443 try {482 try {
444 var cssText = %s;483 var cssText = %s;
@@ -487,17 +526,185 @@ func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDa
487 }526 }
488 })();`, string(cssJSON), prefersDark)527 })();`, string(cssJSON), prefersDark)
489 528
490- for paneID, wv := range c.webViews {529+ return script, nil
491- if wv == nil || wv.IsDestroyed() {530+}
492- continue531+
493- }532+func (c *ContentCoordinator) applyWebUITheme(
494- uri := wv.URI()533+ ctx context.Context,
495- if !strings.HasPrefix(uri, "dumb://") {534+ paneID entity.PaneID,
496- continue535+ wv *webkit.WebView,
536+ script string,
537+ prefersDark bool,
538+) {
539+ if wv == nil || wv.IsDestroyed() {
540+ return
541+ }
542+ uri := wv.URI()
543+ if !strings.HasPrefix(uri, "dumb://") {
544+ return
545+ }
546+ wv.RunJavaScript(ctx, script, "")
547+ logging.FromContext(ctx).
548+ Debug().
549+ Str("pane_id", string(paneID)).
550+ Str("uri", uri).
551+ Bool("prefers_dark", prefersDark).
552+ Msg("applied WebUI theme")
553+}
554+
555+func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
556+ c.appearanceMu.Lock()
557+ if c.pendingThemePanes == nil {
558+ c.pendingThemePanes = make(map[entity.PaneID]bool)
559+ }
560+ c.pendingThemePanes[paneID] = true
561+ c.pendingThemeUpdate = pendingThemeUpdate{
562+ prefersDark: prefersDark,
563+ cssText: cssText,
564+ }
565+ c.hasPendingThemeUpdate = true
566+ c.appearanceMu.Unlock()
567+}
568+
569+func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
570+ c.appearanceMu.Lock()
571+ c.currentTheme = pendingThemeUpdate{
572+ prefersDark: prefersDark,
573+ cssText: cssText,
574+ }
575+ c.hasCurrentTheme = true
576+ c.appearanceMu.Unlock()
577+}
578+
579+func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
580+ c.appearanceMu.Lock()
581+ defer c.appearanceMu.Unlock()
582+
583+ if !c.hasCurrentTheme {
584+ return pendingThemeUpdate{}, false
585+ }
586+ return c.currentTheme, true
587+}
588+
589+func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
590+ c.appearanceMu.Lock()
591+ defer c.appearanceMu.Unlock()
592+
593+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
594+ return pendingThemeUpdate{}, false
595+ }
596+ delete(c.pendingThemePanes, paneID)
597+ update := c.pendingThemeUpdate
598+ if len(c.pendingThemePanes) == 0 {
599+ c.hasPendingThemeUpdate = false
600+ }
601+ return update, true
602+}
603+
604+func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
605+ update, ok := c.takePendingThemeApply(paneID)
606+ if !ok {
607+ return false
608+ }
609+
610+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
611+ if err != nil {
612+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
613+ return false
614+ }
615+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
616+ return true
617+}
618+
619+func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
620+ update, ok := c.getCurrentTheme()
621+ if !ok || update.cssText == "" {
622+ return false
623+ }
624+
625+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
626+ if err != nil {
627+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
628+ return false
629+ }
630+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
631+ return true
632+}
633+
634+func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
635+ c.appearanceMu.Lock()
636+ if c.pendingScriptRefresh == nil {
637+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
638+ }
639+ c.pendingScriptRefresh[paneID] = true
640+ c.appearanceMu.Unlock()
641+}
642+
643+func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
644+ c.appearanceMu.Lock()
645+ defer c.appearanceMu.Unlock()
646+
647+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
648+ return false
649+ }
650+ delete(c.pendingScriptRefresh, paneID)
651+ return true
652+}
653+
654+func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
655+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
656+ return
657+ }
658+ if !c.takePendingScriptRefresh(paneID) {
659+ return
660+ }
661+ if c.injector == nil {
662+ return
663+ }
664+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
665+}
666+
667+func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
668+ if wv == nil || wv.IsDestroyed() {
669+ return false
670+ }
671+ if wv.IsLoading() {
672+ return true
673+ }
674+ return wv.EstimatedProgress() < 1.0
675+}
676+
677+func (c *ContentCoordinator) refreshInjectedScripts(
678+ ctx context.Context,
679+ injector *webkit.ContentInjector,
680+ paneID entity.PaneID,
681+ wv *webkit.WebView,
682+) {
683+ if injector == nil || wv == nil || wv.IsDestroyed() {
684+ return
685+ }
686+ ucm := wv.UserContentManager()
687+ if ucm == nil {
688+ return
689+ }
690+ ucm.RemoveAllScripts()
691+ ucm.RemoveAllStyleSheets()
692+ injector.InjectScripts(ctx, ucm, wv.ID())
693+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
694+}
695+
696+func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
697+ c.appearanceMu.Lock()
698+ if c.pendingScriptRefresh != nil {
699+ delete(c.pendingScriptRefresh, paneID)
700+ }
701+ if c.pendingThemePanes != nil {
702+ delete(c.pendingThemePanes, paneID)
703+ if len(c.pendingThemePanes) == 0 {
704+ c.hasPendingThemeUpdate = false
497 }705 }
498- wv.RunJavaScript(ctx, script, "")
499- log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
500 }706 }
707+ c.appearanceMu.Unlock()
501 }708 }
502 709
503 // GetTitle returns the current title for a pane.710 // GetTitle returns the current title for a pane.
@@ -704,13 +911,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
704 // Skip showing if this is about:blank but the pane is loading a different URL911 // Skip showing if this is about:blank but the pane is loading a different URL
705 // This prevents the brief flash of about:blank during initial navigation912 // This prevents the brief flash of about:blank during initial navigation
706 shouldShow := true913 shouldShow := true
707- if url == "about:blank" {914+ if url == aboutBlankURI {
708 // Get the pane's intended URI from the workspace915 // Get the pane's intended URI from the workspace
709 ws, _ := c.getActiveWS()916 ws, _ := c.getActiveWS()
710 if ws != nil {917 if ws != nil {
711 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {918 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712 // Don't show about:blank if the pane is supposed to load a different URL919 // Don't show about:blank if the pane is supposed to load a different URL
713- if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {920+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
714 shouldShow = false921 shouldShow = false
715 log.Debug().922 log.Debug().
716 Str("pane_id", string(paneID)).923 Str("pane_id", string(paneID)).
@@ -729,6 +936,10 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
729 return936 return
730 }937 }
731 938
939+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
940+ c.applyCurrentTheme(ctx, paneID, wv)
941+ }
942+
732 c.markPendingReveal(paneID)943 c.markPendingReveal(paneID)
733 if wv.EstimatedProgress() > 0 {944 if wv.EstimatedProgress() > 0 {
734 c.revealIfPending(ctx, paneID, url, "progress-after-commit")945 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
@@ -755,6 +966,27 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
755 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)966 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
756 }967 }
757 968
969+func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
970+ if wv == nil || wv.IsDestroyed() {
971+ return false
972+ }
973+ if wv.URI() != aboutBlankURI {
974+ return false
975+ }
976+ ws, _ := c.getActiveWS()
977+ if ws == nil {
978+ return false
979+ }
980+ paneNode := ws.FindPane(paneID)
981+ if paneNode == nil || paneNode.Pane == nil {
982+ return false
983+ }
984+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
985+ return true
986+ }
987+ return false
988+}
989+
758 // onSPANavigation records history when URL changes via JavaScript (History API).990 // onSPANavigation records history when URL changes via JavaScript (History API).
759 // This handles SPA navigation like YouTube search, where the URL changes without a page load.991 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
760 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {992 func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
@@ -792,7 +1024,7 @@ func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
792 }1024 }
793 1025
794 // onLoadFinished hides the progress bar when page loading completes.1026 // onLoadFinished hides the progress bar when page loading completes.
795-func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {1027+func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
796 _, wsView := c.getActiveWS()1028 _, wsView := c.getActiveWS()
797 if wsView == nil {1029 if wsView == nil {
798 return1030 return
@@ -804,6 +1036,11 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
804 }1036 }
805 1037
806 c.revealIfPending(context.Background(), paneID, "", "load-finished")1038 c.revealIfPending(context.Background(), paneID, "", "load-finished")
1039+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1040+ return
1041+ }
1042+ c.applyPendingThemeUpdate(ctx, paneID, wv)
1043+ c.refreshPendingScripts(ctx, paneID, wv)
807 }1044 }
808 1045
809 // onProgressChanged updates the progress bar with current load progress.1046 // onProgressChanged updates the progress bar with current load progress.
@@ -1150,7 +1387,7 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1150 case webkit.LoadCommitted:1387 case webkit.LoadCommitted:
1151 c.onLoadCommitted(ctx, paneID, wv)1388 c.onLoadCommitted(ctx, paneID, wv)
1152 case webkit.LoadFinished:1389 case webkit.LoadFinished:
1153- c.onLoadFinished(paneID)1390+ c.onLoadFinished(ctx, paneID, wv)
1154 }1391 }
1155 }1392 }
1156 1393
M internal/ui/deps.go +5 -2
@@ -8,6 +8,7 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1112 "github.com/bnema/dumber/internal/infrastructure/config"
1213 "github.com/bnema/dumber/internal/infrastructure/favicon"
1314 "github.com/bnema/dumber/internal/infrastructure/filtering"
@@ -25,8 +26,10 @@ type Dependencies struct {
2526 RestoreSessionID string // Session ID to restore on startup (optional)
2627 OnFirstWebViewShown func(context.Context)
2728
28- // Theme management
29- Theme *theme.Manager
29+ // Theme and color scheme management
30+ Theme *theme.Manager
31+ ColorResolver port.ColorSchemeResolver
32+ AdwaitaDetector *colorscheme.AdwaitaDetector
3033
3134 // WebKit infrastructure
3235 WebContext *webkit.WebKitContext
@@ -8,6 +8,7 @@ import (
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/domain/repository"10 "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
11 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/bnema/dumber/internal/infrastructure/favicon"13 "github.com/bnema/dumber/internal/infrastructure/favicon"
13 "github.com/bnema/dumber/internal/infrastructure/filtering"14 "github.com/bnema/dumber/internal/infrastructure/filtering"
@@ -25,8 +26,10 @@ type Dependencies struct {
25 RestoreSessionID string // Session ID to restore on startup (optional)26 RestoreSessionID string // Session ID to restore on startup (optional)
26 OnFirstWebViewShown func(context.Context)27 OnFirstWebViewShown func(context.Context)
27 28
28- // Theme management29+ // Theme and color scheme management
29- Theme *theme.Manager30+ Theme *theme.Manager
31+ ColorResolver port.ColorSchemeResolver
32+ AdwaitaDetector *colorscheme.AdwaitaDetector
30 33
31 // WebKit infrastructure34 // WebKit infrastructure
32 WebContext *webkit.WebKitContext35 WebContext *webkit.WebKitContext
M internal/ui/theme/manager.go +29 -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,20 @@ 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
2325 }
2426
2527 // NewManager creates a new theme manager from configuration.
26-func NewManager(ctx context.Context, cfg *config.Config) *Manager {
28+// The ColorSchemeResolver is required for proper color scheme detection.
29+func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
2730 log := logging.FromContext(ctx)
2831
2932 // Determine color scheme preference
@@ -32,8 +35,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
3235 scheme = cfg.Appearance.ColorScheme
3336 }
3437
35- // Resolve whether we should use dark mode
36- prefersDark := ResolveColorScheme(scheme)
38+ // Resolve whether we should use dark mode via resolver
39+ pref := resolver.Resolve()
40+ prefersDark := pref.PrefersDark
3741
3842 // Build palettes from config or defaults
3943 var lightPalette, darkPalette Palette
@@ -69,13 +73,14 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
6973 Msg("theme manager initialized")
7074
7175 return &Manager{
72- scheme: scheme,
73- prefersDark: prefersDark,
74- lightPalette: lightPalette,
75- darkPalette: darkPalette,
76- uiScale: uiScale,
77- fonts: fonts,
78- modeColors: modeColors,
76+ scheme: scheme,
77+ prefersDark: prefersDark,
78+ lightPalette: lightPalette,
79+ darkPalette: darkPalette,
80+ uiScale: uiScale,
81+ fonts: fonts,
82+ modeColors: modeColors,
83+ colorResolver: resolver,
7984 }
8085 }
8186
@@ -178,11 +183,13 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
178183 log := logging.FromContext(ctx)
179184
180185 m.scheme = scheme
181- m.prefersDark = ResolveColorScheme(scheme)
186+ pref := m.colorResolver.Refresh()
187+ m.prefersDark = pref.PrefersDark
182188
183189 log.Info().
184190 Str("scheme", scheme).
185191 Bool("prefers_dark", m.prefersDark).
192+ Str("source", pref.Source).
186193 Msg("color scheme changed")
187194
188195 // Re-apply CSS if display is available
@@ -199,13 +206,14 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
199206 return
200207 }
201208
202- // Update scheme and resolve prefersDark
209+ // Update scheme and resolve prefersDark via resolver
203210 scheme := "system"
204211 if cfg.Appearance.ColorScheme != "" {
205212 scheme = cfg.Appearance.ColorScheme
206213 }
207214 m.scheme = scheme
208- m.prefersDark = ResolveColorScheme(scheme)
215+ pref := m.colorResolver.Refresh()
216+ m.prefersDark = pref.PrefersDark
209217
210218 // Update palettes
211219 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,20 @@ 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
23 }25 }
24 26
25 // NewManager creates a new theme manager from configuration.27 // NewManager creates a new theme manager from configuration.
26-func NewManager(ctx context.Context, cfg *config.Config) *Manager {28+// The ColorSchemeResolver is required for proper color scheme detection.
29+func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
27 log := logging.FromContext(ctx)30 log := logging.FromContext(ctx)
28 31
29 // Determine color scheme preference32 // Determine color scheme preference
@@ -32,8 +35,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
32 scheme = cfg.Appearance.ColorScheme35 scheme = cfg.Appearance.ColorScheme
33 }36 }
34 37
35- // Resolve whether we should use dark mode38+ // Resolve whether we should use dark mode via resolver
36- prefersDark := ResolveColorScheme(scheme)39+ pref := resolver.Resolve()
40+ prefersDark := pref.PrefersDark
37 41
38 // Build palettes from config or defaults42 // Build palettes from config or defaults
39 var lightPalette, darkPalette Palette43 var lightPalette, darkPalette Palette
@@ -69,13 +73,14 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
69 Msg("theme manager initialized")73 Msg("theme manager initialized")
70 74
71 return &Manager{75 return &Manager{
72- scheme: scheme,76+ scheme: scheme,
73- prefersDark: prefersDark,77+ prefersDark: prefersDark,
74- lightPalette: lightPalette,78+ lightPalette: lightPalette,
75- darkPalette: darkPalette,79+ darkPalette: darkPalette,
76- uiScale: uiScale,80+ uiScale: uiScale,
77- fonts: fonts,81+ fonts: fonts,
78- modeColors: modeColors,82+ modeColors: modeColors,
83+ colorResolver: resolver,
79 }84 }
80 }85 }
81 86
@@ -178,11 +183,13 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
178 log := logging.FromContext(ctx)183 log := logging.FromContext(ctx)
179 184
180 m.scheme = scheme185 m.scheme = scheme
181- m.prefersDark = ResolveColorScheme(scheme)186+ pref := m.colorResolver.Refresh()
187+ m.prefersDark = pref.PrefersDark
182 188
183 log.Info().189 log.Info().
184 Str("scheme", scheme).190 Str("scheme", scheme).
185 Bool("prefers_dark", m.prefersDark).191 Bool("prefers_dark", m.prefersDark).
192+ Str("source", pref.Source).
186 Msg("color scheme changed")193 Msg("color scheme changed")
187 194
188 // Re-apply CSS if display is available195 // Re-apply CSS if display is available
@@ -199,13 +206,14 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
199 return206 return
200 }207 }
201 208
202- // Update scheme and resolve prefersDark209+ // Update scheme and resolve prefersDark via resolver
203 scheme := "system"210 scheme := "system"
204 if cfg.Appearance.ColorScheme != "" {211 if cfg.Appearance.ColorScheme != "" {
205 scheme = cfg.Appearance.ColorScheme212 scheme = cfg.Appearance.ColorScheme
206 }213 }
207 m.scheme = scheme214 m.scheme = scheme
208- m.prefersDark = ResolveColorScheme(scheme)215+ pref := m.colorResolver.Refresh()
216+ m.prefersDark = pref.PrefersDark
209 217
210 // Update palettes218 // Update palettes
211 m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)219 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-}
M webui/package-lock.json +64 -0 generated file: collapsed
M webui/package.json +1 -0
@@ -42,6 +42,7 @@
4242 "bits-ui": "^2.14.4",
4343 "clsx": "^2.1.1",
4444 "frappe-charts": "^1.6.2",
45+ "mode-watcher": "^1.1.0",
4546 "tailwind-merge": "^3.4.0"
4647 }
4748 }
@@ -42,6 +42,7 @@
42 "bits-ui": "^2.14.4",42 "bits-ui": "^2.14.4",
43 "clsx": "^2.1.1",43 "clsx": "^2.1.1",
44 "frappe-charts": "^1.6.2",44 "frappe-charts": "^1.6.2",
45+ "mode-watcher": "^1.1.0",
45 "tailwind-merge": "^3.4.0"46 "tailwind-merge": "^3.4.0"
46 }47 }
47 }48 }
M webui/src/pages/ConfigPage.svelte +2 -3
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount } from "svelte";
3+ import { ModeWatcher } from "mode-watcher";
34 import ConfigShell from "./config/ConfigShell.svelte";
45 import ShortcutsTable from "./config/ShortcutsTable.svelte";
56 import * as AlertDialog from "$lib/components/ui/alert-dialog";
@@ -175,9 +176,6 @@
175176 if (!bridge) {
176177 throw new Error("WebKit bridge not available (not running inside Dumber)");
177178 }
178- if (!webviewId) {
179- throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
180- }
181179 if (!config) {
182180 throw new Error("Config not loaded");
183181 }
@@ -268,6 +266,7 @@
268266 });
269267 </script>
270268
269+<ModeWatcher />
271270 <ConfigShell>
272271 <div class="flex w-full flex-col gap-6 px-6 py-8">
273272 {#if loading}
@@ -1,5 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from "svelte";2 import { onMount } from "svelte";
3+ import { ModeWatcher } from "mode-watcher";
3 import ConfigShell from "./config/ConfigShell.svelte";4 import ConfigShell from "./config/ConfigShell.svelte";
4 import ShortcutsTable from "./config/ShortcutsTable.svelte";5 import ShortcutsTable from "./config/ShortcutsTable.svelte";
5 import * as AlertDialog from "$lib/components/ui/alert-dialog";6 import * as AlertDialog from "$lib/components/ui/alert-dialog";
@@ -175,9 +176,6 @@
175 if (!bridge) {176 if (!bridge) {
176 throw new Error("WebKit bridge not available (not running inside Dumber)");177 throw new Error("WebKit bridge not available (not running inside Dumber)");
177 }178 }
178- if (!webviewId) {
179- throw new Error("Missing window.__dumber_webview_id; cannot dispatch/receive callbacks");
180- }
181 if (!config) {179 if (!config) {
182 throw new Error("Config not loaded");180 throw new Error("Config not loaded");
183 }181 }
@@ -268,6 +266,7 @@
268 });266 });
269 </script>267 </script>
270 268
269+<ModeWatcher />
271 <ConfigShell>270 <ConfigShell>
272 <div class="flex w-full flex-col gap-6 px-6 py-8">271 <div class="flex w-full flex-col gap-6 px-6 py-8">
273 {#if loading}272 {#if loading}
M webui/src/pages/ErrorPage.svelte +2 -0
@@ -1,5 +1,6 @@
11 <script lang="ts">
22 import { onMount } from 'svelte';
3+ import { ModeWatcher } from 'mode-watcher';
34 import { Button } from '$lib/components/ui/button';
45 import * as Card from '$lib/components/ui/card';
56
@@ -137,6 +138,7 @@
137138 <title>{config.title}</title>
138139 </svelte:head>
139140
141+<ModeWatcher />
140142 <div class="min-h-screen flex items-center justify-center p-8 bg-background">
141143 <Card.Root class="w-full max-w-md">
142144 <Card.Header class="text-center">
@@ -1,5 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { onMount } from 'svelte';2 import { onMount } from 'svelte';
3+ import { ModeWatcher } from 'mode-watcher';
3 import { Button } from '$lib/components/ui/button';4 import { Button } from '$lib/components/ui/button';
4 import * as Card from '$lib/components/ui/card';5 import * as Card from '$lib/components/ui/card';
5 6
@@ -137,6 +138,7 @@
137 <title>{config.title}</title>138 <title>{config.title}</title>
138 </svelte:head>139 </svelte:head>
139 140
141+<ModeWatcher />
140 <div class="min-h-screen flex items-center justify-center p-8 bg-background">142 <div class="min-h-screen flex items-center justify-center p-8 bg-background">
141 <Card.Root class="w-full max-w-md">143 <Card.Root class="w-full max-w-md">
142 <Card.Header class="text-center">144 <Card.Header class="text-center">
M webui/src/pages/Homepage.svelte +2 -0
@@ -1,9 +1,11 @@
11 <script lang="ts">
2+ import { ModeWatcher } from 'mode-watcher';
23 import { HomepageShell } from './homepage/shell';
34 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
45 import { homepageState } from './homepage/state.svelte';
56 </script>
67
8+<ModeWatcher />
79 <HomepageShell>
810 {#if homepageState.activePanel === 'history'}
911 <HistoryPanel />
@@ -1,9 +1,11 @@
1 <script lang="ts">1 <script lang="ts">
2+ import { ModeWatcher } from 'mode-watcher';
2 import { HomepageShell } from './homepage/shell';3 import { HomepageShell } from './homepage/shell';
3 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';4 import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
4 import { homepageState } from './homepage/state.svelte';5 import { homepageState } from './homepage/state.svelte';
5 </script>6 </script>
6 7
8+<ModeWatcher />
7 <HomepageShell>9 <HomepageShell>
8 {#if homepageState.activePanel === 'history'}10 {#if homepageState.activePanel === 'history'}
9 <HistoryPanel />11 <HistoryPanel />

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