detached f3b20f3

refactor(ui): complete SQLite wiring, JS handler deprecation, and GTK theme system

- Wire SQLite database and repositories in main.go
- Create theme palette, CSS generator, and manager for native GTK4 styling
- Remove JS message handlers (navigate, query, favorites, shortcuts, palette, keyboard)
- Strip content injector to minimal dark mode detection only
- Fix omnibox label display with SetText() and improved CSS
- Add debouncing and duplicate query prevention to omnibox search
- Fix SQL migration goose statement annotations for triggers
- Clean up debug logging noise from search operations
commit
f3b20f331495133eebe51a35b737826f4850b8cc
author
brice · · 2025-12-14 21:42 UTC
committer
brice · · 2025-12-14 17:33 UTC
parent
e4c93f7
signature
unsigned
27 files · +612 -1090
M assets/webui_embed.go +9 -12
@@ -2,19 +2,16 @@ package assets
22
33 import _ "embed"
44
5-// WebUI scripts embedded at compile time
6-
7-//go:embed webui/main-world.min.js
8-var MainWorldScript string
9-
10-//go:embed webui/gui.min.js
11-var GUIScript string
12-
5+// ColorSchemeScript handles dark mode detection in web pages.
6+// It patches window.matchMedia for prefers-color-scheme queries
7+// based on the __dumber_gtk_prefers_dark flag injected by Go.
8+//
139 //go:embed webui/color-scheme.js
1410 var ColorSchemeScript string
1511
16-// GUI component styles extracted from Svelte components
17-// These are injected into the isolated world using UserStyleSheet
18-//
19-//go:embed webui/gui.min.css
12+// Deprecated: These variables are kept for backwards compatibility during migration.
13+// They will be removed once all JS handlers are deprecated.
14+// The actual files may not exist - check before using.
15+var MainWorldScript string
16+var GUIScript string
2017 var ComponentStyles string
@@ -2,19 +2,16 @@ package assets
2 2
3 import _ "embed"3 import _ "embed"
4 4
5-// WebUI scripts embedded at compile time5+// ColorSchemeScript handles dark mode detection in web pages.
6-6+// It patches window.matchMedia for prefers-color-scheme queries
7-//go:embed webui/main-world.min.js7+// based on the __dumber_gtk_prefers_dark flag injected by Go.
8-var MainWorldScript string8+//
9-
10-//go:embed webui/gui.min.js
11-var GUIScript string
12-
13 //go:embed webui/color-scheme.js9 //go:embed webui/color-scheme.js
14 var ColorSchemeScript string10 var ColorSchemeScript string
15 11
16-// GUI component styles extracted from Svelte components12+// Deprecated: These variables are kept for backwards compatibility during migration.
17-// These are injected into the isolated world using UserStyleSheet13+// They will be removed once all JS handlers are deprecated.
18-//14+// The actual files may not exist - check before using.
19-//go:embed webui/gui.min.css15+var MainWorldScript string
16+var GUIScript string
20 var ComponentStyles string17 var ComponentStyles string
M internal/application/usecase/search_history.go +0 -18
@@ -35,12 +35,6 @@ type SearchOutput struct {
3535
3636 // Search performs a fuzzy search on history entries.
3737 func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
38- log := logging.FromContext(ctx)
39- log.Debug().
40- Str("query", input.Query).
41- Int("limit", input.Limit).
42- Msg("searching history")
43-
4438 if input.Query == "" {
4539 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
4640 }
@@ -55,22 +49,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
5549 return nil, fmt.Errorf("failed to search history: %w", err)
5650 }
5751
58- log.Debug().
59- Str("query", input.Query).
60- Int("results", len(matches)).
61- Msg("history search completed")
62-
6352 return &SearchOutput{Matches: matches}, nil
6453 }
6554
6655 // GetRecent retrieves recent history entries with pagination.
6756 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
68- log := logging.FromContext(ctx)
69- log.Debug().
70- Int("limit", limit).
71- Int("offset", offset).
72- Msg("getting recent history")
73-
7457 if limit <= 0 {
7558 limit = 50 // Default limit
7659 }
@@ -80,7 +63,6 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
8063 return nil, fmt.Errorf("failed to get recent history: %w", err)
8164 }
8265
83- log.Debug().Int("count", len(entries)).Msg("retrieved recent history")
8466 return entries, nil
8567 }
8668
@@ -35,12 +35,6 @@ type SearchOutput struct {
35 35
36 // Search performs a fuzzy search on history entries.36 // Search performs a fuzzy search on history entries.
37 func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {37 func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) {
38- log := logging.FromContext(ctx)
39- log.Debug().
40- Str("query", input.Query).
41- Int("limit", input.Limit).
42- Msg("searching history")
43-
44 if input.Query == "" {38 if input.Query == "" {
45 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil39 return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil
46 }40 }
@@ -55,22 +49,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
55 return nil, fmt.Errorf("failed to search history: %w", err)49 return nil, fmt.Errorf("failed to search history: %w", err)
56 }50 }
57 51
58- log.Debug().
59- Str("query", input.Query).
60- Int("results", len(matches)).
61- Msg("history search completed")
62-
63 return &SearchOutput{Matches: matches}, nil52 return &SearchOutput{Matches: matches}, nil
64 }53 }
65 54
66 // GetRecent retrieves recent history entries with pagination.55 // GetRecent retrieves recent history entries with pagination.
67 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {56 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
68- log := logging.FromContext(ctx)
69- log.Debug().
70- Int("limit", limit).
71- Int("offset", offset).
72- Msg("getting recent history")
73-
74 if limit <= 0 {57 if limit <= 0 {
75 limit = 50 // Default limit58 limit = 50 // Default limit
76 }59 }
@@ -80,7 +63,6 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
80 return nil, fmt.Errorf("failed to get recent history: %w", err)63 return nil, fmt.Errorf("failed to get recent history: %w", err)
81 }64 }
82 65
83- log.Debug().Int("count", len(entries)).Msg("retrieved recent history")
84 return entries, nil66 return entries, nil
85 }67 }
86 68
M internal/infrastructure/persistence/sqlite/migrations/001_history.sql +6 -0
@@ -24,18 +24,24 @@ CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
2424 );
2525
2626 -- Triggers to keep FTS in sync
27+-- +goose StatementBegin
2728 CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN
2829 INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
2930 END;
31+-- +goose StatementEnd
3032
33+-- +goose StatementBegin
3134 CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN
3235 INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
3336 END;
37+-- +goose StatementEnd
3438
39+-- +goose StatementBegin
3540 CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN
3641 INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
3742 INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
3843 END;
44+-- +goose StatementEnd
3945
4046 -- +goose Down
4147 DROP TRIGGER IF EXISTS history_fts_update;
@@ -24,18 +24,24 @@ CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
24 );24 );
25 25
26 -- Triggers to keep FTS in sync26 -- Triggers to keep FTS in sync
27+-- +goose StatementBegin
27 CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN28 CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN
28 INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);29 INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
29 END;30 END;
31+-- +goose StatementEnd
30 32
33+-- +goose StatementBegin
31 CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN34 CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN
32 INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);35 INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
33 END;36 END;
37+-- +goose StatementEnd
34 38
39+-- +goose StatementBegin
35 CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN40 CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN
36 INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);41 INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
37 INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);42 INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
38 END;43 END;
44+-- +goose StatementEnd
39 45
40 -- +goose Down46 -- +goose Down
41 DROP TRIGGER IF EXISTS history_fts_update;47 DROP TRIGGER IF EXISTS history_fts_update;
M internal/infrastructure/webkit/injector.go +21 -121
@@ -3,11 +3,8 @@ package webkit
33 import (
44 "context"
55 "fmt"
6- "os"
7- "strings"
86
97 "github.com/bnema/dumber/assets"
10- "github.com/bnema/dumber/internal/infrastructure/config"
118 "github.com/bnema/dumber/internal/logging"
129 "github.com/bnema/puregotk-webkit/webkit"
1310 )
@@ -19,42 +16,18 @@ const (
1916 MessageHandlerName = "dumber"
2017 )
2118
22-// ContentInjector encapsulates script and style injection into WebViews.
23-// It wraps WebKit's UserContentManager to load our frontend assets.
19+// ContentInjector encapsulates script injection into WebViews.
20+// It injects minimal scripts for dark mode detection in web pages.
2421 type ContentInjector struct {
25- allowList []string
26- blockList []string
2722 prefersDark bool
2823 }
2924
30-// NewContentInjector creates a new injector instance with config-based dark mode detection.
31-func NewContentInjector(ctx context.Context, cfg *config.Config) *ContentInjector {
32- ci := &ContentInjector{}
33- ci.resolveColorScheme(ctx, cfg)
34- return ci
35-}
36-
37-// resolveColorScheme determines dark mode preference from config or system.
38-func (ci *ContentInjector) resolveColorScheme(ctx context.Context, cfg *config.Config) {
39- log := logging.FromContext(ctx)
40-
41- if cfg != nil {
42- switch cfg.Appearance.ColorScheme {
43- case "prefer-dark", "dark":
44- ci.prefersDark = true
45- log.Debug().Bool("prefers_dark", true).Msg("color scheme from config: dark")
46- return
47- case "prefer-light", "light":
48- ci.prefersDark = false
49- log.Debug().Bool("prefers_dark", false).Msg("color scheme from config: light")
50- return
51- }
25+// NewContentInjector creates a new injector instance.
26+// The prefersDark parameter should come from ThemeManager.PrefersDark().
27+func NewContentInjector(prefersDark bool) *ContentInjector {
28+ return &ContentInjector{
29+ prefersDark: prefersDark,
5230 }
53-
54- // Fallback: detect from GTK_THEME environment variable
55- gtkTheme := os.Getenv("GTK_THEME")
56- ci.prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
57- log.Debug().Bool("prefers_dark", ci.prefersDark).Str("gtk_theme", gtkTheme).Msg("color scheme from environment")
5831 }
5932
6033 // PrefersDark returns the current dark mode preference.
@@ -62,13 +35,17 @@ func (ci *ContentInjector) PrefersDark() bool {
6235 return ci.prefersDark
6336 }
6437
65-// SetURLFilters configures optional allow/block lists for script/style injection.
66-func (ci *ContentInjector) SetURLFilters(allowList, blockList []string) {
67- ci.allowList = allowList
68- ci.blockList = blockList
38+// SetPrefersDark updates the dark mode preference.
39+// Call this when theme changes at runtime.
40+func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
41+ ci.prefersDark = prefersDark
6942 }
7043
71-// InjectScripts adds the bootstrap and GUI scripts to the given content manager.
44+// InjectScripts adds the minimal dark mode detection scripts to the given content manager.
45+// Only injects:
46+// - window.__dumber_gtk_prefers_dark flag
47+// - window.__dumber_webview_id (for debugging)
48+// - color-scheme.js (patches matchMedia for prefers-color-scheme)
7249 func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
7350 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
7451
@@ -86,7 +63,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
8663 log.Debug().Str("script", label).Msg("injected user script")
8764 }
8865
89- // Inject GTK dark mode preference for color-scheme.js (must be before ColorSchemeScript)
66+ // 1. Inject GTK dark mode preference (must be before color-scheme.js)
9067 darkModeScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
9168 addScript(
9269 webkit.NewUserScript(
@@ -99,11 +76,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
9976 "gtk-dark-mode",
10077 )
10178
102- // Expose the WebView ID early so JS can tag outgoing messages.
103- // Inject in both main world and isolated world since they have separate window objects.
79+ // 2. Inject WebView ID for debugging
10480 if webviewID != 0 {
10581 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
106- // Main world injection
10782 addScript(
10883 webkit.NewUserScript(
10984 idScript,
@@ -114,21 +89,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
11489 ),
11590 "webview-id",
11691 )
117- // Isolated world injection
118- addScript(
119- webkit.NewUserScriptForWorld(
120- idScript,
121- webkit.UserContentInjectTopFrameValue,
122- webkit.UserScriptInjectAtDocumentStartValue,
123- ScriptWorldName,
124- nil,
125- nil,
126- ),
127- "webview-id-isolated",
128- )
12992 }
13093
131- // Inject color-scheme handler (uses __dumber_gtk_prefers_dark)
94+ // 3. Inject color-scheme handler (uses __dumber_gtk_prefers_dark to patch matchMedia)
13295 if assets.ColorSchemeScript != "" {
13396 addScript(
13497 webkit.NewUserScript(
@@ -140,72 +103,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
140103 ),
141104 "color-scheme",
142105 )
143- }
144-
145- if assets.MainWorldScript != "" {
146- addScript(
147- webkit.NewUserScript(
148- assets.MainWorldScript,
149- webkit.UserContentInjectTopFrameValue,
150- webkit.UserScriptInjectAtDocumentStartValue,
151- ci.allowList,
152- ci.blockList,
153- ),
154- "main-world",
155- )
156106 } else {
157- log.Warn().Msg("MainWorldScript asset is empty; skipping injection")
158- }
159-
160- if assets.GUIScript != "" {
161- addScript(
162- webkit.NewUserScriptForWorld(
163- assets.GUIScript,
164- webkit.UserContentInjectTopFrameValue,
165- webkit.UserScriptInjectAtDocumentEndValue,
166- ScriptWorldName,
167- ci.allowList,
168- ci.blockList,
169- ),
170- "gui-world",
171- )
172- } else {
173- log.Warn().Msg("GUIScript asset is empty; skipping injection")
174- }
175-}
176-
177-// InjectStyles injects component CSS as a JS variable into the isolated world.
178-// The shadow DOM code reads window.__dumber_component_styles and injects it into the shadow root.
179-func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {
180- log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
181-
182- if ucm == nil {
183- log.Warn().Msg("cannot inject styles: user content manager is nil")
184- return
185- }
186-
187- if assets.ComponentStyles == "" {
188- log.Warn().Msg("ComponentStyles asset is empty; skipping injection")
189- return
190- }
191-
192- // Inject CSS as a JS string variable in isolated world at document-start
193- // The shadow DOM code (shadowHost.ts) reads this and injects into shadow root
194- // Using %q properly escapes the CSS for JavaScript
195- componentStylesScript := fmt.Sprintf("window.__dumber_component_styles=%q;", assets.ComponentStyles)
196- script := webkit.NewUserScriptForWorld(
197- componentStylesScript,
198- webkit.UserContentInjectTopFrameValue,
199- webkit.UserScriptInjectAtDocumentStartValue,
200- ScriptWorldName,
201- nil,
202- nil,
203- )
204- if script == nil {
205- log.Warn().Msg("failed to create component styles script")
206- return
107+ log.Warn().Msg("ColorSchemeScript asset is empty; dark mode may not work correctly")
207108 }
208109
209- ucm.AddScript(script)
210- log.Debug().Int("css_bytes", len(assets.ComponentStyles)).Msg("injected component styles")
110+ log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
211111 }
@@ -3,11 +3,8 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "os"
7- "strings"
8 6
9 "github.com/bnema/dumber/assets"7 "github.com/bnema/dumber/assets"
10- "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk-webkit/webkit"9 "github.com/bnema/puregotk-webkit/webkit"
13 )10 )
@@ -19,42 +16,18 @@ const (
19 MessageHandlerName = "dumber"16 MessageHandlerName = "dumber"
20 )17 )
21 18
22-// ContentInjector encapsulates script and style injection into WebViews.19+// ContentInjector encapsulates script injection into WebViews.
23-// It wraps WebKit's UserContentManager to load our frontend assets.20+// It injects minimal scripts for dark mode detection in web pages.
24 type ContentInjector struct {21 type ContentInjector struct {
25- allowList []string
26- blockList []string
27 prefersDark bool22 prefersDark bool
28 }23 }
29 24
30-// NewContentInjector creates a new injector instance with config-based dark mode detection.25+// NewContentInjector creates a new injector instance.
31-func NewContentInjector(ctx context.Context, cfg *config.Config) *ContentInjector {26+// The prefersDark parameter should come from ThemeManager.PrefersDark().
32- ci := &ContentInjector{}27+func NewContentInjector(prefersDark bool) *ContentInjector {
33- ci.resolveColorScheme(ctx, cfg)28+ return &ContentInjector{
34- return ci29+ prefersDark: prefersDark,
35-}
36-
37-// resolveColorScheme determines dark mode preference from config or system.
38-func (ci *ContentInjector) resolveColorScheme(ctx context.Context, cfg *config.Config) {
39- log := logging.FromContext(ctx)
40-
41- if cfg != nil {
42- switch cfg.Appearance.ColorScheme {
43- case "prefer-dark", "dark":
44- ci.prefersDark = true
45- log.Debug().Bool("prefers_dark", true).Msg("color scheme from config: dark")
46- return
47- case "prefer-light", "light":
48- ci.prefersDark = false
49- log.Debug().Bool("prefers_dark", false).Msg("color scheme from config: light")
50- return
51- }
52 }30 }
53-
54- // Fallback: detect from GTK_THEME environment variable
55- gtkTheme := os.Getenv("GTK_THEME")
56- ci.prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
57- log.Debug().Bool("prefers_dark", ci.prefersDark).Str("gtk_theme", gtkTheme).Msg("color scheme from environment")
58 }31 }
59 32
60 // PrefersDark returns the current dark mode preference.33 // PrefersDark returns the current dark mode preference.
@@ -62,13 +35,17 @@ func (ci *ContentInjector) PrefersDark() bool {
62 return ci.prefersDark35 return ci.prefersDark
63 }36 }
64 37
65-// SetURLFilters configures optional allow/block lists for script/style injection.38+// SetPrefersDark updates the dark mode preference.
66-func (ci *ContentInjector) SetURLFilters(allowList, blockList []string) {39+// Call this when theme changes at runtime.
67- ci.allowList = allowList40+func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
68- ci.blockList = blockList41+ ci.prefersDark = prefersDark
69 }42 }
70 43
71-// InjectScripts adds the bootstrap and GUI scripts to the given content manager.44+// InjectScripts adds the minimal dark mode detection scripts to the given content manager.
45+// Only injects:
46+// - window.__dumber_gtk_prefers_dark flag
47+// - window.__dumber_webview_id (for debugging)
48+// - color-scheme.js (patches matchMedia for prefers-color-scheme)
72 func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {49 func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
73 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()50 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
74 51
@@ -86,7 +63,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
86 log.Debug().Str("script", label).Msg("injected user script")63 log.Debug().Str("script", label).Msg("injected user script")
87 }64 }
88 65
89- // Inject GTK dark mode preference for color-scheme.js (must be before ColorSchemeScript)66+ // 1. Inject GTK dark mode preference (must be before color-scheme.js)
90 darkModeScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)67 darkModeScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
91 addScript(68 addScript(
92 webkit.NewUserScript(69 webkit.NewUserScript(
@@ -99,11 +76,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
99 "gtk-dark-mode",76 "gtk-dark-mode",
100 )77 )
101 78
102- // Expose the WebView ID early so JS can tag outgoing messages.79+ // 2. Inject WebView ID for debugging
103- // Inject in both main world and isolated world since they have separate window objects.
104 if webviewID != 0 {80 if webviewID != 0 {
105 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))81 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
106- // Main world injection
107 addScript(82 addScript(
108 webkit.NewUserScript(83 webkit.NewUserScript(
109 idScript,84 idScript,
@@ -114,21 +89,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
114 ),89 ),
115 "webview-id",90 "webview-id",
116 )91 )
117- // Isolated world injection
118- addScript(
119- webkit.NewUserScriptForWorld(
120- idScript,
121- webkit.UserContentInjectTopFrameValue,
122- webkit.UserScriptInjectAtDocumentStartValue,
123- ScriptWorldName,
124- nil,
125- nil,
126- ),
127- "webview-id-isolated",
128- )
129 }92 }
130 93
131- // Inject color-scheme handler (uses __dumber_gtk_prefers_dark)94+ // 3. Inject color-scheme handler (uses __dumber_gtk_prefers_dark to patch matchMedia)
132 if assets.ColorSchemeScript != "" {95 if assets.ColorSchemeScript != "" {
133 addScript(96 addScript(
134 webkit.NewUserScript(97 webkit.NewUserScript(
@@ -140,72 +103,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
140 ),103 ),
141 "color-scheme",104 "color-scheme",
142 )105 )
143- }
144-
145- if assets.MainWorldScript != "" {
146- addScript(
147- webkit.NewUserScript(
148- assets.MainWorldScript,
149- webkit.UserContentInjectTopFrameValue,
150- webkit.UserScriptInjectAtDocumentStartValue,
151- ci.allowList,
152- ci.blockList,
153- ),
154- "main-world",
155- )
156 } else {106 } else {
157- log.Warn().Msg("MainWorldScript asset is empty; skipping injection")107+ log.Warn().Msg("ColorSchemeScript asset is empty; dark mode may not work correctly")
158- }
159-
160- if assets.GUIScript != "" {
161- addScript(
162- webkit.NewUserScriptForWorld(
163- assets.GUIScript,
164- webkit.UserContentInjectTopFrameValue,
165- webkit.UserScriptInjectAtDocumentEndValue,
166- ScriptWorldName,
167- ci.allowList,
168- ci.blockList,
169- ),
170- "gui-world",
171- )
172- } else {
173- log.Warn().Msg("GUIScript asset is empty; skipping injection")
174- }
175-}
176-
177-// InjectStyles injects component CSS as a JS variable into the isolated world.
178-// The shadow DOM code reads window.__dumber_component_styles and injects it into the shadow root.
179-func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {
180- log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
181-
182- if ucm == nil {
183- log.Warn().Msg("cannot inject styles: user content manager is nil")
184- return
185- }
186-
187- if assets.ComponentStyles == "" {
188- log.Warn().Msg("ComponentStyles asset is empty; skipping injection")
189- return
190- }
191-
192- // Inject CSS as a JS string variable in isolated world at document-start
193- // The shadow DOM code (shadowHost.ts) reads this and injects into shadow root
194- // Using %q properly escapes the CSS for JavaScript
195- componentStylesScript := fmt.Sprintf("window.__dumber_component_styles=%q;", assets.ComponentStyles)
196- script := webkit.NewUserScriptForWorld(
197- componentStylesScript,
198- webkit.UserContentInjectTopFrameValue,
199- webkit.UserScriptInjectAtDocumentStartValue,
200- ScriptWorldName,
201- nil,
202- nil,
203- )
204- if script == nil {
205- log.Warn().Msg("failed to create component styles script")
206- return
207 }108 }
208 109
209- ucm.AddScript(script)110+ log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
210- log.Debug().Int("css_bytes", len(assets.ComponentStyles)).Msg("injected component styles")
211 }111 }
M internal/infrastructure/webkit/pool.go +21 -14
@@ -7,7 +7,6 @@ import (
77 "time"
88
99 "github.com/bnema/dumber/internal/logging"
10- "github.com/rs/zerolog"
1110 )
1211
1312 // PoolConfig configures the WebView pool behavior.
@@ -39,7 +38,6 @@ type WebViewPool struct {
3938 config PoolConfig
4039
4140 pool chan *WebView
42- logger zerolog.Logger
4341 ctx context.Context
4442 injector *ContentInjector
4543 router *MessageRouter
@@ -49,7 +47,7 @@ type WebViewPool struct {
4947 }
5048
5149 // NewWebViewPool creates a new WebView pool.
52-func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, injector *ContentInjector, router *MessageRouter, logger zerolog.Logger) *WebViewPool {
50+func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, injector *ContentInjector, router *MessageRouter) *WebViewPool {
5351 if cfg.MaxSize <= 0 {
5452 cfg.MaxSize = 8
5553 }
@@ -68,8 +66,7 @@ func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *Setting
6866 settings: settings,
6967 config: cfg,
7068 pool: make(chan *WebView, cfg.MaxSize),
71- logger: logger.With().Str("component", "webview-pool").Logger(),
72- ctx: ctx,
69+ ctx: logging.WithComponent(ctx, "webview-pool"),
7370 injector: injector,
7471 router: router,
7572 }
@@ -79,6 +76,8 @@ func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *Setting
7976
8077 // Acquire gets a WebView from the pool or creates a new one.
8178 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
79+ log := logging.FromContext(p.ctx)
80+
8281 if p.closed.Load() {
8382 return nil, context.Canceled
8483 }
@@ -89,7 +88,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
8988 if wv != nil && !wv.IsDestroyed() {
9089 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
9190 _ = wv.AttachFrontend(p.ctx, p.injector, p.router)
92- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
91+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
9392 return wv, nil
9493 }
9594 // Fall through to create new if the pooled one was destroyed
@@ -98,12 +97,14 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
9897 }
9998
10099 // Pool empty or pooled view was destroyed, create new
101- p.logger.Debug().Msg("pool empty, creating new webview")
100+ log.Debug().Msg("pool empty, creating new webview")
102101 return p.createWebView()
103102 }
104103
105104 func (p *WebViewPool) createWebView() (*WebView, error) {
106- wv, err := NewWebView(p.wkCtx, p.settings, p.logger)
105+ log := logging.FromContext(p.ctx)
106+
107+ wv, err := NewWebView(p.ctx, p.wkCtx, p.settings)
107108 if err != nil {
108109 return nil, err
109110 }
@@ -112,7 +113,7 @@ func (p *WebViewPool) createWebView() (*WebView, error) {
112113 wv.inner.SetVisible(true)
113114
114115 if err := wv.AttachFrontend(p.ctx, p.injector, p.router); err != nil {
115- logging.FromContext(p.ctx).Warn().Err(err).Msg("failed to attach frontend to new webview")
116+ log.Warn().Err(err).Msg("failed to attach frontend to new webview")
116117 }
117118
118119 return wv, nil
@@ -138,13 +139,15 @@ func (p *WebViewPool) Release(wv *WebView) {
138139 wv.OnProgressChanged = nil
139140 wv.OnClose = nil
140141
142+ log := logging.FromContext(p.ctx)
143+
141144 // Try to return to pool (non-blocking)
142145 select {
143146 case p.pool <- wv:
144- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("returned webview to pool")
147+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("returned webview to pool")
145148 default:
146149 // Pool full, destroy the view
147- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("pool full, destroying webview")
150+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("pool full, destroying webview")
148151 wv.Destroy()
149152 }
150153 }
@@ -163,6 +166,8 @@ func (p *WebViewPool) Prewarm(count int) {
163166
164167 // prewarmSync creates WebViews synchronously.
165168 func (p *WebViewPool) prewarmSync(count int) {
169+ log := logging.FromContext(p.ctx)
170+
166171 for i := 0; i < count && !p.closed.Load(); i++ {
167172 // Check if pool is already at capacity
168173 if len(p.pool) >= p.config.MaxSize {
@@ -171,14 +176,14 @@ func (p *WebViewPool) prewarmSync(count int) {
171176
172177 wv, err := p.createWebView()
173178 if err != nil {
174- p.logger.Warn().Err(err).Msg("failed to prewarm webview")
179+ log.Warn().Err(err).Msg("failed to prewarm webview")
175180 continue
176181 }
177182
178183 // Try to add to pool
179184 select {
180185 case p.pool <- wv:
181- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
186+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
182187 default:
183188 // Pool full, destroy
184189 wv.Destroy()
@@ -201,6 +206,8 @@ func (p *WebViewPool) Close() {
201206 return // Already closed
202207 }
203208
209+ log := logging.FromContext(p.ctx)
210+
204211 // Wait for any background operations
205212 p.wg.Wait()
206213
@@ -212,5 +219,5 @@ func (p *WebViewPool) Close() {
212219 }
213220 }
214221
215- p.logger.Debug().Msg("webview pool closed")
222+ log.Debug().Msg("webview pool closed")
216223 }
@@ -7,7 +7,6 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10- "github.com/rs/zerolog"
11 )10 )
12 11
13 // PoolConfig configures the WebView pool behavior.12 // PoolConfig configures the WebView pool behavior.
@@ -39,7 +38,6 @@ type WebViewPool struct {
39 config PoolConfig38 config PoolConfig
40 39
41 pool chan *WebView40 pool chan *WebView
42- logger zerolog.Logger
43 ctx context.Context41 ctx context.Context
44 injector *ContentInjector42 injector *ContentInjector
45 router *MessageRouter43 router *MessageRouter
@@ -49,7 +47,7 @@ type WebViewPool struct {
49 }47 }
50 48
51 // NewWebViewPool creates a new WebView pool.49 // NewWebViewPool creates a new WebView pool.
52-func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, injector *ContentInjector, router *MessageRouter, logger zerolog.Logger) *WebViewPool {50+func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, injector *ContentInjector, router *MessageRouter) *WebViewPool {
53 if cfg.MaxSize <= 0 {51 if cfg.MaxSize <= 0 {
54 cfg.MaxSize = 852 cfg.MaxSize = 8
55 }53 }
@@ -68,8 +66,7 @@ func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *Setting
68 settings: settings,66 settings: settings,
69 config: cfg,67 config: cfg,
70 pool: make(chan *WebView, cfg.MaxSize),68 pool: make(chan *WebView, cfg.MaxSize),
71- logger: logger.With().Str("component", "webview-pool").Logger(),69+ ctx: logging.WithComponent(ctx, "webview-pool"),
72- ctx: ctx,
73 injector: injector,70 injector: injector,
74 router: router,71 router: router,
75 }72 }
@@ -79,6 +76,8 @@ func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *Setting
79 76
80 // Acquire gets a WebView from the pool or creates a new one.77 // Acquire gets a WebView from the pool or creates a new one.
81 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {78 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
79+ log := logging.FromContext(p.ctx)
80+
82 if p.closed.Load() {81 if p.closed.Load() {
83 return nil, context.Canceled82 return nil, context.Canceled
84 }83 }
@@ -89,7 +88,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
89 if wv != nil && !wv.IsDestroyed() {88 if wv != nil && !wv.IsDestroyed() {
90 // Ensure frontend is attached even if this WebView was pooled before injection was configured.89 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
91 _ = wv.AttachFrontend(p.ctx, p.injector, p.router)90 _ = wv.AttachFrontend(p.ctx, p.injector, p.router)
92- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")91+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
93 return wv, nil92 return wv, nil
94 }93 }
95 // Fall through to create new if the pooled one was destroyed94 // Fall through to create new if the pooled one was destroyed
@@ -98,12 +97,14 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
98 }97 }
99 98
100 // Pool empty or pooled view was destroyed, create new99 // Pool empty or pooled view was destroyed, create new
101- p.logger.Debug().Msg("pool empty, creating new webview")100+ log.Debug().Msg("pool empty, creating new webview")
102 return p.createWebView()101 return p.createWebView()
103 }102 }
104 103
105 func (p *WebViewPool) createWebView() (*WebView, error) {104 func (p *WebViewPool) createWebView() (*WebView, error) {
106- wv, err := NewWebView(p.wkCtx, p.settings, p.logger)105+ log := logging.FromContext(p.ctx)
106+
107+ wv, err := NewWebView(p.ctx, p.wkCtx, p.settings)
107 if err != nil {108 if err != nil {
108 return nil, err109 return nil, err
109 }110 }
@@ -112,7 +113,7 @@ func (p *WebViewPool) createWebView() (*WebView, error) {
112 wv.inner.SetVisible(true)113 wv.inner.SetVisible(true)
113 114
114 if err := wv.AttachFrontend(p.ctx, p.injector, p.router); err != nil {115 if err := wv.AttachFrontend(p.ctx, p.injector, p.router); err != nil {
115- logging.FromContext(p.ctx).Warn().Err(err).Msg("failed to attach frontend to new webview")116+ log.Warn().Err(err).Msg("failed to attach frontend to new webview")
116 }117 }
117 118
118 return wv, nil119 return wv, nil
@@ -138,13 +139,15 @@ func (p *WebViewPool) Release(wv *WebView) {
138 wv.OnProgressChanged = nil139 wv.OnProgressChanged = nil
139 wv.OnClose = nil140 wv.OnClose = nil
140 141
142+ log := logging.FromContext(p.ctx)
143+
141 // Try to return to pool (non-blocking)144 // Try to return to pool (non-blocking)
142 select {145 select {
143 case p.pool <- wv:146 case p.pool <- wv:
144- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("returned webview to pool")147+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("returned webview to pool")
145 default:148 default:
146 // Pool full, destroy the view149 // Pool full, destroy the view
147- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("pool full, destroying webview")150+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("pool full, destroying webview")
148 wv.Destroy()151 wv.Destroy()
149 }152 }
150 }153 }
@@ -163,6 +166,8 @@ func (p *WebViewPool) Prewarm(count int) {
163 166
164 // prewarmSync creates WebViews synchronously.167 // prewarmSync creates WebViews synchronously.
165 func (p *WebViewPool) prewarmSync(count int) {168 func (p *WebViewPool) prewarmSync(count int) {
169+ log := logging.FromContext(p.ctx)
170+
166 for i := 0; i < count && !p.closed.Load(); i++ {171 for i := 0; i < count && !p.closed.Load(); i++ {
167 // Check if pool is already at capacity172 // Check if pool is already at capacity
168 if len(p.pool) >= p.config.MaxSize {173 if len(p.pool) >= p.config.MaxSize {
@@ -171,14 +176,14 @@ func (p *WebViewPool) prewarmSync(count int) {
171 176
172 wv, err := p.createWebView()177 wv, err := p.createWebView()
173 if err != nil {178 if err != nil {
174- p.logger.Warn().Err(err).Msg("failed to prewarm webview")179+ log.Warn().Err(err).Msg("failed to prewarm webview")
175 continue180 continue
176 }181 }
177 182
178 // Try to add to pool183 // Try to add to pool
179 select {184 select {
180 case p.pool <- wv:185 case p.pool <- wv:
181- p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")186+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
182 default:187 default:
183 // Pool full, destroy188 // Pool full, destroy
184 wv.Destroy()189 wv.Destroy()
@@ -201,6 +206,8 @@ func (p *WebViewPool) Close() {
201 return // Already closed206 return // Already closed
202 }207 }
203 208
209+ log := logging.FromContext(p.ctx)
210+
204 // Wait for any background operations211 // Wait for any background operations
205 p.wg.Wait()212 p.wg.Wait()
206 213
@@ -212,5 +219,5 @@ func (p *WebViewPool) Close() {
212 }219 }
213 }220 }
214 221
215- p.logger.Debug().Msg("webview pool closed")222+ log.Debug().Msg("webview pool closed")
216 }223 }
M internal/infrastructure/webkit/scheme_handler.go +6 -2
@@ -1,10 +1,12 @@
11 package webkit
22
33 import (
4+ "context"
45 "net/http"
56 "strings"
67 "sync"
78
9+ "github.com/bnema/dumber/internal/logging"
810 "github.com/bnema/puregotk-webkit/webkit"
911 "github.com/jwijenbergh/puregotk/v4/gio"
1012 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -47,10 +49,12 @@ type DumbSchemeHandler struct {
4749 }
4850
4951 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
50-func NewDumbSchemeHandler(logger zerolog.Logger) *DumbSchemeHandler {
52+func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
53+ log := logging.FromContext(ctx)
54+
5155 h := &DumbSchemeHandler{
5256 handlers: make(map[string]PageHandler),
53- logger: logger.With().Str("component", "scheme-handler").Logger(),
57+ logger: log.With().Str("component", "scheme-handler").Logger(),
5458 }
5559
5660 // Register default pages
@@ -1,10 +1,12 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "context"
4 "net/http"5 "net/http"
5 "strings"6 "strings"
6 "sync"7 "sync"
7 8
9+ "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/puregotk-webkit/webkit"10 "github.com/bnema/puregotk-webkit/webkit"
9 "github.com/jwijenbergh/puregotk/v4/gio"11 "github.com/jwijenbergh/puregotk/v4/gio"
10 "github.com/jwijenbergh/puregotk/v4/glib"12 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -47,10 +49,12 @@ type DumbSchemeHandler struct {
47 }49 }
48 50
49 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.51 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
50-func NewDumbSchemeHandler(logger zerolog.Logger) *DumbSchemeHandler {52+func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
53+ log := logging.FromContext(ctx)
54+
51 h := &DumbSchemeHandler{55 h := &DumbSchemeHandler{
52 handlers: make(map[string]PageHandler),56 handlers: make(map[string]PageHandler),
53- logger: logger.With().Str("component", "scheme-handler").Logger(),57+ logger: log.With().Str("component", "scheme-handler").Logger(),
54 }58 }
55 59
56 // Register default pages60 // Register default pages
M internal/infrastructure/webkit/settings.go +15 -10
@@ -1,37 +1,40 @@
11 package webkit
22
33 import (
4+ "context"
45 "sync"
56
67 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
79 "github.com/bnema/puregotk-webkit/webkit"
8- "github.com/rs/zerolog"
910 )
1011
1112 // SettingsManager creates and manages WebKit Settings instances from config.
1213 type SettingsManager struct {
13- cfg *config.Config
14- logger zerolog.Logger
15- mu sync.RWMutex
14+ cfg *config.Config
15+ ctx context.Context
16+ mu sync.RWMutex
1617 }
1718
1819 // NewSettingsManager creates a new SettingsManager with the given config.
19-func NewSettingsManager(cfg *config.Config, logger zerolog.Logger) *SettingsManager {
20+func NewSettingsManager(ctx context.Context, cfg *config.Config) *SettingsManager {
2021 return &SettingsManager{
21- cfg: cfg,
22- logger: logger.With().Str("component", "webkit-settings").Logger(),
22+ cfg: cfg,
23+ ctx: logging.WithComponent(ctx, "webkit-settings"),
2324 }
2425 }
2526
2627 // CreateSettings creates a new webkit.Settings instance configured from the current config.
2728 func (sm *SettingsManager) CreateSettings() *webkit.Settings {
29+ log := logging.FromContext(sm.ctx)
30+
2831 sm.mu.RLock()
2932 cfg := sm.cfg
3033 sm.mu.RUnlock()
3134
3235 settings := webkit.NewSettings()
3336 if settings == nil {
34- sm.logger.Error().Msg("failed to create webkit settings")
37+ log.Error().Msg("failed to create webkit settings")
3538 return nil
3639 }
3740
@@ -105,7 +108,8 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config.
105108 // WebRTC
106109 settings.SetEnableWebrtc(true)
107110
108- sm.logger.Debug().
111+ log := logging.FromContext(sm.ctx)
112+ log.Debug().
109113 Str("sans_font", cfg.Appearance.SansFont).
110114 Str("rendering_mode", string(cfg.RenderingMode)).
111115 Bool("developer_extras", cfg.Debug.EnableDevTools).
@@ -116,10 +120,11 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config.
116120 // Note: This doesn't update already-created Settings instances.
117121 // New WebViews will use the updated config.
118122 func (sm *SettingsManager) UpdateFromConfig(cfg *config.Config) {
123+ log := logging.FromContext(sm.ctx)
119124 sm.mu.Lock()
120125 defer sm.mu.Unlock()
121126 sm.cfg = cfg
122- sm.logger.Debug().Msg("settings config updated")
127+ log.Debug().Msg("settings config updated")
123128 }
124129
125130 // ApplyToWebView applies current settings to an existing WebView.
@@ -1,37 +1,40 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "context"
4 "sync"5 "sync"
5 6
6 "github.com/bnema/dumber/internal/infrastructure/config"7 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
7 "github.com/bnema/puregotk-webkit/webkit"9 "github.com/bnema/puregotk-webkit/webkit"
8- "github.com/rs/zerolog"
9 )10 )
10 11
11 // SettingsManager creates and manages WebKit Settings instances from config.12 // SettingsManager creates and manages WebKit Settings instances from config.
12 type SettingsManager struct {13 type SettingsManager struct {
13- cfg *config.Config14+ cfg *config.Config
14- logger zerolog.Logger15+ ctx context.Context
15- mu sync.RWMutex16+ mu sync.RWMutex
16 }17 }
17 18
18 // NewSettingsManager creates a new SettingsManager with the given config.19 // NewSettingsManager creates a new SettingsManager with the given config.
19-func NewSettingsManager(cfg *config.Config, logger zerolog.Logger) *SettingsManager {20+func NewSettingsManager(ctx context.Context, cfg *config.Config) *SettingsManager {
20 return &SettingsManager{21 return &SettingsManager{
21- cfg: cfg,22+ cfg: cfg,
22- logger: logger.With().Str("component", "webkit-settings").Logger(),23+ ctx: logging.WithComponent(ctx, "webkit-settings"),
23 }24 }
24 }25 }
25 26
26 // CreateSettings creates a new webkit.Settings instance configured from the current config.27 // CreateSettings creates a new webkit.Settings instance configured from the current config.
27 func (sm *SettingsManager) CreateSettings() *webkit.Settings {28 func (sm *SettingsManager) CreateSettings() *webkit.Settings {
29+ log := logging.FromContext(sm.ctx)
30+
28 sm.mu.RLock()31 sm.mu.RLock()
29 cfg := sm.cfg32 cfg := sm.cfg
30 sm.mu.RUnlock()33 sm.mu.RUnlock()
31 34
32 settings := webkit.NewSettings()35 settings := webkit.NewSettings()
33 if settings == nil {36 if settings == nil {
34- sm.logger.Error().Msg("failed to create webkit settings")37+ log.Error().Msg("failed to create webkit settings")
35 return nil38 return nil
36 }39 }
37 40
@@ -105,7 +108,8 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config.
105 // WebRTC108 // WebRTC
106 settings.SetEnableWebrtc(true)109 settings.SetEnableWebrtc(true)
107 110
108- sm.logger.Debug().111+ log := logging.FromContext(sm.ctx)
112+ log.Debug().
109 Str("sans_font", cfg.Appearance.SansFont).113 Str("sans_font", cfg.Appearance.SansFont).
110 Str("rendering_mode", string(cfg.RenderingMode)).114 Str("rendering_mode", string(cfg.RenderingMode)).
111 Bool("developer_extras", cfg.Debug.EnableDevTools).115 Bool("developer_extras", cfg.Debug.EnableDevTools).
@@ -116,10 +120,11 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config.
116 // Note: This doesn't update already-created Settings instances.120 // Note: This doesn't update already-created Settings instances.
117 // New WebViews will use the updated config.121 // New WebViews will use the updated config.
118 func (sm *SettingsManager) UpdateFromConfig(cfg *config.Config) {122 func (sm *SettingsManager) UpdateFromConfig(cfg *config.Config) {
123+ log := logging.FromContext(sm.ctx)
119 sm.mu.Lock()124 sm.mu.Lock()
120 defer sm.mu.Unlock()125 defer sm.mu.Unlock()
121 sm.cfg = cfg126 sm.cfg = cfg
122- sm.logger.Debug().Msg("settings config updated")127+ log.Debug().Msg("settings config updated")
123 }128 }
124 129
125 // ApplyToWebView applies current settings to an existing WebView.130 // ApplyToWebView applies current settings to an existing WebView.
M internal/infrastructure/webkit/webview.go +19 -8
@@ -110,7 +110,9 @@ type WebView struct {
110110 }
111111
112112 // NewWebView creates a new WebView with the given context and settings.
113-func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.Logger) (*WebView, error) {
113+func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) {
114+ log := logging.FromContext(ctx)
115+
114116 if wkCtx == nil || !wkCtx.IsInitialized() {
115117 return nil, fmt.Errorf("webkit context not initialized")
116118 }
@@ -123,7 +125,7 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.
123125 wv := &WebView{
124126 inner: inner,
125127 ucm: inner.GetUserContentManager(),
126- logger: logger.With().Str("component", "webview").Logger(),
128+ logger: log.With().Str("component", "webview").Logger(),
127129 signalIDs: make([]uint32, 0, 4),
128130 }
129131
@@ -145,7 +147,9 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.
145147
146148 // NewWebViewWithRelated creates a WebView that shares session/cookies with parent.
147149 // This is required for popup windows to maintain authentication state.
148-func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger zerolog.Logger) (*WebView, error) {
150+func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *SettingsManager) (*WebView, error) {
151+ log := logging.FromContext(ctx)
152+
149153 if parent == nil {
150154 return nil, fmt.Errorf("parent webview is nil")
151155 }
@@ -161,7 +165,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze
161165 wv := &WebView{
162166 inner: inner,
163167 ucm: inner.GetUserContentManager(),
164- logger: logger.With().Str("component", "webview-popup").Logger(),
168+ logger: log.With().Str("component", "webview-popup").Logger(),
165169 signalIDs: make([]uint32, 0, 6),
166170 }
167171
@@ -185,10 +189,12 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze
185189 func (wv *WebView) connectSignals() {
186190 // load-changed signal
187191 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
192+ uri := inner.GetUri()
193+ title := inner.GetTitle()
188194
189195 wv.mu.Lock()
190- wv.uri = inner.GetUri()
191- wv.title = inner.GetTitle()
196+ wv.uri = uri
197+ wv.title = title
192198 wv.canGoBack = inner.CanGoBack()
193199 wv.canGoFwd = inner.CanGoForward()
194200 wv.progress = inner.GetEstimatedLoadProgress()
@@ -196,8 +202,14 @@ func (wv *WebView) connectSignals() {
196202 switch event {
197203 case webkit.LoadStartedValue:
198204 wv.isLoading = true
205+ wv.logger.Debug().Str("uri", uri).Msg("load started")
206+ case webkit.LoadRedirectedValue:
207+ wv.logger.Debug().Str("uri", uri).Msg("load redirected")
208+ case webkit.LoadCommittedValue:
209+ wv.logger.Debug().Str("uri", uri).Msg("load committed")
199210 case webkit.LoadFinishedValue:
200211 wv.isLoading = false
212+ wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
201213 }
202214 wv.mu.Unlock()
203215
@@ -551,9 +563,8 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
551563 }
552564
553565 if injector != nil {
554- log.Debug().Msg("AttachFrontend: injecting scripts and styles")
566+ log.Debug().Msg("AttachFrontend: injecting scripts")
555567 injector.InjectScripts(ctx, wv.ucm, wv.id)
556- injector.InjectStyles(ctx, wv.ucm)
557568 }
558569
559570 log.Debug().Msg("frontend assets attached to webview")
@@ -110,7 +110,9 @@ type WebView struct {
110 }110 }
111 111
112 // NewWebView creates a new WebView with the given context and settings.112 // NewWebView creates a new WebView with the given context and settings.
113-func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.Logger) (*WebView, error) {113+func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) {
114+ log := logging.FromContext(ctx)
115+
114 if wkCtx == nil || !wkCtx.IsInitialized() {116 if wkCtx == nil || !wkCtx.IsInitialized() {
115 return nil, fmt.Errorf("webkit context not initialized")117 return nil, fmt.Errorf("webkit context not initialized")
116 }118 }
@@ -123,7 +125,7 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.
123 wv := &WebView{125 wv := &WebView{
124 inner: inner,126 inner: inner,
125 ucm: inner.GetUserContentManager(),127 ucm: inner.GetUserContentManager(),
126- logger: logger.With().Str("component", "webview").Logger(),128+ logger: log.With().Str("component", "webview").Logger(),
127 signalIDs: make([]uint32, 0, 4),129 signalIDs: make([]uint32, 0, 4),
128 }130 }
129 131
@@ -145,7 +147,9 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.
145 147
146 // NewWebViewWithRelated creates a WebView that shares session/cookies with parent.148 // NewWebViewWithRelated creates a WebView that shares session/cookies with parent.
147 // This is required for popup windows to maintain authentication state.149 // This is required for popup windows to maintain authentication state.
148-func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger zerolog.Logger) (*WebView, error) {150+func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *SettingsManager) (*WebView, error) {
151+ log := logging.FromContext(ctx)
152+
149 if parent == nil {153 if parent == nil {
150 return nil, fmt.Errorf("parent webview is nil")154 return nil, fmt.Errorf("parent webview is nil")
151 }155 }
@@ -161,7 +165,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze
161 wv := &WebView{165 wv := &WebView{
162 inner: inner,166 inner: inner,
163 ucm: inner.GetUserContentManager(),167 ucm: inner.GetUserContentManager(),
164- logger: logger.With().Str("component", "webview-popup").Logger(),168+ logger: log.With().Str("component", "webview-popup").Logger(),
165 signalIDs: make([]uint32, 0, 6),169 signalIDs: make([]uint32, 0, 6),
166 }170 }
167 171
@@ -185,10 +189,12 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze
185 func (wv *WebView) connectSignals() {189 func (wv *WebView) connectSignals() {
186 // load-changed signal190 // load-changed signal
187 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {191 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
192+ uri := inner.GetUri()
193+ title := inner.GetTitle()
188 194
189 wv.mu.Lock()195 wv.mu.Lock()
190- wv.uri = inner.GetUri()196+ wv.uri = uri
191- wv.title = inner.GetTitle()197+ wv.title = title
192 wv.canGoBack = inner.CanGoBack()198 wv.canGoBack = inner.CanGoBack()
193 wv.canGoFwd = inner.CanGoForward()199 wv.canGoFwd = inner.CanGoForward()
194 wv.progress = inner.GetEstimatedLoadProgress()200 wv.progress = inner.GetEstimatedLoadProgress()
@@ -196,8 +202,14 @@ func (wv *WebView) connectSignals() {
196 switch event {202 switch event {
197 case webkit.LoadStartedValue:203 case webkit.LoadStartedValue:
198 wv.isLoading = true204 wv.isLoading = true
205+ wv.logger.Debug().Str("uri", uri).Msg("load started")
206+ case webkit.LoadRedirectedValue:
207+ wv.logger.Debug().Str("uri", uri).Msg("load redirected")
208+ case webkit.LoadCommittedValue:
209+ wv.logger.Debug().Str("uri", uri).Msg("load committed")
199 case webkit.LoadFinishedValue:210 case webkit.LoadFinishedValue:
200 wv.isLoading = false211 wv.isLoading = false
212+ wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
201 }213 }
202 wv.mu.Unlock()214 wv.mu.Unlock()
203 215
@@ -551,9 +563,8 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector
551 }563 }
552 564
553 if injector != nil {565 if injector != nil {
554- log.Debug().Msg("AttachFrontend: injecting scripts and styles")566+ log.Debug().Msg("AttachFrontend: injecting scripts")
555 injector.InjectScripts(ctx, wv.ucm, wv.id)567 injector.InjectScripts(ctx, wv.ucm, wv.id)
556- injector.InjectStyles(ctx, wv.ucm)
557 }568 }
558 569
559 log.Debug().Msg("frontend assets attached to webview")570 log.Debug().Msg("frontend assets attached to webview")
M internal/ui/app.go +9 -66
@@ -15,10 +15,8 @@ import (
1515 "github.com/bnema/dumber/internal/infrastructure/webkit"
1616 "github.com/bnema/dumber/internal/logging"
1717 "github.com/bnema/dumber/internal/ui/component"
18- uihandler "github.com/bnema/dumber/internal/ui/handler"
1918 "github.com/bnema/dumber/internal/ui/input"
2019 "github.com/bnema/dumber/internal/ui/layout"
21- "github.com/bnema/dumber/internal/ui/theme"
2220 "github.com/bnema/dumber/internal/ui/window"
2321 )
2422
@@ -52,7 +50,6 @@ type App struct {
5250 pool *webkit.WebViewPool
5351 injector *webkit.ContentInjector
5452 router *webkit.MessageRouter
55- overlay *component.OverlayController
5653 settings *webkit.SettingsManager
5754 webViews map[entity.PaneID]*webkit.WebView
5855
@@ -81,14 +78,10 @@ func New(deps *Dependencies) (*App, error) {
8178 pool: deps.Pool,
8279 injector: deps.Injector,
8380 router: deps.MessageRouter,
84- overlay: deps.Overlay,
8581 settings: deps.Settings,
8682 webViews: make(map[entity.PaneID]*webkit.WebView),
8783 cancel: cancel,
8884 }
89- if app.overlay == nil {
90- app.overlay = component.NewOverlayController(ctx)
91- }
9285 if app.router == nil {
9386 app.router = webkit.NewMessageRouter(ctx)
9487 }
@@ -140,15 +133,17 @@ func (a *App) onActivate(ctx context.Context) {
140133
141134 // Create the main window
142135 var err error
143- a.mainWindow, err = window.New(a.gtkApp, a.deps.Config, a.deps.Logger)
136+ a.mainWindow, err = window.New(ctx, a.gtkApp, a.deps.Config)
144137 if err != nil {
145138 log.Error().Err(err).Msg("failed to create main window")
146139 return
147140 }
148141
149- // Apply GTK CSS styling for tab bar
150- if display := a.mainWindow.Window().GetDisplay(); display != nil {
151- theme.ApplyCSS(display)
142+ // Apply GTK CSS styling from theme manager
143+ if a.deps.Theme != nil {
144+ if display := a.mainWindow.Window().GetDisplay(); display != nil {
145+ a.deps.Theme.ApplyToDisplay(ctx, display)
146+ }
152147 }
153148
154149 // Initialize widget factory for pane layout
@@ -158,7 +153,6 @@ func (a *App) onActivate(ctx context.Context) {
158153 a.keyboardHandler = input.NewKeyboardHandler(
159154 ctx,
160155 &a.deps.Config.Workspace,
161- a.deps.Logger,
162156 )
163157 a.keyboardHandler.SetOnAction(a.handleKeyboardAction)
164158 // Wrap handleModeChange to match the callback signature
@@ -184,10 +178,6 @@ func (a *App) onActivate(ctx context.Context) {
184178 log.Warn().Msg("failed to create native omnibox")
185179 }
186180
187- if err := a.registerMessageHandlers(ctx); err != nil {
188- log.Warn().Err(err).Msg("failed to register message handlers")
189- }
190-
191181 // Create an initial tab
192182 a.createInitialTab(ctx)
193183
@@ -276,9 +266,8 @@ func (a *App) Quit() {
276266 func RunWithArgs(ctx context.Context, deps *Dependencies) int {
277267 app, err := New(deps)
278268 if err != nil {
279- if deps.Logger != nil {
280- deps.Logger.Error().Err(err).Msg("failed to create application")
281- }
269+ log := logging.FromContext(ctx)
270+ log.Error().Err(err).Msg("failed to create application")
282271 return 1
283272 }
284273 return app.Run(ctx, os.Args)
@@ -491,7 +480,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
491480 }
492481
493482 // Create workspace view
494- wsView := component.NewWorkspaceView(a.widgetFactory, *a.deps.Logger)
483+ wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
495484 if wsView == nil {
496485 log.Error().Msg("failed to create workspace view")
497486 return
@@ -679,52 +668,6 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir
679668 return nil
680669 }
681670
682-// registerMessageHandlers wires frontend message types to Go handlers.
683-func (a *App) registerMessageHandlers(ctx context.Context) error {
684- if a.router == nil {
685- return fmt.Errorf("message router is nil")
686- }
687-
688- // Omnibox search
689- if err := a.router.RegisterHandlerWithCallbacks("query", "__dumber_omnibox_suggestions", "", "", uihandler.NewQueryHandler(a.deps.HistoryUC, a.deps.Config)); err != nil {
690- return err
691- }
692- if err := a.router.RegisterHandlerWithCallbacks("omnibox_initial_history", "__dumber_omnibox_suggestions", "", "", uihandler.NewInitialHistoryHandler(a.deps.HistoryUC, a.deps.Config)); err != nil {
693- return err
694- }
695- if err := a.router.RegisterHandlerWithCallbacks("prefix_query", "__dumber_omnibox_inline_suggestion", "", "", uihandler.NewPrefixQueryHandler(a.deps.HistoryUC)); err != nil {
696- return err
697- }
698-
699- // Navigation
700- if err := a.router.RegisterHandler("navigate", uihandler.NewNavigateHandler()); err != nil {
701- return err
702- }
703-
704- // Favorites
705- if err := a.router.RegisterHandlerWithCallbacks("get_favorites", "__dumber_favorites", "", "", uihandler.NewFavoritesHandler(a.deps.FavoritesUC)); err != nil {
706- return err
707- }
708- if err := a.router.RegisterHandlerWithCallbacks("toggle_favorite", "__dumber_favorites", "", "", uihandler.NewToggleFavoriteHandler(a.deps.FavoritesUC)); err != nil {
709- return err
710- }
711-
712- // Shortcuts + palettes
713- if err := a.router.RegisterHandlerWithCallbacks("get_search_shortcuts", "__dumber_search_shortcuts", "__dumber_search_shortcuts_error", "", uihandler.NewShortcutsHandler(a.deps.Config)); err != nil {
714- return err
715- }
716- if err := a.router.RegisterHandlerWithCallbacks("get_color_palettes", "__dumber_color_palettes", "__dumber_color_palettes_error", "", uihandler.NewPaletteHandler(a.deps.Config)); err != nil {
717- return err
718- }
719-
720- // Keyboard blocking
721- if err := a.router.RegisterHandler("keyboard_blocking", uihandler.NewKeyboardBlockingHandler()); err != nil {
722- return err
723- }
724-
725- return nil
726-}
727-
728671 // attachWorkspaceWebViews ensures each pane has a WebView widget attached.
729672 func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
730673 log := logging.FromContext(ctx)
@@ -15,10 +15,8 @@ import (
15 "github.com/bnema/dumber/internal/infrastructure/webkit"15 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/component"17 "github.com/bnema/dumber/internal/ui/component"
18- uihandler "github.com/bnema/dumber/internal/ui/handler"
19 "github.com/bnema/dumber/internal/ui/input"18 "github.com/bnema/dumber/internal/ui/input"
20 "github.com/bnema/dumber/internal/ui/layout"19 "github.com/bnema/dumber/internal/ui/layout"
21- "github.com/bnema/dumber/internal/ui/theme"
22 "github.com/bnema/dumber/internal/ui/window"20 "github.com/bnema/dumber/internal/ui/window"
23 )21 )
24 22
@@ -52,7 +50,6 @@ type App struct {
52 pool *webkit.WebViewPool50 pool *webkit.WebViewPool
53 injector *webkit.ContentInjector51 injector *webkit.ContentInjector
54 router *webkit.MessageRouter52 router *webkit.MessageRouter
55- overlay *component.OverlayController
56 settings *webkit.SettingsManager53 settings *webkit.SettingsManager
57 webViews map[entity.PaneID]*webkit.WebView54 webViews map[entity.PaneID]*webkit.WebView
58 55
@@ -81,14 +78,10 @@ func New(deps *Dependencies) (*App, error) {
81 pool: deps.Pool,78 pool: deps.Pool,
82 injector: deps.Injector,79 injector: deps.Injector,
83 router: deps.MessageRouter,80 router: deps.MessageRouter,
84- overlay: deps.Overlay,
85 settings: deps.Settings,81 settings: deps.Settings,
86 webViews: make(map[entity.PaneID]*webkit.WebView),82 webViews: make(map[entity.PaneID]*webkit.WebView),
87 cancel: cancel,83 cancel: cancel,
88 }84 }
89- if app.overlay == nil {
90- app.overlay = component.NewOverlayController(ctx)
91- }
92 if app.router == nil {85 if app.router == nil {
93 app.router = webkit.NewMessageRouter(ctx)86 app.router = webkit.NewMessageRouter(ctx)
94 }87 }
@@ -140,15 +133,17 @@ func (a *App) onActivate(ctx context.Context) {
140 133
141 // Create the main window134 // Create the main window
142 var err error135 var err error
143- a.mainWindow, err = window.New(a.gtkApp, a.deps.Config, a.deps.Logger)136+ a.mainWindow, err = window.New(ctx, a.gtkApp, a.deps.Config)
144 if err != nil {137 if err != nil {
145 log.Error().Err(err).Msg("failed to create main window")138 log.Error().Err(err).Msg("failed to create main window")
146 return139 return
147 }140 }
148 141
149- // Apply GTK CSS styling for tab bar142+ // Apply GTK CSS styling from theme manager
150- if display := a.mainWindow.Window().GetDisplay(); display != nil {143+ if a.deps.Theme != nil {
151- theme.ApplyCSS(display)144+ if display := a.mainWindow.Window().GetDisplay(); display != nil {
145+ a.deps.Theme.ApplyToDisplay(ctx, display)
146+ }
152 }147 }
153 148
154 // Initialize widget factory for pane layout149 // Initialize widget factory for pane layout
@@ -158,7 +153,6 @@ func (a *App) onActivate(ctx context.Context) {
158 a.keyboardHandler = input.NewKeyboardHandler(153 a.keyboardHandler = input.NewKeyboardHandler(
159 ctx,154 ctx,
160 &a.deps.Config.Workspace,155 &a.deps.Config.Workspace,
161- a.deps.Logger,
162 )156 )
163 a.keyboardHandler.SetOnAction(a.handleKeyboardAction)157 a.keyboardHandler.SetOnAction(a.handleKeyboardAction)
164 // Wrap handleModeChange to match the callback signature158 // Wrap handleModeChange to match the callback signature
@@ -184,10 +178,6 @@ func (a *App) onActivate(ctx context.Context) {
184 log.Warn().Msg("failed to create native omnibox")178 log.Warn().Msg("failed to create native omnibox")
185 }179 }
186 180
187- if err := a.registerMessageHandlers(ctx); err != nil {
188- log.Warn().Err(err).Msg("failed to register message handlers")
189- }
190-
191 // Create an initial tab181 // Create an initial tab
192 a.createInitialTab(ctx)182 a.createInitialTab(ctx)
193 183
@@ -276,9 +266,8 @@ func (a *App) Quit() {
276 func RunWithArgs(ctx context.Context, deps *Dependencies) int {266 func RunWithArgs(ctx context.Context, deps *Dependencies) int {
277 app, err := New(deps)267 app, err := New(deps)
278 if err != nil {268 if err != nil {
279- if deps.Logger != nil {269+ log := logging.FromContext(ctx)
280- deps.Logger.Error().Err(err).Msg("failed to create application")270+ log.Error().Err(err).Msg("failed to create application")
281- }
282 return 1271 return 1
283 }272 }
284 return app.Run(ctx, os.Args)273 return app.Run(ctx, os.Args)
@@ -491,7 +480,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
491 }480 }
492 481
493 // Create workspace view482 // Create workspace view
494- wsView := component.NewWorkspaceView(a.widgetFactory, *a.deps.Logger)483+ wsView := component.NewWorkspaceView(ctx, a.widgetFactory)
495 if wsView == nil {484 if wsView == nil {
496 log.Error().Msg("failed to create workspace view")485 log.Error().Msg("failed to create workspace view")
497 return486 return
@@ -679,52 +668,6 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir
679 return nil668 return nil
680 }669 }
681 670
682-// registerMessageHandlers wires frontend message types to Go handlers.
683-func (a *App) registerMessageHandlers(ctx context.Context) error {
684- if a.router == nil {
685- return fmt.Errorf("message router is nil")
686- }
687-
688- // Omnibox search
689- if err := a.router.RegisterHandlerWithCallbacks("query", "__dumber_omnibox_suggestions", "", "", uihandler.NewQueryHandler(a.deps.HistoryUC, a.deps.Config)); err != nil {
690- return err
691- }
692- if err := a.router.RegisterHandlerWithCallbacks("omnibox_initial_history", "__dumber_omnibox_suggestions", "", "", uihandler.NewInitialHistoryHandler(a.deps.HistoryUC, a.deps.Config)); err != nil {
693- return err
694- }
695- if err := a.router.RegisterHandlerWithCallbacks("prefix_query", "__dumber_omnibox_inline_suggestion", "", "", uihandler.NewPrefixQueryHandler(a.deps.HistoryUC)); err != nil {
696- return err
697- }
698-
699- // Navigation
700- if err := a.router.RegisterHandler("navigate", uihandler.NewNavigateHandler()); err != nil {
701- return err
702- }
703-
704- // Favorites
705- if err := a.router.RegisterHandlerWithCallbacks("get_favorites", "__dumber_favorites", "", "", uihandler.NewFavoritesHandler(a.deps.FavoritesUC)); err != nil {
706- return err
707- }
708- if err := a.router.RegisterHandlerWithCallbacks("toggle_favorite", "__dumber_favorites", "", "", uihandler.NewToggleFavoriteHandler(a.deps.FavoritesUC)); err != nil {
709- return err
710- }
711-
712- // Shortcuts + palettes
713- if err := a.router.RegisterHandlerWithCallbacks("get_search_shortcuts", "__dumber_search_shortcuts", "__dumber_search_shortcuts_error", "", uihandler.NewShortcutsHandler(a.deps.Config)); err != nil {
714- return err
715- }
716- if err := a.router.RegisterHandlerWithCallbacks("get_color_palettes", "__dumber_color_palettes", "__dumber_color_palettes_error", "", uihandler.NewPaletteHandler(a.deps.Config)); err != nil {
717- return err
718- }
719-
720- // Keyboard blocking
721- if err := a.router.RegisterHandler("keyboard_blocking", uihandler.NewKeyboardBlockingHandler()); err != nil {
722- return err
723- }
724-
725- return nil
726-}
727-
728 // attachWorkspaceWebViews ensures each pane has a WebView widget attached.671 // attachWorkspaceWebViews ensures each pane has a WebView widget attached.
729 func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {672 func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
730 log := logging.FromContext(ctx)673 log := logging.FromContext(ctx)
M internal/ui/component/omnibox.go +43 -45
@@ -17,7 +17,7 @@ import (
1717 const (
1818 omniboxWidth = 600
1919 omniboxMaxResults = 10
20- debounceDelayMs = 120
20+ debounceDelayMs = 250 // Increased for stability
2121 )
2222
2323 // ViewMode distinguishes history search from favorites display.
@@ -82,6 +82,7 @@ type Omnibox struct {
8282 // Debouncing
8383 debounceTimer *time.Timer
8484 debounceMu sync.Mutex
85+ lastQuery string // Prevent duplicate searches
8586 }
8687
8788 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -331,11 +332,26 @@ func (o *Omnibox) onEntryChanged() {
331332 // performSearch executes the search based on current view mode and query.
332333 func (o *Omnibox) performSearch() {
333334 o.mu.RLock()
335+ visible := o.visible
334336 mode := o.viewMode
335337 o.mu.RUnlock()
336338
339+ // Skip search if omnibox is hidden
340+ if !visible {
341+ return
342+ }
343+
337344 query := o.entry.GetText()
338345
346+ // Skip duplicate queries
347+ o.debounceMu.Lock()
348+ if query == o.lastQuery && query != "" {
349+ o.debounceMu.Unlock()
350+ return
351+ }
352+ o.lastQuery = query
353+ o.debounceMu.Unlock()
354+
339355 if mode == ViewModeFavorites {
340356 o.loadFavorites(query)
341357 return
@@ -352,12 +368,9 @@ func (o *Omnibox) performSearch() {
352368 ctx := o.ctx
353369 log := logging.FromContext(ctx)
354370 if o.historyUC == nil {
355- log.Debug().Msg("omnibox search: historyUC is nil")
356371 return
357372 }
358373
359- log.Debug().Str("query", query).Int("limit", omniboxMaxResults).Msg("omnibox performing history search")
360-
361374 input := usecase.SearchInput{
362375 Query: query,
363376 Limit: omniboxMaxResults,
@@ -368,8 +381,6 @@ func (o *Omnibox) performSearch() {
368381 return
369382 }
370383
371- log.Debug().Int("results", len(output.Matches)).Str("query", query).Msg("omnibox search completed")
372-
373384 suggestions := make([]Suggestion, 0, len(output.Matches))
374385 for _, r := range output.Matches {
375386 suggestions = append(suggestions, Suggestion{
@@ -394,18 +405,13 @@ func (o *Omnibox) loadInitialHistory() {
394405 log := logging.FromContext(ctx)
395406 var suggestions []Suggestion
396407
397- log.Debug().Str("behavior", o.initialBehavior).Msg("omnibox loading initial history")
398-
399408 switch o.initialBehavior {
400409 case "none":
401- // Show nothing
402- log.Debug().Msg("omnibox initial behavior: none")
403410 o.idleAddUpdateSuggestions(nil)
404411 return
405412
406413 case "most_visited":
407414 // TODO: Implement GetMostVisited in use case if needed
408- // For now, fall through to recent
409415 fallthrough
410416
411417 case "recent", "":
@@ -414,7 +420,6 @@ func (o *Omnibox) loadInitialHistory() {
414420 log.Error().Err(err).Msg("failed to load recent history")
415421 return
416422 }
417- log.Debug().Int("results", len(results)).Msg("omnibox loaded recent history")
418423 suggestions = make([]Suggestion, 0, len(results))
419424 for _, r := range results {
420425 suggestions = append(suggestions, Suggestion{
@@ -543,8 +548,9 @@ func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
543548 if displayText == "" {
544549 displayText = s.URL
545550 }
546- label := gtk.NewLabel(&displayText)
551+ label := gtk.NewLabel(nil)
547552 if label != nil {
553+ label.SetText(displayText) // Explicitly set text
548554 label.AddCssClass("omnibox-suggestion-title")
549555 label.SetHalign(gtk.AlignStartValue)
550556 label.SetHexpand(true)
@@ -552,19 +558,16 @@ func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
552558 box.Append(&label.Widget)
553559 }
554560
555- // Shortcut label (Ctrl+1-9)
556- if index < 9 {
557- shortcutText := formatShortcut(index + 1)
558- shortcutLabel := gtk.NewLabel(&shortcutText)
561+ // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
562+ if index <= 9 {
563+ shortcutLabel := gtk.NewLabel(nil)
559564 if shortcutLabel != nil {
560- shortcutLabel.AddCssClass("omnibox-shortcut-label")
561- box.Append(&shortcutLabel.Widget)
562- }
563- } else if index == 9 {
564- shortcutText := "Ctrl+0"
565- shortcutLabel := gtk.NewLabel(&shortcutText)
566- if shortcutLabel != nil {
567- shortcutLabel.AddCssClass("omnibox-shortcut-label")
565+ if index < 9 {
566+ shortcutLabel.SetText(formatShortcut(index + 1))
567+ } else {
568+ shortcutLabel.SetText("Ctrl+0")
569+ }
570+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
568571 box.Append(&shortcutLabel.Widget)
569572 }
570573 }
@@ -592,8 +595,9 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
592595 if displayText == "" {
593596 displayText = f.URL
594597 }
595- label := gtk.NewLabel(&displayText)
598+ label := gtk.NewLabel(nil)
596599 if label != nil {
600+ label.SetText(displayText)
597601 label.AddCssClass("omnibox-suggestion-title")
598602 label.SetHalign(gtk.AlignStartValue)
599603 label.SetHexpand(true)
@@ -601,19 +605,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
601605 box.Append(&label.Widget)
602606 }
603607
604- // Shortcut label
605- if index < 9 {
606- shortcutText := formatShortcut(index + 1)
607- shortcutLabel := gtk.NewLabel(&shortcutText)
608+ // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
609+ if index <= 9 {
610+ shortcutLabel := gtk.NewLabel(nil)
608611 if shortcutLabel != nil {
609- shortcutLabel.AddCssClass("omnibox-shortcut-label")
610- box.Append(&shortcutLabel.Widget)
611- }
612- } else if index == 9 {
613- shortcutText := "Ctrl+0"
614- shortcutLabel := gtk.NewLabel(&shortcutText)
615- if shortcutLabel != nil {
616- shortcutLabel.AddCssClass("omnibox-shortcut-label")
612+ if index < 9 {
613+ shortcutLabel.SetText(formatShortcut(index + 1))
614+ } else {
615+ shortcutLabel.SetText("Ctrl+0")
616+ }
617+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
617618 box.Append(&shortcutLabel.Widget)
618619 }
619620 }
@@ -624,13 +625,12 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
624625
625626 // selectIndex selects a row by index.
626627 func (o *Omnibox) selectIndex(index int) {
627- o.mu.Lock()
628- defer o.mu.Unlock()
629-
628+ // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
629+ // which also acquires the mutex, causing deadlock
630630 row := o.listBox.GetRowAtIndex(index)
631631 if row != nil {
632632 o.listBox.SelectRow(row)
633- o.selectedIndex = index
633+ // selectedIndex is updated by rowSelectedCb
634634 }
635635 }
636636
@@ -877,11 +877,9 @@ func (o *Omnibox) SetOnClose(fn func()) {
877877
878878 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
879879 func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
880- // Store suggestions in a channel to pass to idle callback
881- // Using a closure that captures the suggestions
882880 var cb glib.SourceFunc = func(data uintptr) bool {
883881 o.updateSuggestions(suggestions)
884- return false // One-shot callback, return false to remove source
882+ return false // One-shot callback
885883 }
886884 glib.IdleAdd(&cb, 0)
887885 }
@@ -17,7 +17,7 @@ import (
17 const (17 const (
18 omniboxWidth = 60018 omniboxWidth = 600
19 omniboxMaxResults = 1019 omniboxMaxResults = 10
20- debounceDelayMs = 12020+ debounceDelayMs = 250 // Increased for stability
21 )21 )
22 22
23 // ViewMode distinguishes history search from favorites display.23 // ViewMode distinguishes history search from favorites display.
@@ -82,6 +82,7 @@ type Omnibox struct {
82 // Debouncing82 // Debouncing
83 debounceTimer *time.Timer83 debounceTimer *time.Timer
84 debounceMu sync.Mutex84 debounceMu sync.Mutex
85+ lastQuery string // Prevent duplicate searches
85 }86 }
86 87
87 // OmniboxConfig holds configuration for creating an Omnibox.88 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -331,11 +332,26 @@ func (o *Omnibox) onEntryChanged() {
331 // performSearch executes the search based on current view mode and query.332 // performSearch executes the search based on current view mode and query.
332 func (o *Omnibox) performSearch() {333 func (o *Omnibox) performSearch() {
333 o.mu.RLock()334 o.mu.RLock()
335+ visible := o.visible
334 mode := o.viewMode336 mode := o.viewMode
335 o.mu.RUnlock()337 o.mu.RUnlock()
336 338
339+ // Skip search if omnibox is hidden
340+ if !visible {
341+ return
342+ }
343+
337 query := o.entry.GetText()344 query := o.entry.GetText()
338 345
346+ // Skip duplicate queries
347+ o.debounceMu.Lock()
348+ if query == o.lastQuery && query != "" {
349+ o.debounceMu.Unlock()
350+ return
351+ }
352+ o.lastQuery = query
353+ o.debounceMu.Unlock()
354+
339 if mode == ViewModeFavorites {355 if mode == ViewModeFavorites {
340 o.loadFavorites(query)356 o.loadFavorites(query)
341 return357 return
@@ -352,12 +368,9 @@ func (o *Omnibox) performSearch() {
352 ctx := o.ctx368 ctx := o.ctx
353 log := logging.FromContext(ctx)369 log := logging.FromContext(ctx)
354 if o.historyUC == nil {370 if o.historyUC == nil {
355- log.Debug().Msg("omnibox search: historyUC is nil")
356 return371 return
357 }372 }
358 373
359- log.Debug().Str("query", query).Int("limit", omniboxMaxResults).Msg("omnibox performing history search")
360-
361 input := usecase.SearchInput{374 input := usecase.SearchInput{
362 Query: query,375 Query: query,
363 Limit: omniboxMaxResults,376 Limit: omniboxMaxResults,
@@ -368,8 +381,6 @@ func (o *Omnibox) performSearch() {
368 return381 return
369 }382 }
370 383
371- log.Debug().Int("results", len(output.Matches)).Str("query", query).Msg("omnibox search completed")
372-
373 suggestions := make([]Suggestion, 0, len(output.Matches))384 suggestions := make([]Suggestion, 0, len(output.Matches))
374 for _, r := range output.Matches {385 for _, r := range output.Matches {
375 suggestions = append(suggestions, Suggestion{386 suggestions = append(suggestions, Suggestion{
@@ -394,18 +405,13 @@ func (o *Omnibox) loadInitialHistory() {
394 log := logging.FromContext(ctx)405 log := logging.FromContext(ctx)
395 var suggestions []Suggestion406 var suggestions []Suggestion
396 407
397- log.Debug().Str("behavior", o.initialBehavior).Msg("omnibox loading initial history")
398-
399 switch o.initialBehavior {408 switch o.initialBehavior {
400 case "none":409 case "none":
401- // Show nothing
402- log.Debug().Msg("omnibox initial behavior: none")
403 o.idleAddUpdateSuggestions(nil)410 o.idleAddUpdateSuggestions(nil)
404 return411 return
405 412
406 case "most_visited":413 case "most_visited":
407 // TODO: Implement GetMostVisited in use case if needed414 // TODO: Implement GetMostVisited in use case if needed
408- // For now, fall through to recent
409 fallthrough415 fallthrough
410 416
411 case "recent", "":417 case "recent", "":
@@ -414,7 +420,6 @@ func (o *Omnibox) loadInitialHistory() {
414 log.Error().Err(err).Msg("failed to load recent history")420 log.Error().Err(err).Msg("failed to load recent history")
415 return421 return
416 }422 }
417- log.Debug().Int("results", len(results)).Msg("omnibox loaded recent history")
418 suggestions = make([]Suggestion, 0, len(results))423 suggestions = make([]Suggestion, 0, len(results))
419 for _, r := range results {424 for _, r := range results {
420 suggestions = append(suggestions, Suggestion{425 suggestions = append(suggestions, Suggestion{
@@ -543,8 +548,9 @@ func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
543 if displayText == "" {548 if displayText == "" {
544 displayText = s.URL549 displayText = s.URL
545 }550 }
546- label := gtk.NewLabel(&displayText)551+ label := gtk.NewLabel(nil)
547 if label != nil {552 if label != nil {
553+ label.SetText(displayText) // Explicitly set text
548 label.AddCssClass("omnibox-suggestion-title")554 label.AddCssClass("omnibox-suggestion-title")
549 label.SetHalign(gtk.AlignStartValue)555 label.SetHalign(gtk.AlignStartValue)
550 label.SetHexpand(true)556 label.SetHexpand(true)
@@ -552,19 +558,16 @@ func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
552 box.Append(&label.Widget)558 box.Append(&label.Widget)
553 }559 }
554 560
555- // Shortcut label (Ctrl+1-9)561+ // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
556- if index < 9 {562+ if index <= 9 {
557- shortcutText := formatShortcut(index + 1)563+ shortcutLabel := gtk.NewLabel(nil)
558- shortcutLabel := gtk.NewLabel(&shortcutText)
559 if shortcutLabel != nil {564 if shortcutLabel != nil {
560- shortcutLabel.AddCssClass("omnibox-shortcut-label")565+ if index < 9 {
561- box.Append(&shortcutLabel.Widget)566+ shortcutLabel.SetText(formatShortcut(index + 1))
562- }567+ } else {
563- } else if index == 9 {568+ shortcutLabel.SetText("Ctrl+0")
564- shortcutText := "Ctrl+0"569+ }
565- shortcutLabel := gtk.NewLabel(&shortcutText)570+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
566- if shortcutLabel != nil {
567- shortcutLabel.AddCssClass("omnibox-shortcut-label")
568 box.Append(&shortcutLabel.Widget)571 box.Append(&shortcutLabel.Widget)
569 }572 }
570 }573 }
@@ -592,8 +595,9 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
592 if displayText == "" {595 if displayText == "" {
593 displayText = f.URL596 displayText = f.URL
594 }597 }
595- label := gtk.NewLabel(&displayText)598+ label := gtk.NewLabel(nil)
596 if label != nil {599 if label != nil {
600+ label.SetText(displayText)
597 label.AddCssClass("omnibox-suggestion-title")601 label.AddCssClass("omnibox-suggestion-title")
598 label.SetHalign(gtk.AlignStartValue)602 label.SetHalign(gtk.AlignStartValue)
599 label.SetHexpand(true)603 label.SetHexpand(true)
@@ -601,19 +605,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
601 box.Append(&label.Widget)605 box.Append(&label.Widget)
602 }606 }
603 607
604- // Shortcut label608+ // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
605- if index < 9 {609+ if index <= 9 {
606- shortcutText := formatShortcut(index + 1)610+ shortcutLabel := gtk.NewLabel(nil)
607- shortcutLabel := gtk.NewLabel(&shortcutText)
608 if shortcutLabel != nil {611 if shortcutLabel != nil {
609- shortcutLabel.AddCssClass("omnibox-shortcut-label")612+ if index < 9 {
610- box.Append(&shortcutLabel.Widget)613+ shortcutLabel.SetText(formatShortcut(index + 1))
611- }614+ } else {
612- } else if index == 9 {615+ shortcutLabel.SetText("Ctrl+0")
613- shortcutText := "Ctrl+0"616+ }
614- shortcutLabel := gtk.NewLabel(&shortcutText)617+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
615- if shortcutLabel != nil {
616- shortcutLabel.AddCssClass("omnibox-shortcut-label")
617 box.Append(&shortcutLabel.Widget)618 box.Append(&shortcutLabel.Widget)
618 }619 }
619 }620 }
@@ -624,13 +625,12 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
624 625
625 // selectIndex selects a row by index.626 // selectIndex selects a row by index.
626 func (o *Omnibox) selectIndex(index int) {627 func (o *Omnibox) selectIndex(index int) {
627- o.mu.Lock()628+ // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
628- defer o.mu.Unlock()629+ // which also acquires the mutex, causing deadlock
629-
630 row := o.listBox.GetRowAtIndex(index)630 row := o.listBox.GetRowAtIndex(index)
631 if row != nil {631 if row != nil {
632 o.listBox.SelectRow(row)632 o.listBox.SelectRow(row)
633- o.selectedIndex = index633+ // selectedIndex is updated by rowSelectedCb
634 }634 }
635 }635 }
636 636
@@ -877,11 +877,9 @@ func (o *Omnibox) SetOnClose(fn func()) {
877 877
878 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.878 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
879 func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {879 func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
880- // Store suggestions in a channel to pass to idle callback
881- // Using a closure that captures the suggestions
882 var cb glib.SourceFunc = func(data uintptr) bool {880 var cb glib.SourceFunc = func(data uintptr) bool {
883 o.updateSuggestions(suggestions)881 o.updateSuggestions(suggestions)
884- return false // One-shot callback, return false to remove source882+ return false // One-shot callback
885 }883 }
886 glib.IdleAdd(&cb, 0)884 glib.IdleAdd(&cb, 0)
887 }885 }
D internal/ui/component/overlay.go +0 -171
@@ -1,171 +0,0 @@
1-package component
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "sync"
8-
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// overlayState tracks the current overlay status for a WebView.
14-type overlayState struct {
15- visible bool
16- mode string
17-}
18-
19-// OverlayController controls the injected overlay UI (omnibox/find).
20-// It issues JavaScript calls into the WebView to show/hide the overlay.
21-type OverlayController struct {
22- mu sync.RWMutex
23- states map[webkit.WebViewID]overlayState
24-}
25-
26-// NewOverlayController creates a new overlay controller.
27-func NewOverlayController(ctx context.Context) *OverlayController {
28- log := logging.FromContext(ctx)
29- log.Debug().Msg("overlay controller initialized")
30- return &OverlayController{
31- states: make(map[webkit.WebViewID]overlayState),
32- }
33-}
34-
35-// Show opens the overlay in the given mode ("omnibox" default or "find").
36-// An optional query seeds the overlay input.
37-func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewID, mode, query string) error {
38- if mode == "" {
39- mode = "omnibox"
40- }
41-
42- log := logging.FromContext(ctx).With().
43- Str("component", "overlay-controller").
44- Uint64("webview_id", uint64(webviewID)).
45- Str("mode", mode).
46- Logger()
47-
48- modeJSON, err := json.Marshal(mode)
49- if err != nil {
50- return fmt.Errorf("marshal mode: %w", err)
51- }
52- queryJSON, err := json.Marshal(query)
53- if err != nil {
54- return fmt.Errorf("marshal query: %w", err)
55- }
56-
57- script := fmt.Sprintf(`(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open(%s,%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay open: APIs unavailable");}}catch(e){console.error("dumber overlay open failed",e);}})();`, string(modeJSON), string(queryJSON))
58-
59- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
60- log.Warn().Err(err).Msg("failed to show overlay")
61- return err
62- }
63-
64- oc.setState(webviewID, overlayState{visible: true, mode: mode})
65- log.Debug().Msg("overlay show requested")
66- return nil
67-}
68-
69-// Hide closes the overlay if present.
70-func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewID) error {
71- log := logging.FromContext(ctx).With().
72- Str("component", "overlay-controller").
73- Uint64("webview_id", uint64(webviewID)).
74- Logger()
75-
76- script := `(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.close==="function"){window.__dumber_omnibox.close();return;}if(typeof window.__dumber_find_close==="function"){window.__dumber_find_close();return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay close: APIs unavailable");}}catch(e){console.error("dumber overlay close failed",e);}})();`
77-
78- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
79- log.Warn().Err(err).Msg("failed to hide overlay")
80- return err
81- }
82-
83- oc.setState(webviewID, overlayState{visible: false})
84- log.Debug().Msg("overlay hide requested")
85- return nil
86-}
87-
88-// Toggle toggles the overlay visibility.
89-func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebViewID) error {
90- log := logging.FromContext(ctx).With().
91- Str("component", "overlay-controller").
92- Uint64("webview_id", uint64(webviewID)).
93- Logger()
94-
95- script := `(function(){try{if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.toggle==="function"){window.__dumber_omnibox.toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("omnibox","");return;}console.warn("dumber overlay toggle: APIs unavailable");}catch(e){console.error("dumber overlay toggle failed",e);}})();`
96-
97- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
98- log.Warn().Err(err).Msg("failed to toggle overlay")
99- return err
100- }
101-
102- // Flip current state best-effort
103- current := oc.getState(webviewID)
104- oc.setState(webviewID, overlayState{visible: !current.visible, mode: current.mode})
105- log.Debug().Bool("visible", !current.visible).Msg("overlay toggle requested")
106- return nil
107-}
108-
109-// OpenFind opens the find-in-page overlay with an optional query.
110-func (oc *OverlayController) OpenFind(ctx context.Context, webviewID webkit.WebViewID, query string) error {
111- log := logging.FromContext(ctx).With().
112- Str("component", "overlay-controller").
113- Uint64("webview_id", uint64(webviewID)).
114- Logger()
115-
116- queryJSON, err := json.Marshal(query)
117- if err != nil {
118- return fmt.Errorf("marshal find query: %w", err)
119- }
120-
121- script := fmt.Sprintf(`(function(){try{if(typeof window.__dumber_find_open==="function"){window.__dumber_find_open(%s);return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("find",%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}console.warn("dumber find open: APIs unavailable");}catch(e){console.error("dumber find open failed",e);}})();`, string(queryJSON), string(queryJSON))
122-
123- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
124- log.Warn().Err(err).Msg("failed to open find overlay")
125- return err
126- }
127-
128- oc.setState(webviewID, overlayState{visible: true, mode: "find"})
129- log.Debug().Msg("find overlay requested")
130- return nil
131-}
132-
133-// State returns the tracked overlay state for the given WebView.
134-func (oc *OverlayController) State(webviewID webkit.WebViewID) overlayState {
135- return oc.getState(webviewID)
136-}
137-
138-func (oc *OverlayController) setState(webviewID webkit.WebViewID, state overlayState) {
139- oc.mu.Lock()
140- defer oc.mu.Unlock()
141- oc.states[webviewID] = state
142-}
143-
144-func (oc *OverlayController) getState(webviewID webkit.WebViewID) overlayState {
145- oc.mu.RLock()
146- defer oc.mu.RUnlock()
147-
148- if state, ok := oc.states[webviewID]; ok {
149- return state
150- }
151- return overlayState{}
152-}
153-
154-// runJavaScript executes script in the isolated "dumber" world for the given WebView.
155-// This is fire-and-forget: errors are logged asynchronously by the WebView.
156-func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit.WebViewID, script string) error {
157- log := logging.FromContext(ctx)
158- if webviewID == 0 {
159- log.Debug().Msg("runJavaScript called with webviewID=0")
160- return fmt.Errorf("webview id is required for overlay command")
161- }
162-
163- wv := webkit.LookupWebView(webviewID)
164- if wv == nil {
165- return fmt.Errorf("webview %d not found", webviewID)
166- }
167-
168- // Run in the isolated "dumber" world where the GUI scripts are injected
169- wv.RunJavaScript(ctx, script, webkit.ScriptWorldName)
170- return nil
171-}
@@ -1,171 +0,0 @@
1-package component
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "sync"
8-
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// overlayState tracks the current overlay status for a WebView.
14-type overlayState struct {
15- visible bool
16- mode string
17-}
18-
19-// OverlayController controls the injected overlay UI (omnibox/find).
20-// It issues JavaScript calls into the WebView to show/hide the overlay.
21-type OverlayController struct {
22- mu sync.RWMutex
23- states map[webkit.WebViewID]overlayState
24-}
25-
26-// NewOverlayController creates a new overlay controller.
27-func NewOverlayController(ctx context.Context) *OverlayController {
28- log := logging.FromContext(ctx)
29- log.Debug().Msg("overlay controller initialized")
30- return &OverlayController{
31- states: make(map[webkit.WebViewID]overlayState),
32- }
33-}
34-
35-// Show opens the overlay in the given mode ("omnibox" default or "find").
36-// An optional query seeds the overlay input.
37-func (oc *OverlayController) Show(ctx context.Context, webviewID webkit.WebViewID, mode, query string) error {
38- if mode == "" {
39- mode = "omnibox"
40- }
41-
42- log := logging.FromContext(ctx).With().
43- Str("component", "overlay-controller").
44- Uint64("webview_id", uint64(webviewID)).
45- Str("mode", mode).
46- Logger()
47-
48- modeJSON, err := json.Marshal(mode)
49- if err != nil {
50- return fmt.Errorf("marshal mode: %w", err)
51- }
52- queryJSON, err := json.Marshal(query)
53- if err != nil {
54- return fmt.Errorf("marshal query: %w", err)
55- }
56-
57- script := fmt.Sprintf(`(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open(%s,%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay open: APIs unavailable");}}catch(e){console.error("dumber overlay open failed",e);}})();`, string(modeJSON), string(queryJSON))
58-
59- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
60- log.Warn().Err(err).Msg("failed to show overlay")
61- return err
62- }
63-
64- oc.setState(webviewID, overlayState{visible: true, mode: mode})
65- log.Debug().Msg("overlay show requested")
66- return nil
67-}
68-
69-// Hide closes the overlay if present.
70-func (oc *OverlayController) Hide(ctx context.Context, webviewID webkit.WebViewID) error {
71- log := logging.FromContext(ctx).With().
72- Str("component", "overlay-controller").
73- Uint64("webview_id", uint64(webviewID)).
74- Logger()
75-
76- script := `(function(){try{if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.close==="function"){window.__dumber_omnibox.close();return;}if(typeof window.__dumber_find_close==="function"){window.__dumber_find_close();return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();}else{console.warn("dumber overlay close: APIs unavailable");}}catch(e){console.error("dumber overlay close failed",e);}})();`
77-
78- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
79- log.Warn().Err(err).Msg("failed to hide overlay")
80- return err
81- }
82-
83- oc.setState(webviewID, overlayState{visible: false})
84- log.Debug().Msg("overlay hide requested")
85- return nil
86-}
87-
88-// Toggle toggles the overlay visibility.
89-func (oc *OverlayController) Toggle(ctx context.Context, webviewID webkit.WebViewID) error {
90- log := logging.FromContext(ctx).With().
91- Str("component", "overlay-controller").
92- Uint64("webview_id", uint64(webviewID)).
93- Logger()
94-
95- script := `(function(){try{if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.toggle==="function"){window.__dumber_omnibox.toggle();return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("omnibox","");return;}console.warn("dumber overlay toggle: APIs unavailable");}catch(e){console.error("dumber overlay toggle failed",e);}})();`
96-
97- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
98- log.Warn().Err(err).Msg("failed to toggle overlay")
99- return err
100- }
101-
102- // Flip current state best-effort
103- current := oc.getState(webviewID)
104- oc.setState(webviewID, overlayState{visible: !current.visible, mode: current.mode})
105- log.Debug().Bool("visible", !current.visible).Msg("overlay toggle requested")
106- return nil
107-}
108-
109-// OpenFind opens the find-in-page overlay with an optional query.
110-func (oc *OverlayController) OpenFind(ctx context.Context, webviewID webkit.WebViewID, query string) error {
111- log := logging.FromContext(ctx).With().
112- Str("component", "overlay-controller").
113- Uint64("webview_id", uint64(webviewID)).
114- Logger()
115-
116- queryJSON, err := json.Marshal(query)
117- if err != nil {
118- return fmt.Errorf("marshal find query: %w", err)
119- }
120-
121- script := fmt.Sprintf(`(function(){try{if(typeof window.__dumber_find_open==="function"){window.__dumber_find_open(%s);return;}if(window.__dumber_omnibox&&typeof window.__dumber_omnibox.open==="function"){window.__dumber_omnibox.open("find",%s);return;}if(typeof window.__dumber_toggle==="function"){window.__dumber_toggle();return;}console.warn("dumber find open: APIs unavailable");}catch(e){console.error("dumber find open failed",e);}})();`, string(queryJSON), string(queryJSON))
122-
123- if err := oc.runJavaScript(ctx, webviewID, script); err != nil {
124- log.Warn().Err(err).Msg("failed to open find overlay")
125- return err
126- }
127-
128- oc.setState(webviewID, overlayState{visible: true, mode: "find"})
129- log.Debug().Msg("find overlay requested")
130- return nil
131-}
132-
133-// State returns the tracked overlay state for the given WebView.
134-func (oc *OverlayController) State(webviewID webkit.WebViewID) overlayState {
135- return oc.getState(webviewID)
136-}
137-
138-func (oc *OverlayController) setState(webviewID webkit.WebViewID, state overlayState) {
139- oc.mu.Lock()
140- defer oc.mu.Unlock()
141- oc.states[webviewID] = state
142-}
143-
144-func (oc *OverlayController) getState(webviewID webkit.WebViewID) overlayState {
145- oc.mu.RLock()
146- defer oc.mu.RUnlock()
147-
148- if state, ok := oc.states[webviewID]; ok {
149- return state
150- }
151- return overlayState{}
152-}
153-
154-// runJavaScript executes script in the isolated "dumber" world for the given WebView.
155-// This is fire-and-forget: errors are logged asynchronously by the WebView.
156-func (oc *OverlayController) runJavaScript(ctx context.Context, webviewID webkit.WebViewID, script string) error {
157- log := logging.FromContext(ctx)
158- if webviewID == 0 {
159- log.Debug().Msg("runJavaScript called with webviewID=0")
160- return fmt.Errorf("webview id is required for overlay command")
161- }
162-
163- wv := webkit.LookupWebView(webviewID)
164- if wv == nil {
165- return fmt.Errorf("webview %d not found", webviewID)
166- }
167-
168- // Run in the isolated "dumber" world where the GUI scripts are injected
169- wv.RunJavaScript(ctx, script, webkit.ScriptWorldName)
170- return nil
171-}
M internal/ui/component/workspace_view.go +7 -3
@@ -2,10 +2,12 @@
22 package component
33
44 import (
5+ "context"
56 "errors"
67 "sync"
78
89 "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
911 "github.com/bnema/dumber/internal/ui/layout"
1012 "github.com/rs/zerolog"
1113 )
@@ -67,7 +69,9 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
6769 }
6870
6971 // NewWorkspaceView creates a new workspace view.
70-func NewWorkspaceView(factory layout.WidgetFactory, logger zerolog.Logger) *WorkspaceView {
72+func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *WorkspaceView {
73+ log := logging.FromContext(ctx)
74+
7175 container := factory.NewBox(layout.OrientationVertical, 0)
7276 container.SetHexpand(true)
7377 container.SetVexpand(true)
@@ -76,12 +80,12 @@ func NewWorkspaceView(factory layout.WidgetFactory, logger zerolog.Logger) *Work
7680 wv := &WorkspaceView{
7781 factory: factory,
7882 container: container,
79- logger: logger.With().Str("component", "workspace-view").Logger(),
83+ logger: log.With().Str("component", "workspace-view").Logger(),
8084 paneViews: make(map[entity.PaneID]*PaneView),
8185 }
8286
8387 // Create tree renderer with our adapter as the pane view factory
84- wv.treeRenderer = layout.NewTreeRenderer(factory, &paneViewFactoryAdapter{wv: wv}, wv.logger)
88+ wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv})
8589
8690 return wv
8791 }
@@ -2,10 +2,12 @@
2 package component2 package component
3 3
4 import (4 import (
5+ "context"
5 "errors"6 "errors"
6 "sync"7 "sync"
7 8
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/dumber/internal/ui/layout"11 "github.com/bnema/dumber/internal/ui/layout"
10 "github.com/rs/zerolog"12 "github.com/rs/zerolog"
11 )13 )
@@ -67,7 +69,9 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
67 }69 }
68 70
69 // NewWorkspaceView creates a new workspace view.71 // NewWorkspaceView creates a new workspace view.
70-func NewWorkspaceView(factory layout.WidgetFactory, logger zerolog.Logger) *WorkspaceView {72+func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *WorkspaceView {
73+ log := logging.FromContext(ctx)
74+
71 container := factory.NewBox(layout.OrientationVertical, 0)75 container := factory.NewBox(layout.OrientationVertical, 0)
72 container.SetHexpand(true)76 container.SetHexpand(true)
73 container.SetVexpand(true)77 container.SetVexpand(true)
@@ -76,12 +80,12 @@ func NewWorkspaceView(factory layout.WidgetFactory, logger zerolog.Logger) *Work
76 wv := &WorkspaceView{80 wv := &WorkspaceView{
77 factory: factory,81 factory: factory,
78 container: container,82 container: container,
79- logger: logger.With().Str("component", "workspace-view").Logger(),83+ logger: log.With().Str("component", "workspace-view").Logger(),
80 paneViews: make(map[entity.PaneID]*PaneView),84 paneViews: make(map[entity.PaneID]*PaneView),
81 }85 }
82 86
83 // Create tree renderer with our adapter as the pane view factory87 // Create tree renderer with our adapter as the pane view factory
84- wv.treeRenderer = layout.NewTreeRenderer(factory, &paneViewFactoryAdapter{wv: wv}, wv.logger)88+ wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv})
85 89
86 return wv90 return wv
87 }91 }
M internal/ui/deps.go +4 -7
@@ -9,8 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/domain/repository"
1010 "github.com/bnema/dumber/internal/infrastructure/config"
1111 "github.com/bnema/dumber/internal/infrastructure/webkit"
12- "github.com/bnema/dumber/internal/ui/component"
13- "github.com/rs/zerolog"
12+ "github.com/bnema/dumber/internal/ui/theme"
1413 )
1514
1615 // Dependencies holds all injected dependencies for the UI layer.
@@ -19,7 +18,9 @@ type Dependencies struct {
1918 // Core context and configuration
2019 Ctx context.Context
2120 Config *config.Config
22- Logger *zerolog.Logger
21+
22+ // Theme management
23+ Theme *theme.Manager
2324
2425 // WebKit infrastructure
2526 WebContext *webkit.WebKitContext
@@ -28,7 +29,6 @@ type Dependencies struct {
2829 Settings *webkit.SettingsManager
2930 Injector *webkit.ContentInjector
3031 MessageRouter *webkit.MessageRouter
31- Overlay *component.OverlayController
3232
3333 // Repositories
3434 HistoryRepo repository.HistoryRepository
@@ -53,9 +53,6 @@ func (d *Dependencies) Validate() error {
5353 if d.Config == nil {
5454 return ErrMissingDependency("Config")
5555 }
56- if d.Logger == nil {
57- return ErrMissingDependency("Logger")
58- }
5956 // WebKit dependencies are optional for testing without WebViews
6057 // Use cases are optional - can be nil if not needed
6158 return nil
@@ -9,8 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/domain/repository"9 "github.com/bnema/dumber/internal/domain/repository"
10 "github.com/bnema/dumber/internal/infrastructure/config"10 "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/infrastructure/webkit"11 "github.com/bnema/dumber/internal/infrastructure/webkit"
12- "github.com/bnema/dumber/internal/ui/component"12+ "github.com/bnema/dumber/internal/ui/theme"
13- "github.com/rs/zerolog"
14 )13 )
15 14
16 // Dependencies holds all injected dependencies for the UI layer.15 // Dependencies holds all injected dependencies for the UI layer.
@@ -19,7 +18,9 @@ type Dependencies struct {
19 // Core context and configuration18 // Core context and configuration
20 Ctx context.Context19 Ctx context.Context
21 Config *config.Config20 Config *config.Config
22- Logger *zerolog.Logger21+
22+ // Theme management
23+ Theme *theme.Manager
23 24
24 // WebKit infrastructure25 // WebKit infrastructure
25 WebContext *webkit.WebKitContext26 WebContext *webkit.WebKitContext
@@ -28,7 +29,6 @@ type Dependencies struct {
28 Settings *webkit.SettingsManager29 Settings *webkit.SettingsManager
29 Injector *webkit.ContentInjector30 Injector *webkit.ContentInjector
30 MessageRouter *webkit.MessageRouter31 MessageRouter *webkit.MessageRouter
31- Overlay *component.OverlayController
32 32
33 // Repositories33 // Repositories
34 HistoryRepo repository.HistoryRepository34 HistoryRepo repository.HistoryRepository
@@ -53,9 +53,6 @@ func (d *Dependencies) Validate() error {
53 if d.Config == nil {53 if d.Config == nil {
54 return ErrMissingDependency("Config")54 return ErrMissingDependency("Config")
55 }55 }
56- if d.Logger == nil {
57- return ErrMissingDependency("Logger")
58- }
59 // WebKit dependencies are optional for testing without WebViews56 // WebKit dependencies are optional for testing without WebViews
60 // Use cases are optional - can be nil if not needed57 // Use cases are optional - can be nil if not needed
61 return nil58 return nil
D internal/ui/handler/favorites_handler.go +0 -124
@@ -1,124 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7-
8- "github.com/bnema/dumber/internal/application/usecase"
9- "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/webkit"
11- "github.com/bnema/dumber/internal/logging"
12-)
13-
14-// Favorite represents the payload the frontend expects.
15-type Favorite struct {
16- ID int64 `json:"id"`
17- URL string `json:"url"`
18- Title string `json:"title"`
19- FaviconURL string `json:"favicon_url,omitempty"`
20- Position int `json:"position,omitempty"`
21-}
22-
23-// FavoritesHandler returns all favorites.
24-type FavoritesHandler struct {
25- uc *usecase.ManageFavoritesUseCase
26-}
27-
28-// NewFavoritesHandler creates a favorites handler.
29-func NewFavoritesHandler(uc *usecase.ManageFavoritesUseCase) *FavoritesHandler {
30- return &FavoritesHandler{uc: uc}
31-}
32-
33-// Handle returns the favorites list.
34-func (h *FavoritesHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
35- log := logging.FromContext(ctx)
36-
37- if h.uc == nil {
38- log.Warn().Msg("favorites use case is nil; returning empty favorites")
39- return []Favorite{}, nil
40- }
41-
42- favs, err := h.uc.GetAll(ctx)
43- if err != nil {
44- return nil, fmt.Errorf("get favorites: %w", err)
45- }
46-
47- return mapFavorites(favs), nil
48-}
49-
50-// ToggleFavoriteHandler toggles favorite status for a URL.
51-type ToggleFavoriteHandler struct {
52- uc *usecase.ManageFavoritesUseCase
53-}
54-
55-// NewToggleFavoriteHandler creates a toggle favorite handler.
56-func NewToggleFavoriteHandler(uc *usecase.ManageFavoritesUseCase) *ToggleFavoriteHandler {
57- return &ToggleFavoriteHandler{uc: uc}
58-}
59-
60-// Handle toggles a favorite and returns the updated list.
61-func (h *ToggleFavoriteHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
62- log := logging.FromContext(ctx)
63-
64- var req struct {
65- URL string `json:"url"`
66- Title string `json:"title"`
67- FaviconURL string `json:"faviconURL"`
68- }
69- if err := json.Unmarshal(payload, &req); err != nil {
70- return nil, fmt.Errorf("decode toggle favorite payload: %w", err)
71- }
72-
73- if h.uc == nil {
74- log.Warn().Msg("favorites use case is nil; cannot toggle favorite")
75- return []Favorite{}, nil
76- }
77-
78- existing, err := h.uc.GetByURL(ctx, req.URL)
79- if err != nil {
80- return nil, fmt.Errorf("check favorite: %w", err)
81- }
82-
83- if existing != nil {
84- if err := h.uc.Remove(ctx, existing.ID); err != nil {
85- return nil, fmt.Errorf("remove favorite: %w", err)
86- }
87- log.Info().Str("url", req.URL).Msg("favorite removed via toggle")
88- } else {
89- _, err := h.uc.Add(ctx, usecase.AddFavoriteInput{
90- URL: req.URL,
91- Title: req.Title,
92- FaviconURL: req.FaviconURL,
93- })
94- if err != nil {
95- return nil, fmt.Errorf("add favorite: %w", err)
96- }
97- log.Info().Str("url", req.URL).Msg("favorite added via toggle")
98- }
99-
100- // Return the refreshed list so the UI stays in sync.
101- favs, err := h.uc.GetAll(ctx)
102- if err != nil {
103- return nil, fmt.Errorf("get favorites after toggle: %w", err)
104- }
105-
106- return mapFavorites(favs), nil
107-}
108-
109-func mapFavorites(favs []*entity.Favorite) []Favorite {
110- result := make([]Favorite, 0, len(favs))
111- for _, f := range favs {
112- if f == nil {
113- continue
114- }
115- result = append(result, Favorite{
116- ID: int64(f.ID),
117- URL: f.URL,
118- Title: f.Title,
119- FaviconURL: f.FaviconURL,
120- Position: f.Position,
121- })
122- }
123- return result
124-}
@@ -1,124 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7-
8- "github.com/bnema/dumber/internal/application/usecase"
9- "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/webkit"
11- "github.com/bnema/dumber/internal/logging"
12-)
13-
14-// Favorite represents the payload the frontend expects.
15-type Favorite struct {
16- ID int64 `json:"id"`
17- URL string `json:"url"`
18- Title string `json:"title"`
19- FaviconURL string `json:"favicon_url,omitempty"`
20- Position int `json:"position,omitempty"`
21-}
22-
23-// FavoritesHandler returns all favorites.
24-type FavoritesHandler struct {
25- uc *usecase.ManageFavoritesUseCase
26-}
27-
28-// NewFavoritesHandler creates a favorites handler.
29-func NewFavoritesHandler(uc *usecase.ManageFavoritesUseCase) *FavoritesHandler {
30- return &FavoritesHandler{uc: uc}
31-}
32-
33-// Handle returns the favorites list.
34-func (h *FavoritesHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
35- log := logging.FromContext(ctx)
36-
37- if h.uc == nil {
38- log.Warn().Msg("favorites use case is nil; returning empty favorites")
39- return []Favorite{}, nil
40- }
41-
42- favs, err := h.uc.GetAll(ctx)
43- if err != nil {
44- return nil, fmt.Errorf("get favorites: %w", err)
45- }
46-
47- return mapFavorites(favs), nil
48-}
49-
50-// ToggleFavoriteHandler toggles favorite status for a URL.
51-type ToggleFavoriteHandler struct {
52- uc *usecase.ManageFavoritesUseCase
53-}
54-
55-// NewToggleFavoriteHandler creates a toggle favorite handler.
56-func NewToggleFavoriteHandler(uc *usecase.ManageFavoritesUseCase) *ToggleFavoriteHandler {
57- return &ToggleFavoriteHandler{uc: uc}
58-}
59-
60-// Handle toggles a favorite and returns the updated list.
61-func (h *ToggleFavoriteHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
62- log := logging.FromContext(ctx)
63-
64- var req struct {
65- URL string `json:"url"`
66- Title string `json:"title"`
67- FaviconURL string `json:"faviconURL"`
68- }
69- if err := json.Unmarshal(payload, &req); err != nil {
70- return nil, fmt.Errorf("decode toggle favorite payload: %w", err)
71- }
72-
73- if h.uc == nil {
74- log.Warn().Msg("favorites use case is nil; cannot toggle favorite")
75- return []Favorite{}, nil
76- }
77-
78- existing, err := h.uc.GetByURL(ctx, req.URL)
79- if err != nil {
80- return nil, fmt.Errorf("check favorite: %w", err)
81- }
82-
83- if existing != nil {
84- if err := h.uc.Remove(ctx, existing.ID); err != nil {
85- return nil, fmt.Errorf("remove favorite: %w", err)
86- }
87- log.Info().Str("url", req.URL).Msg("favorite removed via toggle")
88- } else {
89- _, err := h.uc.Add(ctx, usecase.AddFavoriteInput{
90- URL: req.URL,
91- Title: req.Title,
92- FaviconURL: req.FaviconURL,
93- })
94- if err != nil {
95- return nil, fmt.Errorf("add favorite: %w", err)
96- }
97- log.Info().Str("url", req.URL).Msg("favorite added via toggle")
98- }
99-
100- // Return the refreshed list so the UI stays in sync.
101- favs, err := h.uc.GetAll(ctx)
102- if err != nil {
103- return nil, fmt.Errorf("get favorites after toggle: %w", err)
104- }
105-
106- return mapFavorites(favs), nil
107-}
108-
109-func mapFavorites(favs []*entity.Favorite) []Favorite {
110- result := make([]Favorite, 0, len(favs))
111- for _, f := range favs {
112- if f == nil {
113- continue
114- }
115- result = append(result, Favorite{
116- ID: int64(f.ID),
117- URL: f.URL,
118- Title: f.Title,
119- FaviconURL: f.FaviconURL,
120- Position: f.Position,
121- })
122- }
123- return result
124-}
D internal/ui/handler/keyboard_handler.go +0 -60
@@ -1,60 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// KeyboardBlockingHandler toggles keyboard and focus blocking in the page context.
14-type KeyboardBlockingHandler struct{}
15-
16-// NewKeyboardBlockingHandler creates a new handler.
17-func NewKeyboardBlockingHandler() *KeyboardBlockingHandler {
18- return &KeyboardBlockingHandler{}
19-}
20-
21-// Handle enables or disables blocking by invoking page-world helpers.
22-func (h *KeyboardBlockingHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23- log := logging.FromContext(ctx)
24-
25- var req struct {
26- Action string `json:"action"`
27- }
28- if err := json.Unmarshal(payload, &req); err != nil {
29- return nil, fmt.Errorf("decode keyboard payload: %w", err)
30- }
31-
32- action := strings.ToLower(strings.TrimSpace(req.Action))
33- if action == "" {
34- return nil, fmt.Errorf("keyboard_blocking action is required")
35- }
36-
37- wv := webkit.LookupWebView(webviewID)
38- if wv == nil {
39- return nil, fmt.Errorf("webview %d not found", webviewID)
40- }
41-
42- var js string
43- switch action {
44- case "enable":
45- js = `if(window.__dumber_enableKeyboardBlocking){window.__dumber_enableKeyboardBlocking();}`
46- case "disable":
47- js = `if(window.__dumber_disableKeyboardBlocking){window.__dumber_disableKeyboardBlocking();}`
48- default:
49- return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)
50- }
51-
52- wv.RunJavaScript(ctx, js, "")
53-
54- log.Debug().
55- Str("action", action).
56- Uint64("webview_id", uint64(webviewID)).
57- Msg("keyboard blocking toggled")
58-
59- return map[string]string{"status": "ok"}, nil
60-}
@@ -1,60 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// KeyboardBlockingHandler toggles keyboard and focus blocking in the page context.
14-type KeyboardBlockingHandler struct{}
15-
16-// NewKeyboardBlockingHandler creates a new handler.
17-func NewKeyboardBlockingHandler() *KeyboardBlockingHandler {
18- return &KeyboardBlockingHandler{}
19-}
20-
21-// Handle enables or disables blocking by invoking page-world helpers.
22-func (h *KeyboardBlockingHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23- log := logging.FromContext(ctx)
24-
25- var req struct {
26- Action string `json:"action"`
27- }
28- if err := json.Unmarshal(payload, &req); err != nil {
29- return nil, fmt.Errorf("decode keyboard payload: %w", err)
30- }
31-
32- action := strings.ToLower(strings.TrimSpace(req.Action))
33- if action == "" {
34- return nil, fmt.Errorf("keyboard_blocking action is required")
35- }
36-
37- wv := webkit.LookupWebView(webviewID)
38- if wv == nil {
39- return nil, fmt.Errorf("webview %d not found", webviewID)
40- }
41-
42- var js string
43- switch action {
44- case "enable":
45- js = `if(window.__dumber_enableKeyboardBlocking){window.__dumber_enableKeyboardBlocking();}`
46- case "disable":
47- js = `if(window.__dumber_disableKeyboardBlocking){window.__dumber_disableKeyboardBlocking();}`
48- default:
49- return nil, fmt.Errorf("unknown keyboard_blocking action: %s", action)
50- }
51-
52- wv.RunJavaScript(ctx, js, "")
53-
54- log.Debug().
55- Str("action", action).
56- Uint64("webview_id", uint64(webviewID)).
57- Msg("keyboard blocking toggled")
58-
59- return map[string]string{"status": "ok"}, nil
60-}
D internal/ui/handler/navigate_handler.go +0 -57
@@ -1,57 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// NavigateHandler processes navigation requests from the frontend.
14-type NavigateHandler struct{}
15-
16-// NewNavigateHandler creates a navigation handler.
17-func NewNavigateHandler() *NavigateHandler {
18- return &NavigateHandler{}
19-}
20-
21-// Handle loads the requested URL in the target WebView.
22-func (h *NavigateHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23- log := logging.FromContext(ctx)
24-
25- var req struct {
26- URL string `json:"url"`
27- }
28- if err := json.Unmarshal(payload, &req); err != nil {
29- return nil, fmt.Errorf("decode navigate payload: %w", err)
30- }
31-
32- url := strings.TrimSpace(req.URL)
33- if url == "" {
34- return nil, fmt.Errorf("navigation URL is required")
35- }
36-
37- wv := webkit.LookupWebView(webviewID)
38- if wv == nil {
39- return nil, fmt.Errorf("webview %d not found", webviewID)
40- }
41-
42- if err := wv.LoadURI(ctx, url); err != nil {
43- log.Warn().
44- Err(err).
45- Str("url", url).
46- Uint64("webview_id", uint64(webviewID)).
47- Msg("failed to load URL from frontend")
48- return nil, err
49- }
50-
51- log.Info().
52- Str("url", url).
53- Uint64("webview_id", uint64(webviewID)).
54- Msg("navigation triggered from frontend")
55-
56- return map[string]string{"status": "ok"}, nil
57-}
@@ -1,57 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// NavigateHandler processes navigation requests from the frontend.
14-type NavigateHandler struct{}
15-
16-// NewNavigateHandler creates a navigation handler.
17-func NewNavigateHandler() *NavigateHandler {
18- return &NavigateHandler{}
19-}
20-
21-// Handle loads the requested URL in the target WebView.
22-func (h *NavigateHandler) Handle(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
23- log := logging.FromContext(ctx)
24-
25- var req struct {
26- URL string `json:"url"`
27- }
28- if err := json.Unmarshal(payload, &req); err != nil {
29- return nil, fmt.Errorf("decode navigate payload: %w", err)
30- }
31-
32- url := strings.TrimSpace(req.URL)
33- if url == "" {
34- return nil, fmt.Errorf("navigation URL is required")
35- }
36-
37- wv := webkit.LookupWebView(webviewID)
38- if wv == nil {
39- return nil, fmt.Errorf("webview %d not found", webviewID)
40- }
41-
42- if err := wv.LoadURI(ctx, url); err != nil {
43- log.Warn().
44- Err(err).
45- Str("url", url).
46- Uint64("webview_id", uint64(webviewID)).
47- Msg("failed to load URL from frontend")
48- return nil, err
49- }
50-
51- log.Info().
52- Str("url", url).
53- Uint64("webview_id", uint64(webviewID)).
54- Msg("navigation triggered from frontend")
55-
56- return map[string]string{"status": "ok"}, nil
57-}
D internal/ui/handler/palette_handler.go +0 -59
@@ -1,59 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "strings"
7-
8- "github.com/bnema/dumber/internal/infrastructure/config"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// PaletteHandler returns color palettes to the frontend.
14-type PaletteHandler struct {
15- config *config.Config
16-}
17-
18-// NewPaletteHandler creates a palette handler.
19-func NewPaletteHandler(cfg *config.Config) *PaletteHandler {
20- return &PaletteHandler{config: cfg}
21-}
22-
23-// PaletteResponse mirrors the frontend expectation.
24-type PaletteResponse struct {
25- Light config.ColorPalette `json:"light"`
26- Dark config.ColorPalette `json:"dark"`
27- Current string `json:"current"`
28-}
29-
30-// Handle returns light/dark palettes and the current preference.
31-func (h *PaletteHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
32- log := logging.FromContext(ctx)
33-
34- if h.config == nil {
35- log.Warn().Msg("config is nil; returning default palettes")
36- defaultCfg := config.DefaultConfig()
37- return PaletteResponse{
38- Light: defaultCfg.Appearance.LightPalette,
39- Dark: defaultCfg.Appearance.DarkPalette,
40- Current: "light",
41- }, nil
42- }
43-
44- current := strings.ToLower(h.config.Appearance.ColorScheme)
45- switch current {
46- case "prefer-dark", "dark":
47- current = "dark"
48- case "prefer-light", "light":
49- current = "light"
50- default:
51- current = "system"
52- }
53-
54- return PaletteResponse{
55- Light: h.config.Appearance.LightPalette,
56- Dark: h.config.Appearance.DarkPalette,
57- Current: current,
58- }, nil
59-}
@@ -1,59 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "strings"
7-
8- "github.com/bnema/dumber/internal/infrastructure/config"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// PaletteHandler returns color palettes to the frontend.
14-type PaletteHandler struct {
15- config *config.Config
16-}
17-
18-// NewPaletteHandler creates a palette handler.
19-func NewPaletteHandler(cfg *config.Config) *PaletteHandler {
20- return &PaletteHandler{config: cfg}
21-}
22-
23-// PaletteResponse mirrors the frontend expectation.
24-type PaletteResponse struct {
25- Light config.ColorPalette `json:"light"`
26- Dark config.ColorPalette `json:"dark"`
27- Current string `json:"current"`
28-}
29-
30-// Handle returns light/dark palettes and the current preference.
31-func (h *PaletteHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
32- log := logging.FromContext(ctx)
33-
34- if h.config == nil {
35- log.Warn().Msg("config is nil; returning default palettes")
36- defaultCfg := config.DefaultConfig()
37- return PaletteResponse{
38- Light: defaultCfg.Appearance.LightPalette,
39- Dark: defaultCfg.Appearance.DarkPalette,
40- Current: "light",
41- }, nil
42- }
43-
44- current := strings.ToLower(h.config.Appearance.ColorScheme)
45- switch current {
46- case "prefer-dark", "dark":
47- current = "dark"
48- case "prefer-light", "light":
49- current = "light"
50- default:
51- current = "system"
52- }
53-
54- return PaletteResponse{
55- Light: h.config.Appearance.LightPalette,
56- Dark: h.config.Appearance.DarkPalette,
57- Current: current,
58- }, nil
59-}
D internal/ui/handler/query_handler.go +0 -192
@@ -1,192 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/application/usecase"
10- "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/config"
12- "github.com/bnema/dumber/internal/infrastructure/webkit"
13- "github.com/bnema/dumber/internal/logging"
14-)
15-
16-// Suggestion represents a minimal suggestion payload for the frontend.
17-type Suggestion struct {
18- URL string `json:"url"`
19- Title string `json:"title,omitempty"`
20- FaviconURL string `json:"favicon_url,omitempty"`
21- Favicon string `json:"favicon,omitempty"`
22-}
23-
24-// QueryHandler responds to omnibox search queries.
25-type QueryHandler struct {
26- history *usecase.SearchHistoryUseCase
27- config *config.Config
28-}
29-
30-// NewQueryHandler creates a new QueryHandler.
31-func NewQueryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *QueryHandler {
32- return &QueryHandler{
33- history: history,
34- config: cfg,
35- }
36-}
37-
38-// Handle executes a fuzzy history search and returns suggestions.
39-func (h *QueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
40- log := logging.FromContext(ctx)
41-
42- var req struct {
43- Query string `json:"q"`
44- Limit int `json:"limit"`
45- }
46- if err := json.Unmarshal(payload, &req); err != nil {
47- return nil, fmt.Errorf("decode query payload: %w", err)
48- }
49-
50- limit := req.Limit
51- if limit <= 0 {
52- limit = 10
53- if h.config != nil && h.config.Omnibox.InitialBehavior != "" {
54- // Keep limit low by default; no per-behavior tuning yet.
55- limit = 10
56- }
57- }
58-
59- if h.history == nil {
60- log.Warn().Msg("history use case is nil; returning empty suggestions")
61- return []Suggestion{}, nil
62- }
63-
64- result, err := h.history.Search(ctx, usecase.SearchInput{
65- Query: strings.TrimSpace(req.Query),
66- Limit: limit,
67- })
68- if err != nil {
69- return nil, fmt.Errorf("history search failed: %w", err)
70- }
71-
72- return toSuggestions(result.Matches), nil
73-}
74-
75-// InitialHistoryHandler returns initial history suggestions for an empty omnibox.
76-type InitialHistoryHandler struct {
77- history *usecase.SearchHistoryUseCase
78- config *config.Config
79-}
80-
81-// NewInitialHistoryHandler creates a new handler for initial history load.
82-func NewInitialHistoryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *InitialHistoryHandler {
83- return &InitialHistoryHandler{
84- history: history,
85- config: cfg,
86- }
87-}
88-
89-// Handle returns recent history entries as suggestions.
90-func (h *InitialHistoryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
91- log := logging.FromContext(ctx)
92-
93- var req struct {
94- Limit int `json:"limit"`
95- }
96- if len(payload) > 0 {
97- _ = json.Unmarshal(payload, &req)
98- }
99-
100- limit := req.Limit
101- if limit <= 0 {
102- limit = 10
103- }
104-
105- if h.history == nil {
106- log.Warn().Msg("history use case is nil; returning empty initial history")
107- return []Suggestion{}, nil
108- }
109-
110- entries, err := h.history.GetRecent(ctx, limit, 0)
111- if err != nil {
112- return nil, fmt.Errorf("get recent history: %w", err)
113- }
114-
115- suggestions := make([]Suggestion, 0, len(entries))
116- for _, entry := range entries {
117- if entry == nil {
118- continue
119- }
120- suggestions = append(suggestions, Suggestion{
121- URL: entry.URL,
122- Title: entry.Title,
123- FaviconURL: entry.FaviconURL,
124- Favicon: entry.FaviconURL,
125- })
126- }
127-
128- return suggestions, nil
129-}
130-
131-// PrefixQueryHandler returns an inline suggestion (ghost text) for a prefix query.
132-type PrefixQueryHandler struct {
133- history *usecase.SearchHistoryUseCase
134-}
135-
136-// NewPrefixQueryHandler creates a new handler for prefix inline queries.
137-func NewPrefixQueryHandler(history *usecase.SearchHistoryUseCase) *PrefixQueryHandler {
138- return &PrefixQueryHandler{history: history}
139-}
140-
141-// Handle returns the best matching URL for the given prefix (or null).
142-func (h *PrefixQueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
143- log := logging.FromContext(ctx)
144-
145- var req struct {
146- Query string `json:"q"`
147- }
148- if err := json.Unmarshal(payload, &req); err != nil {
149- return nil, fmt.Errorf("decode prefix query payload: %w", err)
150- }
151-
152- query := strings.TrimSpace(req.Query)
153- if query == "" {
154- return nil, nil
155- }
156-
157- if h.history == nil {
158- log.Warn().Msg("history use case is nil; returning no inline suggestion")
159- return nil, nil
160- }
161-
162- result, err := h.history.Search(ctx, usecase.SearchInput{
163- Query: query,
164- Limit: 1,
165- })
166- if err != nil {
167- return nil, fmt.Errorf("inline history search failed: %w", err)
168- }
169-
170- if len(result.Matches) == 0 || result.Matches[0].Entry == nil {
171- return nil, nil
172- }
173-
174- return result.Matches[0].Entry.URL, nil
175-}
176-
177-// toSuggestions converts history matches into UI suggestions.
178-func toSuggestions(matches []entity.HistoryMatch) []Suggestion {
179- suggestions := make([]Suggestion, 0, len(matches))
180- for _, match := range matches {
181- if match.Entry == nil {
182- continue
183- }
184- suggestions = append(suggestions, Suggestion{
185- URL: match.Entry.URL,
186- Title: match.Entry.Title,
187- FaviconURL: match.Entry.FaviconURL,
188- Favicon: match.Entry.FaviconURL,
189- })
190- }
191- return suggestions
192-}
@@ -1,192 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/application/usecase"
10- "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/config"
12- "github.com/bnema/dumber/internal/infrastructure/webkit"
13- "github.com/bnema/dumber/internal/logging"
14-)
15-
16-// Suggestion represents a minimal suggestion payload for the frontend.
17-type Suggestion struct {
18- URL string `json:"url"`
19- Title string `json:"title,omitempty"`
20- FaviconURL string `json:"favicon_url,omitempty"`
21- Favicon string `json:"favicon,omitempty"`
22-}
23-
24-// QueryHandler responds to omnibox search queries.
25-type QueryHandler struct {
26- history *usecase.SearchHistoryUseCase
27- config *config.Config
28-}
29-
30-// NewQueryHandler creates a new QueryHandler.
31-func NewQueryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *QueryHandler {
32- return &QueryHandler{
33- history: history,
34- config: cfg,
35- }
36-}
37-
38-// Handle executes a fuzzy history search and returns suggestions.
39-func (h *QueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
40- log := logging.FromContext(ctx)
41-
42- var req struct {
43- Query string `json:"q"`
44- Limit int `json:"limit"`
45- }
46- if err := json.Unmarshal(payload, &req); err != nil {
47- return nil, fmt.Errorf("decode query payload: %w", err)
48- }
49-
50- limit := req.Limit
51- if limit <= 0 {
52- limit = 10
53- if h.config != nil && h.config.Omnibox.InitialBehavior != "" {
54- // Keep limit low by default; no per-behavior tuning yet.
55- limit = 10
56- }
57- }
58-
59- if h.history == nil {
60- log.Warn().Msg("history use case is nil; returning empty suggestions")
61- return []Suggestion{}, nil
62- }
63-
64- result, err := h.history.Search(ctx, usecase.SearchInput{
65- Query: strings.TrimSpace(req.Query),
66- Limit: limit,
67- })
68- if err != nil {
69- return nil, fmt.Errorf("history search failed: %w", err)
70- }
71-
72- return toSuggestions(result.Matches), nil
73-}
74-
75-// InitialHistoryHandler returns initial history suggestions for an empty omnibox.
76-type InitialHistoryHandler struct {
77- history *usecase.SearchHistoryUseCase
78- config *config.Config
79-}
80-
81-// NewInitialHistoryHandler creates a new handler for initial history load.
82-func NewInitialHistoryHandler(history *usecase.SearchHistoryUseCase, cfg *config.Config) *InitialHistoryHandler {
83- return &InitialHistoryHandler{
84- history: history,
85- config: cfg,
86- }
87-}
88-
89-// Handle returns recent history entries as suggestions.
90-func (h *InitialHistoryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
91- log := logging.FromContext(ctx)
92-
93- var req struct {
94- Limit int `json:"limit"`
95- }
96- if len(payload) > 0 {
97- _ = json.Unmarshal(payload, &req)
98- }
99-
100- limit := req.Limit
101- if limit <= 0 {
102- limit = 10
103- }
104-
105- if h.history == nil {
106- log.Warn().Msg("history use case is nil; returning empty initial history")
107- return []Suggestion{}, nil
108- }
109-
110- entries, err := h.history.GetRecent(ctx, limit, 0)
111- if err != nil {
112- return nil, fmt.Errorf("get recent history: %w", err)
113- }
114-
115- suggestions := make([]Suggestion, 0, len(entries))
116- for _, entry := range entries {
117- if entry == nil {
118- continue
119- }
120- suggestions = append(suggestions, Suggestion{
121- URL: entry.URL,
122- Title: entry.Title,
123- FaviconURL: entry.FaviconURL,
124- Favicon: entry.FaviconURL,
125- })
126- }
127-
128- return suggestions, nil
129-}
130-
131-// PrefixQueryHandler returns an inline suggestion (ghost text) for a prefix query.
132-type PrefixQueryHandler struct {
133- history *usecase.SearchHistoryUseCase
134-}
135-
136-// NewPrefixQueryHandler creates a new handler for prefix inline queries.
137-func NewPrefixQueryHandler(history *usecase.SearchHistoryUseCase) *PrefixQueryHandler {
138- return &PrefixQueryHandler{history: history}
139-}
140-
141-// Handle returns the best matching URL for the given prefix (or null).
142-func (h *PrefixQueryHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
143- log := logging.FromContext(ctx)
144-
145- var req struct {
146- Query string `json:"q"`
147- }
148- if err := json.Unmarshal(payload, &req); err != nil {
149- return nil, fmt.Errorf("decode prefix query payload: %w", err)
150- }
151-
152- query := strings.TrimSpace(req.Query)
153- if query == "" {
154- return nil, nil
155- }
156-
157- if h.history == nil {
158- log.Warn().Msg("history use case is nil; returning no inline suggestion")
159- return nil, nil
160- }
161-
162- result, err := h.history.Search(ctx, usecase.SearchInput{
163- Query: query,
164- Limit: 1,
165- })
166- if err != nil {
167- return nil, fmt.Errorf("inline history search failed: %w", err)
168- }
169-
170- if len(result.Matches) == 0 || result.Matches[0].Entry == nil {
171- return nil, nil
172- }
173-
174- return result.Matches[0].Entry.URL, nil
175-}
176-
177-// toSuggestions converts history matches into UI suggestions.
178-func toSuggestions(matches []entity.HistoryMatch) []Suggestion {
179- suggestions := make([]Suggestion, 0, len(matches))
180- for _, match := range matches {
181- if match.Entry == nil {
182- continue
183- }
184- suggestions = append(suggestions, Suggestion{
185- URL: match.Entry.URL,
186- Title: match.Entry.Title,
187- FaviconURL: match.Entry.FaviconURL,
188- Favicon: match.Entry.FaviconURL,
189- })
190- }
191- return suggestions
192-}
D internal/ui/handler/shortcuts_handler.go +0 -38
@@ -1,38 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6-
7- "github.com/bnema/dumber/internal/infrastructure/config"
8- "github.com/bnema/dumber/internal/infrastructure/webkit"
9- "github.com/bnema/dumber/internal/logging"
10-)
11-
12-// ShortcutsHandler returns configured search shortcuts.
13-type ShortcutsHandler struct {
14- config *config.Config
15-}
16-
17-// NewShortcutsHandler creates a new shortcuts handler.
18-func NewShortcutsHandler(cfg *config.Config) *ShortcutsHandler {
19- return &ShortcutsHandler{config: cfg}
20-}
21-
22-// Handle returns the configured search shortcuts map.
23-func (h *ShortcutsHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
24- log := logging.FromContext(ctx)
25-
26- if h.config == nil {
27- log.Warn().Msg("config is nil; returning empty search shortcuts")
28- return map[string]config.SearchShortcut{}, nil
29- }
30-
31- // Return a shallow copy to avoid accidental mutations.
32- shortcuts := make(map[string]config.SearchShortcut, len(h.config.SearchShortcuts))
33- for key, val := range h.config.SearchShortcuts {
34- shortcuts[key] = val
35- }
36-
37- return shortcuts, nil
38-}
@@ -1,38 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "encoding/json"
6-
7- "github.com/bnema/dumber/internal/infrastructure/config"
8- "github.com/bnema/dumber/internal/infrastructure/webkit"
9- "github.com/bnema/dumber/internal/logging"
10-)
11-
12-// ShortcutsHandler returns configured search shortcuts.
13-type ShortcutsHandler struct {
14- config *config.Config
15-}
16-
17-// NewShortcutsHandler creates a new shortcuts handler.
18-func NewShortcutsHandler(cfg *config.Config) *ShortcutsHandler {
19- return &ShortcutsHandler{config: cfg}
20-}
21-
22-// Handle returns the configured search shortcuts map.
23-func (h *ShortcutsHandler) Handle(ctx context.Context, _ webkit.WebViewID, _ json.RawMessage) (any, error) {
24- log := logging.FromContext(ctx)
25-
26- if h.config == nil {
27- log.Warn().Msg("config is nil; returning empty search shortcuts")
28- return map[string]config.SearchShortcut{}, nil
29- }
30-
31- // Return a shallow copy to avoid accidental mutations.
32- shortcuts := make(map[string]config.SearchShortcut, len(h.config.SearchShortcuts))
33- for key, val := range h.config.SearchShortcuts {
34- shortcuts[key] = val
35- }
36-
37- return shortcuts, nil
38-}
M internal/ui/input/keyboard.go +5 -3
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/logging"
910 "github.com/jwijenbergh/puregotk/v4/gdk"
1011 "github.com/jwijenbergh/puregotk/v4/gtk"
1112 "github.com/rs/zerolog"
@@ -30,7 +31,7 @@ type KeyboardHandler struct {
3031 controller *gtk.EventControllerKey
3132
3233 ctx context.Context
33- logger *zerolog.Logger
34+ logger zerolog.Logger
3435 mu sync.RWMutex
3536 }
3637
@@ -38,14 +39,15 @@ type KeyboardHandler struct {
3839 func NewKeyboardHandler(
3940 ctx context.Context,
4041 cfg *config.WorkspaceConfig,
41- logger *zerolog.Logger,
4242 ) *KeyboardHandler {
43+ log := logging.FromContext(ctx)
44+
4345 h := &KeyboardHandler{
4446 shortcuts: NewShortcutSet(ctx, cfg),
4547 modal: NewModalState(ctx),
4648 cfg: cfg,
4749 ctx: ctx,
48- logger: logger,
50+ logger: log.With().Str("component", "keyboard-handler").Logger(),
4951 }
5052
5153 return h
@@ -6,6 +6,7 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/logging"
9 "github.com/jwijenbergh/puregotk/v4/gdk"10 "github.com/jwijenbergh/puregotk/v4/gdk"
10 "github.com/jwijenbergh/puregotk/v4/gtk"11 "github.com/jwijenbergh/puregotk/v4/gtk"
11 "github.com/rs/zerolog"12 "github.com/rs/zerolog"
@@ -30,7 +31,7 @@ type KeyboardHandler struct {
30 controller *gtk.EventControllerKey31 controller *gtk.EventControllerKey
31 32
32 ctx context.Context33 ctx context.Context
33- logger *zerolog.Logger34+ logger zerolog.Logger
34 mu sync.RWMutex35 mu sync.RWMutex
35 }36 }
36 37
@@ -38,14 +39,15 @@ type KeyboardHandler struct {
38 func NewKeyboardHandler(39 func NewKeyboardHandler(
39 ctx context.Context,40 ctx context.Context,
40 cfg *config.WorkspaceConfig,41 cfg *config.WorkspaceConfig,
41- logger *zerolog.Logger,
42 ) *KeyboardHandler {42 ) *KeyboardHandler {
43+ log := logging.FromContext(ctx)
44+
43 h := &KeyboardHandler{45 h := &KeyboardHandler{
44 shortcuts: NewShortcutSet(ctx, cfg),46 shortcuts: NewShortcutSet(ctx, cfg),
45 modal: NewModalState(ctx),47 modal: NewModalState(ctx),
46 cfg: cfg,48 cfg: cfg,
47 ctx: ctx,49 ctx: ctx,
48- logger: logger,50+ logger: log.With().Str("component", "keyboard-handler").Logger(),
49 }51 }
50 52
51 return h53 return h
M internal/ui/layout/split.go +6 -2
@@ -1,8 +1,10 @@
11 package layout
22
33 import (
4+ "context"
45 "sync"
56
7+ "github.com/bnema/dumber/internal/logging"
68 "github.com/rs/zerolog"
79 )
810
@@ -25,7 +27,9 @@ const maxRetryFrames = 120
2527
2628 // NewSplitView creates a new split view with the given orientation and children.
2729 // The ratio determines the initial divider position (0.0-1.0).
28-func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView {
30+func NewSplitView(ctx context.Context, factory WidgetFactory, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView {
31+ log := logging.FromContext(ctx)
32+
2933 paned := factory.NewPaned(orientation)
3034
3135 // Configure resize behavior - both children should resize
@@ -41,7 +45,7 @@ func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orie
4145 startChild: startChild,
4246 endChild: endChild,
4347 ratio: clampRatio(ratio),
44- logger: logger.With().Str("component", "split-view").Logger(),
48+ logger: log.With().Str("component", "split-view").Logger(),
4549 }
4650
4751 // Set children
@@ -1,8 +1,10 @@
1 package layout1 package layout
2 2
3 import (3 import (
4+ "context"
4 "sync"5 "sync"
5 6
7+ "github.com/bnema/dumber/internal/logging"
6 "github.com/rs/zerolog"8 "github.com/rs/zerolog"
7 )9 )
8 10
@@ -25,7 +27,9 @@ const maxRetryFrames = 120
25 27
26 // NewSplitView creates a new split view with the given orientation and children.28 // NewSplitView creates a new split view with the given orientation and children.
27 // The ratio determines the initial divider position (0.0-1.0).29 // The ratio determines the initial divider position (0.0-1.0).
28-func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView {30+func NewSplitView(ctx context.Context, factory WidgetFactory, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView {
31+ log := logging.FromContext(ctx)
32+
29 paned := factory.NewPaned(orientation)33 paned := factory.NewPaned(orientation)
30 34
31 // Configure resize behavior - both children should resize35 // Configure resize behavior - both children should resize
@@ -41,7 +45,7 @@ func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orie
41 startChild: startChild,45 startChild: startChild,
42 endChild: endChild,46 endChild: endChild,
43 ratio: clampRatio(ratio),47 ratio: clampRatio(ratio),
44- logger: logger.With().Str("component", "split-view").Logger(),48+ logger: log.With().Str("component", "split-view").Logger(),
45 }49 }
46 50
47 // Set children51 // Set children
M internal/ui/layout/tree.go +9 -3
@@ -1,10 +1,12 @@
11 package layout
22
33 import (
4+ "context"
45 "errors"
56 "sync"
67
78 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
810 "github.com/rs/zerolog"
911 )
1012
@@ -29,16 +31,20 @@ type TreeRenderer struct {
2931 paneViewFactory PaneViewFactory
3032 logger zerolog.Logger
3133 nodeToWidget map[string]Widget
34+ ctx context.Context
3235 mu sync.RWMutex
3336 }
3437
3538 // NewTreeRenderer creates a new tree renderer.
36-func NewTreeRenderer(factory WidgetFactory, paneViewFactory PaneViewFactory, logger zerolog.Logger) *TreeRenderer {
39+func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory PaneViewFactory) *TreeRenderer {
40+ log := logging.FromContext(ctx)
41+
3742 return &TreeRenderer{
3843 factory: factory,
3944 paneViewFactory: paneViewFactory,
40- logger: logger.With().Str("component", "tree-renderer").Logger(),
45+ logger: log.With().Str("component", "tree-renderer").Logger(),
4146 nodeToWidget: make(map[string]Widget),
47+ ctx: ctx,
4248 }
4349 }
4450
@@ -122,7 +128,7 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) {
122128 }
123129
124130 // Create split view
125- splitView := NewSplitView(tr.factory, tr.logger, orientation, leftWidget, rightWidget, node.SplitRatio)
131+ splitView := NewSplitView(tr.ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
126132
127133 return splitView.Widget(), nil
128134 }
@@ -1,10 +1,12 @@
1 package layout1 package layout
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "sync"6 "sync"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/logging"
8 "github.com/rs/zerolog"10 "github.com/rs/zerolog"
9 )11 )
10 12
@@ -29,16 +31,20 @@ type TreeRenderer struct {
29 paneViewFactory PaneViewFactory31 paneViewFactory PaneViewFactory
30 logger zerolog.Logger32 logger zerolog.Logger
31 nodeToWidget map[string]Widget33 nodeToWidget map[string]Widget
34+ ctx context.Context
32 mu sync.RWMutex35 mu sync.RWMutex
33 }36 }
34 37
35 // NewTreeRenderer creates a new tree renderer.38 // NewTreeRenderer creates a new tree renderer.
36-func NewTreeRenderer(factory WidgetFactory, paneViewFactory PaneViewFactory, logger zerolog.Logger) *TreeRenderer {39+func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory PaneViewFactory) *TreeRenderer {
40+ log := logging.FromContext(ctx)
41+
37 return &TreeRenderer{42 return &TreeRenderer{
38 factory: factory,43 factory: factory,
39 paneViewFactory: paneViewFactory,44 paneViewFactory: paneViewFactory,
40- logger: logger.With().Str("component", "tree-renderer").Logger(),45+ logger: log.With().Str("component", "tree-renderer").Logger(),
41 nodeToWidget: make(map[string]Widget),46 nodeToWidget: make(map[string]Widget),
47+ ctx: ctx,
42 }48 }
43 }49 }
44 50
@@ -122,7 +128,7 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) {
122 }128 }
123 129
124 // Create split view130 // Create split view
125- splitView := NewSplitView(tr.factory, tr.logger, orientation, leftWidget, rightWidget, node.SplitRatio)131+ splitView := NewSplitView(tr.ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
126 132
127 return splitView.Widget(), nil133 return splitView.Widget(), nil
128 }134 }
M internal/ui/theme/css.go +113 -72
@@ -2,55 +2,80 @@
22 package theme
33
44 import (
5- "github.com/jwijenbergh/puregotk/v4/gdk"
6- "github.com/jwijenbergh/puregotk/v4/gtk"
5+ "strings"
76 )
87
9-// tabBarCSS contains GTK4 CSS styling for the tab bar and tab buttons.
10-// These styles match the old gotk4 implementation.
11-const tabBarCSS = `
12-/* Tab bar styling */
8+// GenerateCSS creates GTK4 CSS using the provided palette.
9+func GenerateCSS(p Palette) string {
10+ var sb strings.Builder
11+
12+ // CSS custom properties (variables) - GTK4 uses :root selector
13+ sb.WriteString("/* Theme variables */\n")
14+ sb.WriteString(":root {\n")
15+ sb.WriteString(p.ToCSSVars())
16+ sb.WriteString("}\n\n")
17+
18+ // Tab bar styling
19+ sb.WriteString(generateTabBarCSS(p))
20+ sb.WriteString("\n")
21+
22+ // Omnibox styling
23+ sb.WriteString(generateOmniboxCSS(p))
24+ sb.WriteString("\n")
25+
26+ // Pane styling
27+ sb.WriteString(generatePaneCSS(p))
28+
29+ return sb.String()
30+}
31+
32+// generateTabBarCSS creates tab bar styles.
33+func generateTabBarCSS(p Palette) string {
34+ return `/* Tab bar styling */
1335 .tab-bar {
14- background-color: #2b2b2b;
15- border-top: 2px solid #353535;
36+ background-color: var(--surface);
37+ border-top: 2px solid var(--border);
1638 padding: 0;
1739 min-height: 32px;
1840 }
1941
20-/* Tab button styling - matches stacked pane titles */
42+/* Tab button styling */
2143 button.tab-button {
22- background-color: #404040;
44+ background-color: var(--surface-variant);
2345 background-image: none;
2446 border: none;
25- border-right: 1px solid #353535;
47+ border-right: 1px solid var(--border);
2648 border-radius: 0;
2749 padding: 4px 8px;
2850 transition: background-color 200ms ease-in-out;
2951 }
3052
3153 button.tab-button:hover {
32- background-color: #505050;
54+ background-color: shade(var(--surface-variant), 1.2);
3355 }
3456
3557 button.tab-button.tab-button-active {
36- background-color: #707070;
58+ background-color: shade(var(--surface-variant), 1.4);
3759 font-weight: 600;
3860 }
3961
4062 /* Tab title text */
4163 .tab-title {
4264 font-size: 12px;
43- color: #ffffff;
65+ color: var(--text);
4466 font-weight: 500;
4567 }
68+`
69+}
4670
47-/* ===== Omnibox Styling ===== */
48-/* Matches Svelte5 omnibox with GTK4-native CSS */
71+// generateOmniboxCSS creates omnibox styles.
72+func generateOmniboxCSS(p Palette) string {
73+ return `/* ===== Omnibox Styling ===== */
4974
50-/* Omnibox window - floating popup with subtle depth */
75+/* Omnibox window - floating popup */
5176 window.omnibox-window {
52- background-color: #2d2d2d;
53- border: 1px solid #454545;
77+ background-color: var(--surface-variant);
78+ border: 1px solid var(--border);
5479 border-radius: 3px;
5580 }
5681
@@ -60,10 +85,10 @@ window.omnibox-window {
6085 background-color: transparent;
6186 }
6287
63-/* Header with History/Favorites toggle - slightly elevated */
88+/* Header with History/Favorites toggle */
6489 .omnibox-header {
65- background-color: shade(#2d2d2d, 1.1);
66- border-bottom: 1px solid #404040;
90+ background-color: shade(var(--surface-variant), 1.1);
91+ border-bottom: 1px solid var(--border);
6792 padding: 6px 12px;
6893 }
6994
@@ -76,42 +101,42 @@ window.omnibox-window {
76101 margin-right: 8px;
77102 font-size: 13px;
78103 font-weight: 500;
79- color: #909090;
104+ color: var(--muted);
80105 transition: all 100ms ease-in-out;
81106 }
82107
83108 .omnibox-header-btn:hover {
84- background-color: alpha(#4a90e2, 0.15);
85- color: #cccccc;
109+ background-color: alpha(var(--accent), 0.15);
110+ color: var(--text);
86111 }
87112
88113 .omnibox-header-btn.omnibox-header-active {
89- background-color: alpha(#4a90e2, 0.2);
90- color: #4a90e2;
114+ background-color: alpha(var(--accent), 0.2);
115+ color: var(--accent);
91116 font-weight: 600;
92117 }
93118
94-/* Search entry field - recessed appearance */
119+/* Search entry field */
95120 .omnibox-entry {
96- background-color: #1a1a1a;
97- color: #e8e8e8;
98- border: 1px solid #404040;
121+ background-color: var(--bg);
122+ color: var(--text);
123+ border: 1px solid var(--border);
99124 border-radius: 2px;
100125 padding: 10px 12px;
101126 margin: 8px 12px;
102127 font-size: 16px;
103- caret-color: #4a90e2;
128+ caret-color: var(--accent);
104129 }
105130
106131 .omnibox-entry:focus {
107- border-color: #4a90e2;
108- background-color: #1e1e1e;
132+ border-color: var(--accent);
133+ background-color: shade(var(--bg), 1.05);
109134 }
110135
111136 /* Scrolled window for suggestions */
112137 .omnibox-scrolled {
113- background-color: #2a2a2a;
114- border-top: 1px solid #404040;
138+ background-color: shade(var(--surface-variant), 0.95);
139+ border-top: 1px solid var(--border);
115140 }
116141
117142 /* List box */
@@ -119,13 +144,13 @@ window.omnibox-window {
119144 background-color: transparent;
120145 }
121146
122-/* Suggestion rows - with left accent border */
147+/* Suggestion rows */
123148 .omnibox-row {
124149 padding: 8px 12px;
125150 margin: 0;
126151 border-radius: 0;
127152 border-left: 3px solid transparent;
128- border-bottom: 1px solid alpha(#505050, 0.5);
153+ border-bottom: 1px solid alpha(var(--border), 0.5);
129154 transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out;
130155 }
131156
@@ -134,62 +159,78 @@ window.omnibox-window {
134159 }
135160
136161 .omnibox-row:hover {
137- background-color: alpha(#4a90e2, 0.12);
138- border-left-color: #4a90e2;
162+ background-color: alpha(var(--accent), 0.12);
163+ border-left-color: var(--accent);
139164 }
140165
141166 .omnibox-row:selected {
142- background-color: alpha(#4a90e2, 0.2);
143- border-left-color: #4a90e2;
167+ background-color: alpha(var(--accent), 0.2);
168+ border-left-color: var(--accent);
144169 }
145170
146171 /* Suggestion title/URL */
147172 .omnibox-suggestion-title {
148173 font-size: 14px;
149- color: #e0e0e0;
174+ color: var(--text, #ffffff);
150175 font-weight: 400;
151176 }
152177
178+/* Also style labels inside omnibox rows directly */
179+.omnibox-row label {
180+ color: var(--text, #ffffff);
181+}
182+
153183 .omnibox-row:selected .omnibox-suggestion-title {
154- color: #ffffff;
184+ color: var(--text);
185+ font-weight: 500;
155186 }
156187
157-/* Keyboard shortcut label (Ctrl+1-9) */
158-.omnibox-shortcut-label {
159- font-size: 11px;
160- color: #707070;
188+/* Keyboard shortcut badge */
189+.omnibox-shortcut-badge {
190+ background-color: alpha(var(--muted, #909090), 0.3);
191+ color: var(--muted, #909090);
192+ border-radius: 4px;
193+ padding: 2px 6px;
194+ font-size: 10px;
195+ font-weight: 500;
161196 font-family: monospace;
197+ margin-left: 8px;
162198 }
163199
164-.omnibox-row:hover .omnibox-shortcut-label,
165-.omnibox-row:selected .omnibox-shortcut-label {
166- color: alpha(#ffffff, 0.6);
200+.omnibox-row:hover .omnibox-shortcut-badge {
201+ background-color: alpha(var(--accent, #4ade80), 0.2);
202+ color: var(--accent, #4ade80);
167203 }
168204
169-/* Search shortcut badge */
170-.omnibox-shortcut-badge {
171- background-color: #4a90e2;
172- color: #ffffff;
173- border-radius: 0;
174- border: 1px solid #3a80d2;
175- padding: 2px 8px;
176- font-size: 11px;
177- font-weight: 600;
178- margin-right: 8px;
205+.omnibox-row:selected .omnibox-shortcut-badge {
206+ background-color: alpha(var(--accent, #4ade80), 0.3);
207+ color: var(--accent, #4ade80);
179208 }
180209 `
210+}
181211
182-// ApplyCSS loads tab bar styling into the display.
183-func ApplyCSS(display *gdk.Display) {
184- if display == nil {
185- return
186- }
212+// generatePaneCSS creates pane border styles.
213+func generatePaneCSS(p Palette) string {
214+ return `/* ===== Pane Styling ===== */
187215
188- provider := gtk.NewCssProvider()
189- if provider == nil {
190- return
191- }
216+/* Pane border - default transparent */
217+.pane-border {
218+ border: 1px solid transparent;
219+}
192220
193- provider.LoadFromString(tabBarCSS)
194- gtk.StyleContextAddProviderForDisplay(display, provider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION))
221+/* Active pane - accent border */
222+.pane-active {
223+ border: 1px solid var(--accent);
224+}
225+
226+/* Pane mode active - thick blue border */
227+.pane-mode-active {
228+ border: 4px solid #4A90E2;
229+}
230+
231+/* Tab mode active - thick orange border */
232+.tab-mode-active {
233+ border: 4px solid #FFA500;
234+}
235+`
195236 }
@@ -2,55 +2,80 @@
2 package theme2 package theme
3 3
4 import (4 import (
5- "github.com/jwijenbergh/puregotk/v4/gdk"5+ "strings"
6- "github.com/jwijenbergh/puregotk/v4/gtk"
7 )6 )
8 7
9-// tabBarCSS contains GTK4 CSS styling for the tab bar and tab buttons.8+// GenerateCSS creates GTK4 CSS using the provided palette.
10-// These styles match the old gotk4 implementation.9+func GenerateCSS(p Palette) string {
11-const tabBarCSS = `10+ var sb strings.Builder
12-/* Tab bar styling */11+
12+ // CSS custom properties (variables) - GTK4 uses :root selector
13+ sb.WriteString("/* Theme variables */\n")
14+ sb.WriteString(":root {\n")
15+ sb.WriteString(p.ToCSSVars())
16+ sb.WriteString("}\n\n")
17+
18+ // Tab bar styling
19+ sb.WriteString(generateTabBarCSS(p))
20+ sb.WriteString("\n")
21+
22+ // Omnibox styling
23+ sb.WriteString(generateOmniboxCSS(p))
24+ sb.WriteString("\n")
25+
26+ // Pane styling
27+ sb.WriteString(generatePaneCSS(p))
28+
29+ return sb.String()
30+}
31+
32+// generateTabBarCSS creates tab bar styles.
33+func generateTabBarCSS(p Palette) string {
34+ return `/* Tab bar styling */
13 .tab-bar {35 .tab-bar {
14- background-color: #2b2b2b;36+ background-color: var(--surface);
15- border-top: 2px solid #353535;37+ border-top: 2px solid var(--border);
16 padding: 0;38 padding: 0;
17 min-height: 32px;39 min-height: 32px;
18 }40 }
19 41
20-/* Tab button styling - matches stacked pane titles */42+/* Tab button styling */
21 button.tab-button {43 button.tab-button {
22- background-color: #404040;44+ background-color: var(--surface-variant);
23 background-image: none;45 background-image: none;
24 border: none;46 border: none;
25- border-right: 1px solid #353535;47+ border-right: 1px solid var(--border);
26 border-radius: 0;48 border-radius: 0;
27 padding: 4px 8px;49 padding: 4px 8px;
28 transition: background-color 200ms ease-in-out;50 transition: background-color 200ms ease-in-out;
29 }51 }
30 52
31 button.tab-button:hover {53 button.tab-button:hover {
32- background-color: #505050;54+ background-color: shade(var(--surface-variant), 1.2);
33 }55 }
34 56
35 button.tab-button.tab-button-active {57 button.tab-button.tab-button-active {
36- background-color: #707070;58+ background-color: shade(var(--surface-variant), 1.4);
37 font-weight: 600;59 font-weight: 600;
38 }60 }
39 61
40 /* Tab title text */62 /* Tab title text */
41 .tab-title {63 .tab-title {
42 font-size: 12px;64 font-size: 12px;
43- color: #ffffff;65+ color: var(--text);
44 font-weight: 500;66 font-weight: 500;
45 }67 }
68+`
69+}
46 70
47-/* ===== Omnibox Styling ===== */71+// generateOmniboxCSS creates omnibox styles.
48-/* Matches Svelte5 omnibox with GTK4-native CSS */72+func generateOmniboxCSS(p Palette) string {
73+ return `/* ===== Omnibox Styling ===== */
49 74
50-/* Omnibox window - floating popup with subtle depth */75+/* Omnibox window - floating popup */
51 window.omnibox-window {76 window.omnibox-window {
52- background-color: #2d2d2d;77+ background-color: var(--surface-variant);
53- border: 1px solid #454545;78+ border: 1px solid var(--border);
54 border-radius: 3px;79 border-radius: 3px;
55 }80 }
56 81
@@ -60,10 +85,10 @@ window.omnibox-window {
60 background-color: transparent;85 background-color: transparent;
61 }86 }
62 87
63-/* Header with History/Favorites toggle - slightly elevated */88+/* Header with History/Favorites toggle */
64 .omnibox-header {89 .omnibox-header {
65- background-color: shade(#2d2d2d, 1.1);90+ background-color: shade(var(--surface-variant), 1.1);
66- border-bottom: 1px solid #404040;91+ border-bottom: 1px solid var(--border);
67 padding: 6px 12px;92 padding: 6px 12px;
68 }93 }
69 94
@@ -76,42 +101,42 @@ window.omnibox-window {
76 margin-right: 8px;101 margin-right: 8px;
77 font-size: 13px;102 font-size: 13px;
78 font-weight: 500;103 font-weight: 500;
79- color: #909090;104+ color: var(--muted);
80 transition: all 100ms ease-in-out;105 transition: all 100ms ease-in-out;
81 }106 }
82 107
83 .omnibox-header-btn:hover {108 .omnibox-header-btn:hover {
84- background-color: alpha(#4a90e2, 0.15);109+ background-color: alpha(var(--accent), 0.15);
85- color: #cccccc;110+ color: var(--text);
86 }111 }
87 112
88 .omnibox-header-btn.omnibox-header-active {113 .omnibox-header-btn.omnibox-header-active {
89- background-color: alpha(#4a90e2, 0.2);114+ background-color: alpha(var(--accent), 0.2);
90- color: #4a90e2;115+ color: var(--accent);
91 font-weight: 600;116 font-weight: 600;
92 }117 }
93 118
94-/* Search entry field - recessed appearance */119+/* Search entry field */
95 .omnibox-entry {120 .omnibox-entry {
96- background-color: #1a1a1a;121+ background-color: var(--bg);
97- color: #e8e8e8;122+ color: var(--text);
98- border: 1px solid #404040;123+ border: 1px solid var(--border);
99 border-radius: 2px;124 border-radius: 2px;
100 padding: 10px 12px;125 padding: 10px 12px;
101 margin: 8px 12px;126 margin: 8px 12px;
102 font-size: 16px;127 font-size: 16px;
103- caret-color: #4a90e2;128+ caret-color: var(--accent);
104 }129 }
105 130
106 .omnibox-entry:focus {131 .omnibox-entry:focus {
107- border-color: #4a90e2;132+ border-color: var(--accent);
108- background-color: #1e1e1e;133+ background-color: shade(var(--bg), 1.05);
109 }134 }
110 135
111 /* Scrolled window for suggestions */136 /* Scrolled window for suggestions */
112 .omnibox-scrolled {137 .omnibox-scrolled {
113- background-color: #2a2a2a;138+ background-color: shade(var(--surface-variant), 0.95);
114- border-top: 1px solid #404040;139+ border-top: 1px solid var(--border);
115 }140 }
116 141
117 /* List box */142 /* List box */
@@ -119,13 +144,13 @@ window.omnibox-window {
119 background-color: transparent;144 background-color: transparent;
120 }145 }
121 146
122-/* Suggestion rows - with left accent border */147+/* Suggestion rows */
123 .omnibox-row {148 .omnibox-row {
124 padding: 8px 12px;149 padding: 8px 12px;
125 margin: 0;150 margin: 0;
126 border-radius: 0;151 border-radius: 0;
127 border-left: 3px solid transparent;152 border-left: 3px solid transparent;
128- border-bottom: 1px solid alpha(#505050, 0.5);153+ border-bottom: 1px solid alpha(var(--border), 0.5);
129 transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out;154 transition: background-color 100ms ease-in-out, border-left-color 100ms ease-in-out;
130 }155 }
131 156
@@ -134,62 +159,78 @@ window.omnibox-window {
134 }159 }
135 160
136 .omnibox-row:hover {161 .omnibox-row:hover {
137- background-color: alpha(#4a90e2, 0.12);162+ background-color: alpha(var(--accent), 0.12);
138- border-left-color: #4a90e2;163+ border-left-color: var(--accent);
139 }164 }
140 165
141 .omnibox-row:selected {166 .omnibox-row:selected {
142- background-color: alpha(#4a90e2, 0.2);167+ background-color: alpha(var(--accent), 0.2);
143- border-left-color: #4a90e2;168+ border-left-color: var(--accent);
144 }169 }
145 170
146 /* Suggestion title/URL */171 /* Suggestion title/URL */
147 .omnibox-suggestion-title {172 .omnibox-suggestion-title {
148 font-size: 14px;173 font-size: 14px;
149- color: #e0e0e0;174+ color: var(--text, #ffffff);
150 font-weight: 400;175 font-weight: 400;
151 }176 }
152 177
178+/* Also style labels inside omnibox rows directly */
179+.omnibox-row label {
180+ color: var(--text, #ffffff);
181+}
182+
153 .omnibox-row:selected .omnibox-suggestion-title {183 .omnibox-row:selected .omnibox-suggestion-title {
154- color: #ffffff;184+ color: var(--text);
185+ font-weight: 500;
155 }186 }
156 187
157-/* Keyboard shortcut label (Ctrl+1-9) */188+/* Keyboard shortcut badge */
158-.omnibox-shortcut-label {189+.omnibox-shortcut-badge {
159- font-size: 11px;190+ background-color: alpha(var(--muted, #909090), 0.3);
160- color: #707070;191+ color: var(--muted, #909090);
192+ border-radius: 4px;
193+ padding: 2px 6px;
194+ font-size: 10px;
195+ font-weight: 500;
161 font-family: monospace;196 font-family: monospace;
197+ margin-left: 8px;
162 }198 }
163 199
164-.omnibox-row:hover .omnibox-shortcut-label,200+.omnibox-row:hover .omnibox-shortcut-badge {
165-.omnibox-row:selected .omnibox-shortcut-label {201+ background-color: alpha(var(--accent, #4ade80), 0.2);
166- color: alpha(#ffffff, 0.6);202+ color: var(--accent, #4ade80);
167 }203 }
168 204
169-/* Search shortcut badge */205+.omnibox-row:selected .omnibox-shortcut-badge {
170-.omnibox-shortcut-badge {206+ background-color: alpha(var(--accent, #4ade80), 0.3);
171- background-color: #4a90e2;207+ color: var(--accent, #4ade80);
172- color: #ffffff;
173- border-radius: 0;
174- border: 1px solid #3a80d2;
175- padding: 2px 8px;
176- font-size: 11px;
177- font-weight: 600;
178- margin-right: 8px;
179 }208 }
180 `209 `
210+}
181 211
182-// ApplyCSS loads tab bar styling into the display.212+// generatePaneCSS creates pane border styles.
183-func ApplyCSS(display *gdk.Display) {213+func generatePaneCSS(p Palette) string {
184- if display == nil {214+ return `/* ===== Pane Styling ===== */
185- return
186- }
187 215
188- provider := gtk.NewCssProvider()216+/* Pane border - default transparent */
189- if provider == nil {217+.pane-border {
190- return218+ border: 1px solid transparent;
191- }219+}
192 220
193- provider.LoadFromString(tabBarCSS)221+/* Active pane - accent border */
194- gtk.StyleContextAddProviderForDisplay(display, provider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION))222+.pane-active {
223+ border: 1px solid var(--accent);
224+}
225+
226+/* Pane mode active - thick blue border */
227+.pane-mode-active {
228+ border: 4px solid #4A90E2;
229+}
230+
231+/* Tab mode active - thick orange border */
232+.tab-mode-active {
233+ border: 4px solid #FFA500;
234+}
235+`
195 }236 }
A internal/ui/theme/manager.go +132 -0
@@ -0,0 +1,132 @@
1+package theme
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Manager handles theme state and CSS application.
13+type Manager struct {
14+ scheme string // "light", "dark", "system"
15+ prefersDark bool // Resolved dark mode preference
16+ lightPalette Palette // Light theme colors
17+ darkPalette Palette // Dark theme colors
18+ cssProvider *gtk.CssProvider
19+}
20+
21+// NewManager creates a new theme manager from configuration.
22+func NewManager(ctx context.Context, cfg *config.Config) *Manager {
23+ log := logging.FromContext(ctx)
24+
25+ // Determine color scheme preference
26+ scheme := "system"
27+ if cfg != nil && cfg.Appearance.ColorScheme != "" {
28+ scheme = cfg.Appearance.ColorScheme
29+ }
30+
31+ // Resolve whether we should use dark mode
32+ prefersDark := ResolveColorScheme(scheme)
33+
34+ // Build palettes from config or defaults
35+ var lightPalette, darkPalette Palette
36+ if cfg != nil {
37+ lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
38+ darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
39+ } else {
40+ lightPalette = DefaultLightPalette()
41+ darkPalette = DefaultDarkPalette()
42+ }
43+
44+ log.Debug().
45+ Str("scheme", scheme).
46+ Bool("prefers_dark", prefersDark).
47+ Msg("theme manager initialized")
48+
49+ return &Manager{
50+ scheme: scheme,
51+ prefersDark: prefersDark,
52+ lightPalette: lightPalette,
53+ darkPalette: darkPalette,
54+ }
55+}
56+
57+// PrefersDark returns true if dark mode is active.
58+func (m *Manager) PrefersDark() bool {
59+ return m.prefersDark
60+}
61+
62+// GetCurrentPalette returns the active palette based on current scheme.
63+func (m *Manager) GetCurrentPalette() Palette {
64+ if m.prefersDark {
65+ return m.darkPalette
66+ }
67+ return m.lightPalette
68+}
69+
70+// GetLightPalette returns the light theme palette.
71+func (m *Manager) GetLightPalette() Palette {
72+ return m.lightPalette
73+}
74+
75+// GetDarkPalette returns the dark theme palette.
76+func (m *Manager) GetDarkPalette() Palette {
77+ return m.darkPalette
78+}
79+
80+// ApplyToDisplay loads the theme CSS into the display.
81+func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
82+ log := logging.FromContext(ctx)
83+
84+ if display == nil {
85+ log.Warn().Msg("cannot apply theme: display is nil")
86+ return
87+ }
88+
89+ // Generate CSS with current palette
90+ palette := m.GetCurrentPalette()
91+ css := GenerateCSS(palette)
92+
93+ // Create CSS provider if needed
94+ if m.cssProvider == nil {
95+ m.cssProvider = gtk.NewCssProvider()
96+ }
97+
98+ if m.cssProvider == nil {
99+ log.Error().Msg("failed to create CSS provider")
100+ return
101+ }
102+
103+ // Load CSS
104+ m.cssProvider.LoadFromString(css)
105+ gtk.StyleContextAddProviderForDisplay(
106+ display,
107+ m.cssProvider,
108+ uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
109+ )
110+
111+ log.Debug().
112+ Bool("dark_mode", m.prefersDark).
113+ Msg("theme CSS applied to display")
114+}
115+
116+// SetColorScheme changes the active color scheme at runtime.
117+func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
118+ log := logging.FromContext(ctx)
119+
120+ m.scheme = scheme
121+ m.prefersDark = ResolveColorScheme(scheme)
122+
123+ log.Info().
124+ Str("scheme", scheme).
125+ Bool("prefers_dark", m.prefersDark).
126+ Msg("color scheme changed")
127+
128+ // Re-apply CSS if display is available
129+ if display != nil {
130+ m.ApplyToDisplay(ctx, display)
131+ }
132+}
@@ -0,0 +1,132 @@
1+package theme
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Manager handles theme state and CSS application.
13+type Manager struct {
14+ scheme string // "light", "dark", "system"
15+ prefersDark bool // Resolved dark mode preference
16+ lightPalette Palette // Light theme colors
17+ darkPalette Palette // Dark theme colors
18+ cssProvider *gtk.CssProvider
19+}
20+
21+// NewManager creates a new theme manager from configuration.
22+func NewManager(ctx context.Context, cfg *config.Config) *Manager {
23+ log := logging.FromContext(ctx)
24+
25+ // Determine color scheme preference
26+ scheme := "system"
27+ if cfg != nil && cfg.Appearance.ColorScheme != "" {
28+ scheme = cfg.Appearance.ColorScheme
29+ }
30+
31+ // Resolve whether we should use dark mode
32+ prefersDark := ResolveColorScheme(scheme)
33+
34+ // Build palettes from config or defaults
35+ var lightPalette, darkPalette Palette
36+ if cfg != nil {
37+ lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
38+ darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
39+ } else {
40+ lightPalette = DefaultLightPalette()
41+ darkPalette = DefaultDarkPalette()
42+ }
43+
44+ log.Debug().
45+ Str("scheme", scheme).
46+ Bool("prefers_dark", prefersDark).
47+ Msg("theme manager initialized")
48+
49+ return &Manager{
50+ scheme: scheme,
51+ prefersDark: prefersDark,
52+ lightPalette: lightPalette,
53+ darkPalette: darkPalette,
54+ }
55+}
56+
57+// PrefersDark returns true if dark mode is active.
58+func (m *Manager) PrefersDark() bool {
59+ return m.prefersDark
60+}
61+
62+// GetCurrentPalette returns the active palette based on current scheme.
63+func (m *Manager) GetCurrentPalette() Palette {
64+ if m.prefersDark {
65+ return m.darkPalette
66+ }
67+ return m.lightPalette
68+}
69+
70+// GetLightPalette returns the light theme palette.
71+func (m *Manager) GetLightPalette() Palette {
72+ return m.lightPalette
73+}
74+
75+// GetDarkPalette returns the dark theme palette.
76+func (m *Manager) GetDarkPalette() Palette {
77+ return m.darkPalette
78+}
79+
80+// ApplyToDisplay loads the theme CSS into the display.
81+func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
82+ log := logging.FromContext(ctx)
83+
84+ if display == nil {
85+ log.Warn().Msg("cannot apply theme: display is nil")
86+ return
87+ }
88+
89+ // Generate CSS with current palette
90+ palette := m.GetCurrentPalette()
91+ css := GenerateCSS(palette)
92+
93+ // Create CSS provider if needed
94+ if m.cssProvider == nil {
95+ m.cssProvider = gtk.NewCssProvider()
96+ }
97+
98+ if m.cssProvider == nil {
99+ log.Error().Msg("failed to create CSS provider")
100+ return
101+ }
102+
103+ // Load CSS
104+ m.cssProvider.LoadFromString(css)
105+ gtk.StyleContextAddProviderForDisplay(
106+ display,
107+ m.cssProvider,
108+ uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION),
109+ )
110+
111+ log.Debug().
112+ Bool("dark_mode", m.prefersDark).
113+ Msg("theme CSS applied to display")
114+}
115+
116+// SetColorScheme changes the active color scheme at runtime.
117+func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
118+ log := logging.FromContext(ctx)
119+
120+ m.scheme = scheme
121+ m.prefersDark = ResolveColorScheme(scheme)
122+
123+ log.Info().
124+ Str("scheme", scheme).
125+ Bool("prefers_dark", m.prefersDark).
126+ Msg("color scheme changed")
127+
128+ // Re-apply CSS if display is available
129+ if display != nil {
130+ m.ApplyToDisplay(ctx, display)
131+ }
132+}
A internal/ui/theme/palette.go +128 -0
@@ -0,0 +1,128 @@
1+// Package theme provides GTK CSS styling for UI components.
2+package theme
3+
4+import (
5+ "fmt"
6+ "regexp"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+)
11+
12+// Palette holds semantic color tokens for theming.
13+type Palette struct {
14+ Background string // Main background color
15+ Surface string // Elevated surfaces (cards, popups)
16+ SurfaceVariant string // Secondary surfaces
17+ Text string // Primary text color
18+ Muted string // Secondary/disabled text
19+ Accent string // Primary accent color (actions, highlights)
20+ Border string // Border and divider lines
21+}
22+
23+// DefaultDarkPalette returns the default dark theme palette.
24+func DefaultDarkPalette() Palette {
25+ return Palette{
26+ Background: "#0a0a0b",
27+ Surface: "#1a1a1b",
28+ SurfaceVariant: "#2d2d2d",
29+ Text: "#ffffff",
30+ Muted: "#909090",
31+ Accent: "#4ade80",
32+ Border: "#333333",
33+ }
34+}
35+
36+// DefaultLightPalette returns the default light theme palette.
37+func DefaultLightPalette() Palette {
38+ return Palette{
39+ Background: "#fafafa",
40+ Surface: "#ffffff",
41+ SurfaceVariant: "#f0f0f0",
42+ Text: "#1a1a1a",
43+ Muted: "#666666",
44+ Accent: "#22c55e",
45+ Border: "#dddddd",
46+ }
47+}
48+
49+// PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
50+func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
51+ var defaults Palette
52+ if isDark {
53+ defaults = DefaultDarkPalette()
54+ } else {
55+ defaults = DefaultLightPalette()
56+ }
57+
58+ if cfg == nil {
59+ return defaults
60+ }
61+
62+ return Palette{
63+ Background: coalesce(cfg.Background, defaults.Background),
64+ Surface: coalesce(cfg.Surface, defaults.Surface),
65+ SurfaceVariant: coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
66+ Text: coalesce(cfg.Text, defaults.Text),
67+ Muted: coalesce(cfg.Muted, defaults.Muted),
68+ Accent: coalesce(cfg.Accent, defaults.Accent),
69+ Border: coalesce(cfg.Border, defaults.Border),
70+ }
71+}
72+
73+// coalesce returns the first non-empty string.
74+func coalesce(values ...string) string {
75+ for _, v := range values {
76+ if v != "" {
77+ return v
78+ }
79+ }
80+ return ""
81+}
82+
83+// hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
84+var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
85+
86+// ValidateHexColor checks if a string is a valid hex color.
87+func ValidateHexColor(color string) error {
88+ if color == "" {
89+ return nil // Empty is valid (will use default)
90+ }
91+ if !hexColorRegex.MatchString(color) {
92+ return fmt.Errorf("invalid hex color: %s", color)
93+ }
94+ return nil
95+}
96+
97+// Validate checks all palette colors are valid hex values.
98+func (p Palette) Validate() error {
99+ colors := map[string]string{
100+ "background": p.Background,
101+ "surface": p.Surface,
102+ "surface_variant": p.SurfaceVariant,
103+ "text": p.Text,
104+ "muted": p.Muted,
105+ "accent": p.Accent,
106+ "border": p.Border,
107+ }
108+
109+ for name, color := range colors {
110+ if err := ValidateHexColor(color); err != nil {
111+ return fmt.Errorf("%s: %w", name, err)
112+ }
113+ }
114+ return nil
115+}
116+
117+// ToCSSVars generates CSS custom property declarations.
118+func (p Palette) ToCSSVars() string {
119+ var sb strings.Builder
120+ sb.WriteString(" --bg: " + p.Background + ";\n")
121+ sb.WriteString(" --surface: " + p.Surface + ";\n")
122+ sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
123+ sb.WriteString(" --text: " + p.Text + ";\n")
124+ sb.WriteString(" --muted: " + p.Muted + ";\n")
125+ sb.WriteString(" --accent: " + p.Accent + ";\n")
126+ sb.WriteString(" --border: " + p.Border + ";\n")
127+ return sb.String()
128+}
@@ -0,0 +1,128 @@
1+// Package theme provides GTK CSS styling for UI components.
2+package theme
3+
4+import (
5+ "fmt"
6+ "regexp"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+)
11+
12+// Palette holds semantic color tokens for theming.
13+type Palette struct {
14+ Background string // Main background color
15+ Surface string // Elevated surfaces (cards, popups)
16+ SurfaceVariant string // Secondary surfaces
17+ Text string // Primary text color
18+ Muted string // Secondary/disabled text
19+ Accent string // Primary accent color (actions, highlights)
20+ Border string // Border and divider lines
21+}
22+
23+// DefaultDarkPalette returns the default dark theme palette.
24+func DefaultDarkPalette() Palette {
25+ return Palette{
26+ Background: "#0a0a0b",
27+ Surface: "#1a1a1b",
28+ SurfaceVariant: "#2d2d2d",
29+ Text: "#ffffff",
30+ Muted: "#909090",
31+ Accent: "#4ade80",
32+ Border: "#333333",
33+ }
34+}
35+
36+// DefaultLightPalette returns the default light theme palette.
37+func DefaultLightPalette() Palette {
38+ return Palette{
39+ Background: "#fafafa",
40+ Surface: "#ffffff",
41+ SurfaceVariant: "#f0f0f0",
42+ Text: "#1a1a1a",
43+ Muted: "#666666",
44+ Accent: "#22c55e",
45+ Border: "#dddddd",
46+ }
47+}
48+
49+// PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
50+func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
51+ var defaults Palette
52+ if isDark {
53+ defaults = DefaultDarkPalette()
54+ } else {
55+ defaults = DefaultLightPalette()
56+ }
57+
58+ if cfg == nil {
59+ return defaults
60+ }
61+
62+ return Palette{
63+ Background: coalesce(cfg.Background, defaults.Background),
64+ Surface: coalesce(cfg.Surface, defaults.Surface),
65+ SurfaceVariant: coalesce(cfg.SurfaceVariant, defaults.SurfaceVariant),
66+ Text: coalesce(cfg.Text, defaults.Text),
67+ Muted: coalesce(cfg.Muted, defaults.Muted),
68+ Accent: coalesce(cfg.Accent, defaults.Accent),
69+ Border: coalesce(cfg.Border, defaults.Border),
70+ }
71+}
72+
73+// coalesce returns the first non-empty string.
74+func coalesce(values ...string) string {
75+ for _, v := range values {
76+ if v != "" {
77+ return v
78+ }
79+ }
80+ return ""
81+}
82+
83+// hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
84+var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
85+
86+// ValidateHexColor checks if a string is a valid hex color.
87+func ValidateHexColor(color string) error {
88+ if color == "" {
89+ return nil // Empty is valid (will use default)
90+ }
91+ if !hexColorRegex.MatchString(color) {
92+ return fmt.Errorf("invalid hex color: %s", color)
93+ }
94+ return nil
95+}
96+
97+// Validate checks all palette colors are valid hex values.
98+func (p Palette) Validate() error {
99+ colors := map[string]string{
100+ "background": p.Background,
101+ "surface": p.Surface,
102+ "surface_variant": p.SurfaceVariant,
103+ "text": p.Text,
104+ "muted": p.Muted,
105+ "accent": p.Accent,
106+ "border": p.Border,
107+ }
108+
109+ for name, color := range colors {
110+ if err := ValidateHexColor(color); err != nil {
111+ return fmt.Errorf("%s: %w", name, err)
112+ }
113+ }
114+ return nil
115+}
116+
117+// ToCSSVars generates CSS custom property declarations.
118+func (p Palette) ToCSSVars() string {
119+ var sb strings.Builder
120+ sb.WriteString(" --bg: " + p.Background + ";\n")
121+ sb.WriteString(" --surface: " + p.Surface + ";\n")
122+ sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
123+ sb.WriteString(" --text: " + p.Text + ";\n")
124+ sb.WriteString(" --muted: " + p.Muted + ";\n")
125+ sb.WriteString(" --accent: " + p.Accent + ";\n")
126+ sb.WriteString(" --border: " + p.Border + ";\n")
127+ return sb.String()
128+}
A internal/ui/theme/system.go +51 -0
@@ -0,0 +1,51 @@
1+package theme
2+
3+import (
4+ "os"
5+ "strings"
6+
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
8+)
9+
10+// DetectSystemDarkMode checks if the system prefers dark mode.
11+// It checks multiple sources in order of preference:
12+// 1. GTK_THEME environment variable (contains "dark")
13+// 2. GTK Settings gtk-application-prefer-dark-theme property
14+// Returns true if dark mode is preferred.
15+func DetectSystemDarkMode() bool {
16+ // Check GTK_THEME environment variable first
17+ gtkTheme := os.Getenv("GTK_THEME")
18+ if gtkTheme != "" {
19+ if strings.Contains(strings.ToLower(gtkTheme), "dark") {
20+ return true
21+ }
22+ // If GTK_THEME is set but doesn't contain "dark", it's likely light
23+ return false
24+ }
25+
26+ // Try GTK Settings
27+ settings := gtk.SettingsGetDefault()
28+ if settings != nil {
29+ // gtk-application-prefer-dark-theme is a boolean property
30+ preferDark := settings.GetPropertyGtkApplicationPreferDarkTheme()
31+ return preferDark
32+ }
33+
34+ // Default to dark mode if detection fails (better for eyes)
35+ return true
36+}
37+
38+// ResolveColorScheme determines the effective dark mode preference.
39+// It takes the config value ("prefer-dark", "prefer-light", "default")
40+// and resolves "default" to the system preference.
41+func ResolveColorScheme(configScheme string) bool {
42+ switch strings.ToLower(configScheme) {
43+ case "prefer-dark", "dark":
44+ return true
45+ case "prefer-light", "light":
46+ return false
47+ default:
48+ // "default" or empty - follow system
49+ return DetectSystemDarkMode()
50+ }
51+}
@@ -0,0 +1,51 @@
1+package theme
2+
3+import (
4+ "os"
5+ "strings"
6+
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
8+)
9+
10+// DetectSystemDarkMode checks if the system prefers dark mode.
11+// It checks multiple sources in order of preference:
12+// 1. GTK_THEME environment variable (contains "dark")
13+// 2. GTK Settings gtk-application-prefer-dark-theme property
14+// Returns true if dark mode is preferred.
15+func DetectSystemDarkMode() bool {
16+ // Check GTK_THEME environment variable first
17+ gtkTheme := os.Getenv("GTK_THEME")
18+ if gtkTheme != "" {
19+ if strings.Contains(strings.ToLower(gtkTheme), "dark") {
20+ return true
21+ }
22+ // If GTK_THEME is set but doesn't contain "dark", it's likely light
23+ return false
24+ }
25+
26+ // Try GTK Settings
27+ settings := gtk.SettingsGetDefault()
28+ if settings != nil {
29+ // gtk-application-prefer-dark-theme is a boolean property
30+ preferDark := settings.GetPropertyGtkApplicationPreferDarkTheme()
31+ return preferDark
32+ }
33+
34+ // Default to dark mode if detection fails (better for eyes)
35+ return true
36+}
37+
38+// ResolveColorScheme determines the effective dark mode preference.
39+// It takes the config value ("prefer-dark", "prefer-light", "default")
40+// and resolves "default" to the system preference.
41+func ResolveColorScheme(configScheme string) bool {
42+ switch strings.ToLower(configScheme) {
43+ case "prefer-dark", "dark":
44+ return true
45+ case "prefer-light", "light":
46+ return false
47+ default:
48+ // "default" or empty - follow system
49+ return DetectSystemDarkMode()
50+ }
51+}
M internal/ui/window/main_window.go +8 -3
@@ -2,7 +2,10 @@
22 package window
33
44 import (
5+ "context"
6+
57 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
69 "github.com/bnema/dumber/internal/ui/component"
710 "github.com/jwijenbergh/puregotk/v4/gtk"
811 "github.com/rs/zerolog"
@@ -22,14 +25,16 @@ type MainWindow struct {
2225 contentArea *gtk.Box // Container for workspace content
2326
2427 cfg *config.Config
25- logger *zerolog.Logger
28+ logger zerolog.Logger
2629 }
2730
2831 // New creates a new main browser window.
29-func New(app *gtk.Application, cfg *config.Config, logger *zerolog.Logger) (*MainWindow, error) {
32+func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWindow, error) {
33+ log := logging.FromContext(ctx)
34+
3035 mw := &MainWindow{
3136 cfg: cfg,
32- logger: logger,
37+ logger: log.With().Str("component", "main-window").Logger(),
3338 }
3439
3540 // Create the application window
@@ -2,7 +2,10 @@
2 package window2 package window
3 3
4 import (4 import (
5+ "context"
6+
5 "github.com/bnema/dumber/internal/infrastructure/config"7 "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
6 "github.com/bnema/dumber/internal/ui/component"9 "github.com/bnema/dumber/internal/ui/component"
7 "github.com/jwijenbergh/puregotk/v4/gtk"10 "github.com/jwijenbergh/puregotk/v4/gtk"
8 "github.com/rs/zerolog"11 "github.com/rs/zerolog"
@@ -22,14 +25,16 @@ type MainWindow struct {
22 contentArea *gtk.Box // Container for workspace content25 contentArea *gtk.Box // Container for workspace content
23 26
24 cfg *config.Config27 cfg *config.Config
25- logger *zerolog.Logger28+ logger zerolog.Logger
26 }29 }
27 30
28 // New creates a new main browser window.31 // New creates a new main browser window.
29-func New(app *gtk.Application, cfg *config.Config, logger *zerolog.Logger) (*MainWindow, error) {32+func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWindow, error) {
33+ log := logging.FromContext(ctx)
34+
30 mw := &MainWindow{35 mw := &MainWindow{
31 cfg: cfg,36 cfg: cfg,
32- logger: logger,37+ logger: log.With().Str("component", "main-window").Logger(),
33 }38 }
34 39
35 // Create the application window40 // Create the application window

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