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 | |||
| 2 | 2 | ||
| 3 | 3 | import _ "embed" | |
| 4 | 4 | ||
| 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 | + | // | |
| 13 | 9 | //go:embed webui/color-scheme.js | |
| 14 | 10 | var ColorSchemeScript string | |
| 15 | 11 | ||
| 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 | |
| 20 | 17 | 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 time | 5 | + | // 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.js | 7 | + | // based on the __dumber_gtk_prefers_dark flag injected by Go. |
| 8 | - | var MainWorldScript string | 8 | + | // |
| 9 | - | ||||
| 10 | - | //go:embed webui/gui.min.js | |||
| 11 | - | var GUIScript string | |||
| 12 | - | ||||
| 13 | //go:embed webui/color-scheme.js | 9 | //go:embed webui/color-scheme.js | ||
| 14 | var ColorSchemeScript string | 10 | var ColorSchemeScript string | ||
| 15 | 11 | ||||
| 16 | - | // GUI component styles extracted from Svelte components | 12 | + | // Deprecated: These variables are kept for backwards compatibility during migration. |
| 17 | - | // These are injected into the isolated world using UserStyleSheet | 13 | + | // 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.css | 15 | + | var MainWorldScript string |
| 16 | + | var GUIScript string | |||
| 20 | var ComponentStyles string | 17 | var ComponentStyles string | ||
M internal/application/usecase/search_history.go +0 -18
| @@ -35,12 +35,6 @@ type SearchOutput struct { | |||
| 35 | 35 | ||
| 36 | 36 | // Search performs a fuzzy search on history entries. | |
| 37 | 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 | 38 | if input.Query == "" { | |
| 45 | 39 | return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil | |
| 46 | 40 | } | |
| @@ -55,22 +49,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||
| 55 | 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 | 52 | return &SearchOutput{Matches: matches}, nil | |
| 64 | 53 | } | |
| 65 | 54 | ||
| 66 | 55 | // GetRecent retrieves recent history entries with pagination. | |
| 67 | 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 | 57 | if limit <= 0 { | |
| 75 | 58 | limit = 50 // Default limit | |
| 76 | 59 | } | |
| @@ -80,7 +63,6 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||
| 80 | 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 | 66 | return entries, nil | |
| 85 | 67 | } | |
| 86 | 68 | ||
| @@ -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{}}, nil | 39 | 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}, nil | 52 | 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 limit | 58 | 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, nil | 66 | 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( | |||
| 24 | 24 | ); | |
| 25 | 25 | ||
| 26 | 26 | -- Triggers to keep FTS in sync | |
| 27 | + | -- +goose StatementBegin | |
| 27 | 28 | CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN | |
| 28 | 29 | INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title); | |
| 29 | 30 | END; | |
| 31 | + | -- +goose StatementEnd | |
| 30 | 32 | ||
| 33 | + | -- +goose StatementBegin | |
| 31 | 34 | CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN | |
| 32 | 35 | INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title); | |
| 33 | 36 | END; | |
| 37 | + | -- +goose StatementEnd | |
| 34 | 38 | ||
| 39 | + | -- +goose StatementBegin | |
| 35 | 40 | CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN | |
| 36 | 41 | INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title); | |
| 37 | 42 | INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title); | |
| 38 | 43 | END; | |
| 44 | + | -- +goose StatementEnd | |
| 39 | 45 | ||
| 40 | 46 | -- +goose Down | |
| 41 | 47 | 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 sync | 26 | -- Triggers to keep FTS in sync | ||
| 27 | + | -- +goose StatementBegin | |||
| 27 | CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN | 28 | 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 BEGIN | 34 | 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 BEGIN | 40 | 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 Down | 46 | -- +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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "strings" | |
| 8 | 6 | ||
| 9 | 7 | "github.com/bnema/dumber/assets" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 9 | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | 10 | ) | |
| @@ -19,42 +16,18 @@ const ( | |||
| 19 | 16 | MessageHandlerName = "dumber" | |
| 20 | 17 | ) | |
| 21 | 18 | ||
| 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. | |
| 24 | 21 | type ContentInjector struct { | |
| 25 | - | allowList []string | |
| 26 | - | blockList []string | |
| 27 | 22 | prefersDark bool | |
| 28 | 23 | } | |
| 29 | 24 | ||
| 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, | |
| 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 | 33 | // PrefersDark returns the current dark mode preference. | |
| @@ -62,13 +35,17 @@ func (ci *ContentInjector) PrefersDark() bool { | |||
| 62 | 35 | return ci.prefersDark | |
| 63 | 36 | } | |
| 64 | 37 | ||
| 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 | |
| 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 | 49 | func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) { | |
| 73 | 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 | 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 | 67 | darkModeScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | |
| 91 | 68 | addScript( | |
| 92 | 69 | webkit.NewUserScript( | |
| @@ -99,11 +76,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 99 | 76 | "gtk-dark-mode", | |
| 100 | 77 | ) | |
| 101 | 78 | ||
| 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 | |
| 104 | 80 | if webviewID != 0 { | |
| 105 | 81 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 106 | - | // Main world injection | |
| 107 | 82 | addScript( | |
| 108 | 83 | webkit.NewUserScript( | |
| 109 | 84 | idScript, | |
| @@ -114,21 +89,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 114 | 89 | ), | |
| 115 | 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 | 95 | if assets.ColorSchemeScript != "" { | |
| 133 | 96 | addScript( | |
| 134 | 97 | webkit.NewUserScript( | |
| @@ -140,72 +103,9 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 140 | 103 | ), | |
| 141 | 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 | 106 | } 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") | |
| 207 | 108 | } | |
| 208 | 109 | ||
| 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") | |
| 211 | 111 | } | |
| @@ -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 bool | 22 | 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 ci | 29 | + | 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.prefersDark | 35 | 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 = allowList | 40 | + | func (ci *ContentInjector) SetPrefersDark(prefersDark bool) { |
| 68 | - | ci.blockList = blockList | 41 | + | 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 ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | - | "github.com/rs/zerolog" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | // PoolConfig configures the WebView pool behavior. | |
| @@ -39,7 +38,6 @@ type WebViewPool struct { | |||
| 39 | 38 | config PoolConfig | |
| 40 | 39 | ||
| 41 | 40 | pool chan *WebView | |
| 42 | - | logger zerolog.Logger | |
| 43 | 41 | ctx context.Context | |
| 44 | 42 | injector *ContentInjector | |
| 45 | 43 | router *MessageRouter | |
| @@ -49,7 +47,7 @@ type WebViewPool struct { | |||
| 49 | 47 | } | |
| 50 | 48 | ||
| 51 | 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 | 51 | if cfg.MaxSize <= 0 { | |
| 54 | 52 | cfg.MaxSize = 8 | |
| 55 | 53 | } | |
| @@ -68,8 +66,7 @@ func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *Setting | |||
| 68 | 66 | settings: settings, | |
| 69 | 67 | config: cfg, | |
| 70 | 68 | 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"), | |
| 73 | 70 | injector: injector, | |
| 74 | 71 | router: router, | |
| 75 | 72 | } | |
| @@ -79,6 +76,8 @@ func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *Setting | |||
| 79 | 76 | ||
| 80 | 77 | // Acquire gets a WebView from the pool or creates a new one. | |
| 81 | 78 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |
| 79 | + | log := logging.FromContext(p.ctx) | |
| 80 | + | ||
| 82 | 81 | if p.closed.Load() { | |
| 83 | 82 | return nil, context.Canceled | |
| 84 | 83 | } | |
| @@ -89,7 +88,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 89 | 88 | if wv != nil && !wv.IsDestroyed() { | |
| 90 | 89 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | |
| 91 | 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 | 92 | return wv, nil | |
| 94 | 93 | } | |
| 95 | 94 | // 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 | 99 | // 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 | 101 | return p.createWebView() | |
| 103 | 102 | } | |
| 104 | 103 | ||
| 105 | 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 | 108 | if err != nil { | |
| 108 | 109 | return nil, err | |
| 109 | 110 | } | |
| @@ -112,7 +113,7 @@ func (p *WebViewPool) createWebView() (*WebView, error) { | |||
| 112 | 113 | wv.inner.SetVisible(true) | |
| 113 | 114 | ||
| 114 | 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 | 119 | return wv, nil | |
| @@ -138,13 +139,15 @@ func (p *WebViewPool) Release(wv *WebView) { | |||
| 138 | 139 | wv.OnProgressChanged = nil | |
| 139 | 140 | wv.OnClose = nil | |
| 140 | 141 | ||
| 142 | + | log := logging.FromContext(p.ctx) | |
| 143 | + | ||
| 141 | 144 | // Try to return to pool (non-blocking) | |
| 142 | 145 | select { | |
| 143 | 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 | 148 | default: | |
| 146 | 149 | // 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 | 151 | wv.Destroy() | |
| 149 | 152 | } | |
| 150 | 153 | } | |
| @@ -163,6 +166,8 @@ func (p *WebViewPool) Prewarm(count int) { | |||
| 163 | 166 | ||
| 164 | 167 | // prewarmSync creates WebViews synchronously. | |
| 165 | 168 | func (p *WebViewPool) prewarmSync(count int) { | |
| 169 | + | log := logging.FromContext(p.ctx) | |
| 170 | + | ||
| 166 | 171 | for i := 0; i < count && !p.closed.Load(); i++ { | |
| 167 | 172 | // Check if pool is already at capacity | |
| 168 | 173 | if len(p.pool) >= p.config.MaxSize { | |
| @@ -171,14 +176,14 @@ func (p *WebViewPool) prewarmSync(count int) { | |||
| 171 | 176 | ||
| 172 | 177 | wv, err := p.createWebView() | |
| 173 | 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 | 180 | continue | |
| 176 | 181 | } | |
| 177 | 182 | ||
| 178 | 183 | // Try to add to pool | |
| 179 | 184 | select { | |
| 180 | 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 | 187 | default: | |
| 183 | 188 | // Pool full, destroy | |
| 184 | 189 | wv.Destroy() | |
| @@ -201,6 +206,8 @@ func (p *WebViewPool) Close() { | |||
| 201 | 206 | return // Already closed | |
| 202 | 207 | } | |
| 203 | 208 | ||
| 209 | + | log := logging.FromContext(p.ctx) | |
| 210 | + | ||
| 204 | 211 | // Wait for any background operations | |
| 205 | 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 | } | |
| @@ -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 PoolConfig | 38 | config PoolConfig | ||
| 40 | 39 | ||||
| 41 | pool chan *WebView | 40 | pool chan *WebView | ||
| 42 | - | logger zerolog.Logger | |||
| 43 | ctx context.Context | 41 | ctx context.Context | ||
| 44 | injector *ContentInjector | 42 | injector *ContentInjector | ||
| 45 | router *MessageRouter | 43 | 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 = 8 | 52 | 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.Canceled | 82 | 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, nil | 92 | return wv, nil | ||
| 94 | } | 93 | } | ||
| 95 | // Fall through to create new if the pooled one was destroyed | 94 | // 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 new | 99 | // 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, err | 109 | 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, nil | 119 | return wv, nil | ||
| @@ -138,13 +139,15 @@ func (p *WebViewPool) Release(wv *WebView) { | |||||
| 138 | wv.OnProgressChanged = nil | 139 | wv.OnProgressChanged = nil | ||
| 139 | wv.OnClose = nil | 140 | 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 view | 149 | // 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 capacity | 172 | // 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 | continue | 180 | continue | ||
| 176 | } | 181 | } | ||
| 177 | 182 | ||||
| 178 | // Try to add to pool | 183 | // 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, destroy | 188 | // Pool full, destroy | ||
| 184 | wv.Destroy() | 189 | wv.Destroy() | ||
| @@ -201,6 +206,8 @@ func (p *WebViewPool) Close() { | |||||
| 201 | return // Already closed | 206 | return // Already closed | ||
| 202 | } | 207 | } | ||
| 203 | 208 | ||||
| 209 | + | log := logging.FromContext(p.ctx) | |||
| 210 | + | ||||
| 204 | // Wait for any background operations | 211 | // 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 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "net/http" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | 10 | "github.com/bnema/puregotk-webkit/webkit" | |
| 9 | 11 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 10 | 12 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -47,10 +49,12 @@ type DumbSchemeHandler struct { | |||
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 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 | 55 | h := &DumbSchemeHandler{ | |
| 52 | 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 | 60 | // Register default pages | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | package webkit | 1 | 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 pages | 60 | // Register default pages | ||
M internal/infrastructure/webkit/settings.go +15 -10
| @@ -1,37 +1,40 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "sync" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 7 | 9 | "github.com/bnema/puregotk-webkit/webkit" | |
| 8 | - | "github.com/rs/zerolog" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // SettingsManager creates and manages WebKit Settings instances from config. | |
| 12 | 13 | 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 | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 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 | 21 | return &SettingsManager{ | |
| 21 | - | cfg: cfg, | |
| 22 | - | logger: logger.With().Str("component", "webkit-settings").Logger(), | |
| 22 | + | cfg: cfg, | |
| 23 | + | ctx: logging.WithComponent(ctx, "webkit-settings"), | |
| 23 | 24 | } | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 26 | 27 | // CreateSettings creates a new webkit.Settings instance configured from the current config. | |
| 27 | 28 | func (sm *SettingsManager) CreateSettings() *webkit.Settings { | |
| 29 | + | log := logging.FromContext(sm.ctx) | |
| 30 | + | ||
| 28 | 31 | sm.mu.RLock() | |
| 29 | 32 | cfg := sm.cfg | |
| 30 | 33 | sm.mu.RUnlock() | |
| 31 | 34 | ||
| 32 | 35 | settings := webkit.NewSettings() | |
| 33 | 36 | if settings == nil { | |
| 34 | - | sm.logger.Error().Msg("failed to create webkit settings") | |
| 37 | + | log.Error().Msg("failed to create webkit settings") | |
| 35 | 38 | return nil | |
| 36 | 39 | } | |
| 37 | 40 | ||
| @@ -105,7 +108,8 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config. | |||
| 105 | 108 | // WebRTC | |
| 106 | 109 | settings.SetEnableWebrtc(true) | |
| 107 | 110 | ||
| 108 | - | sm.logger.Debug(). | |
| 111 | + | log := logging.FromContext(sm.ctx) | |
| 112 | + | log.Debug(). | |
| 109 | 113 | Str("sans_font", cfg.Appearance.SansFont). | |
| 110 | 114 | Str("rendering_mode", string(cfg.RenderingMode)). | |
| 111 | 115 | Bool("developer_extras", cfg.Debug.EnableDevTools). | |
| @@ -116,10 +120,11 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config. | |||
| 116 | 120 | // Note: This doesn't update already-created Settings instances. | |
| 117 | 121 | // New WebViews will use the updated config. | |
| 118 | 122 | func (sm *SettingsManager) UpdateFromConfig(cfg *config.Config) { | |
| 123 | + | log := logging.FromContext(sm.ctx) | |
| 119 | 124 | sm.mu.Lock() | |
| 120 | 125 | defer sm.mu.Unlock() | |
| 121 | 126 | sm.cfg = cfg | |
| 122 | - | sm.logger.Debug().Msg("settings config updated") | |
| 127 | + | log.Debug().Msg("settings config updated") | |
| 123 | 128 | } | |
| 124 | 129 | ||
| 125 | 130 | // ApplyToWebView applies current settings to an existing WebView. | |
| @@ -1,37 +1,40 @@ | |||||
| 1 | package webkit | 1 | 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.Config | 14 | + | cfg *config.Config |
| 14 | - | logger zerolog.Logger | 15 | + | ctx context.Context |
| 15 | - | mu sync.RWMutex | 16 | + | 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.cfg | 32 | 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 nil | 38 | return nil | ||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| @@ -105,7 +108,8 @@ func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config. | |||||
| 105 | // WebRTC | 108 | // 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 = cfg | 126 | 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 { | |||
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | 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 | 116 | if wkCtx == nil || !wkCtx.IsInitialized() { | |
| 115 | 117 | return nil, fmt.Errorf("webkit context not initialized") | |
| 116 | 118 | } | |
| @@ -123,7 +125,7 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog. | |||
| 123 | 125 | wv := &WebView{ | |
| 124 | 126 | inner: inner, | |
| 125 | 127 | ucm: inner.GetUserContentManager(), | |
| 126 | - | logger: logger.With().Str("component", "webview").Logger(), | |
| 128 | + | logger: log.With().Str("component", "webview").Logger(), | |
| 127 | 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 | 148 | // NewWebViewWithRelated creates a WebView that shares session/cookies with parent. | |
| 147 | 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 | 153 | if parent == nil { | |
| 150 | 154 | return nil, fmt.Errorf("parent webview is nil") | |
| 151 | 155 | } | |
| @@ -161,7 +165,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze | |||
| 161 | 165 | wv := &WebView{ | |
| 162 | 166 | inner: inner, | |
| 163 | 167 | ucm: inner.GetUserContentManager(), | |
| 164 | - | logger: logger.With().Str("component", "webview-popup").Logger(), | |
| 168 | + | logger: log.With().Str("component", "webview-popup").Logger(), | |
| 165 | 169 | signalIDs: make([]uint32, 0, 6), | |
| 166 | 170 | } | |
| 167 | 171 | ||
| @@ -185,10 +189,12 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze | |||
| 185 | 189 | func (wv *WebView) connectSignals() { | |
| 186 | 190 | // load-changed signal | |
| 187 | 191 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | |
| 192 | + | uri := inner.GetUri() | |
| 193 | + | title := inner.GetTitle() | |
| 188 | 194 | ||
| 189 | 195 | wv.mu.Lock() | |
| 190 | - | wv.uri = inner.GetUri() | |
| 191 | - | wv.title = inner.GetTitle() | |
| 196 | + | wv.uri = uri | |
| 197 | + | wv.title = title | |
| 192 | 198 | wv.canGoBack = inner.CanGoBack() | |
| 193 | 199 | wv.canGoFwd = inner.CanGoForward() | |
| 194 | 200 | wv.progress = inner.GetEstimatedLoadProgress() | |
| @@ -196,8 +202,14 @@ func (wv *WebView) connectSignals() { | |||
| 196 | 202 | switch event { | |
| 197 | 203 | case webkit.LoadStartedValue: | |
| 198 | 204 | 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 | 210 | case webkit.LoadFinishedValue: | |
| 200 | 211 | wv.isLoading = false | |
| 212 | + | wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished") | |
| 201 | 213 | } | |
| 202 | 214 | wv.mu.Unlock() | |
| 203 | 215 | ||
| @@ -551,9 +563,8 @@ func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector | |||
| 551 | 563 | } | |
| 552 | 564 | ||
| 553 | 565 | if injector != nil { | |
| 554 | - | log.Debug().Msg("AttachFrontend: injecting scripts and styles") | |
| 566 | + | log.Debug().Msg("AttachFrontend: injecting scripts") | |
| 555 | 567 | injector.InjectScripts(ctx, wv.ucm, wv.id) | |
| 556 | - | injector.InjectStyles(ctx, wv.ucm) | |
| 557 | 568 | } | |
| 558 | 569 | ||
| 559 | 570 | 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 signal | 190 | // 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 = true | 204 | 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 = false | 211 | 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 ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 16 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 17 | 17 | "github.com/bnema/dumber/internal/ui/component" | |
| 18 | - | uihandler "github.com/bnema/dumber/internal/ui/handler" | |
| 19 | 18 | "github.com/bnema/dumber/internal/ui/input" | |
| 20 | 19 | "github.com/bnema/dumber/internal/ui/layout" | |
| 21 | - | "github.com/bnema/dumber/internal/ui/theme" | |
| 22 | 20 | "github.com/bnema/dumber/internal/ui/window" | |
| 23 | 21 | ) | |
| 24 | 22 | ||
| @@ -52,7 +50,6 @@ type App struct { | |||
| 52 | 50 | pool *webkit.WebViewPool | |
| 53 | 51 | injector *webkit.ContentInjector | |
| 54 | 52 | router *webkit.MessageRouter | |
| 55 | - | overlay *component.OverlayController | |
| 56 | 53 | settings *webkit.SettingsManager | |
| 57 | 54 | webViews map[entity.PaneID]*webkit.WebView | |
| 58 | 55 | ||
| @@ -81,14 +78,10 @@ func New(deps *Dependencies) (*App, error) { | |||
| 81 | 78 | pool: deps.Pool, | |
| 82 | 79 | injector: deps.Injector, | |
| 83 | 80 | router: deps.MessageRouter, | |
| 84 | - | overlay: deps.Overlay, | |
| 85 | 81 | settings: deps.Settings, | |
| 86 | 82 | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 87 | 83 | cancel: cancel, | |
| 88 | 84 | } | |
| 89 | - | if app.overlay == nil { | |
| 90 | - | app.overlay = component.NewOverlayController(ctx) | |
| 91 | - | } | |
| 92 | 85 | if app.router == nil { | |
| 93 | 86 | app.router = webkit.NewMessageRouter(ctx) | |
| 94 | 87 | } | |
| @@ -140,15 +133,17 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 140 | 133 | ||
| 141 | 134 | // Create the main window | |
| 142 | 135 | 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 | 137 | if err != nil { | |
| 145 | 138 | log.Error().Err(err).Msg("failed to create main window") | |
| 146 | 139 | return | |
| 147 | 140 | } | |
| 148 | 141 | ||
| 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 | + | } | |
| 152 | 147 | } | |
| 153 | 148 | ||
| 154 | 149 | // Initialize widget factory for pane layout | |
| @@ -158,7 +153,6 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 158 | 153 | a.keyboardHandler = input.NewKeyboardHandler( | |
| 159 | 154 | ctx, | |
| 160 | 155 | &a.deps.Config.Workspace, | |
| 161 | - | a.deps.Logger, | |
| 162 | 156 | ) | |
| 163 | 157 | a.keyboardHandler.SetOnAction(a.handleKeyboardAction) | |
| 164 | 158 | // Wrap handleModeChange to match the callback signature | |
| @@ -184,10 +178,6 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 184 | 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 | 181 | // Create an initial tab | |
| 192 | 182 | a.createInitialTab(ctx) | |
| 193 | 183 | ||
| @@ -276,9 +266,8 @@ func (a *App) Quit() { | |||
| 276 | 266 | func RunWithArgs(ctx context.Context, deps *Dependencies) int { | |
| 277 | 267 | app, err := New(deps) | |
| 278 | 268 | 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") | |
| 282 | 271 | return 1 | |
| 283 | 272 | } | |
| 284 | 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 | 482 | // Create workspace view | |
| 494 | - | wsView := component.NewWorkspaceView(a.widgetFactory, *a.deps.Logger) | |
| 483 | + | wsView := component.NewWorkspaceView(ctx, a.widgetFactory) | |
| 495 | 484 | if wsView == nil { | |
| 496 | 485 | log.Error().Msg("failed to create workspace view") | |
| 497 | 486 | return | |
| @@ -679,52 +668,6 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir | |||
| 679 | 668 | 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 | 671 | // attachWorkspaceWebViews ensures each pane has a WebView widget attached. | |
| 729 | 672 | func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) { | |
| 730 | 673 | 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.WebViewPool | 50 | pool *webkit.WebViewPool | ||
| 53 | injector *webkit.ContentInjector | 51 | injector *webkit.ContentInjector | ||
| 54 | router *webkit.MessageRouter | 52 | router *webkit.MessageRouter | ||
| 55 | - | overlay *component.OverlayController | |||
| 56 | settings *webkit.SettingsManager | 53 | settings *webkit.SettingsManager | ||
| 57 | webViews map[entity.PaneID]*webkit.WebView | 54 | 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 window | 134 | // Create the main window | ||
| 142 | var err error | 135 | 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 | return | 139 | return | ||
| 147 | } | 140 | } | ||
| 148 | 141 | ||||
| 149 | - | // Apply GTK CSS styling for tab bar | 142 | + | // 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 layout | 149 | // 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 signature | 158 | // 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 tab | 181 | // 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 1 | 271 | 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 view | 482 | // 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 | return | 486 | return | ||
| @@ -679,52 +668,6 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir | |||||
| 679 | return nil | 668 | 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 ( | |||
| 17 | 17 | const ( | |
| 18 | 18 | omniboxWidth = 600 | |
| 19 | 19 | omniboxMaxResults = 10 | |
| 20 | - | debounceDelayMs = 120 | |
| 20 | + | debounceDelayMs = 250 // Increased for stability | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // ViewMode distinguishes history search from favorites display. | |
| @@ -82,6 +82,7 @@ type Omnibox struct { | |||
| 82 | 82 | // Debouncing | |
| 83 | 83 | debounceTimer *time.Timer | |
| 84 | 84 | debounceMu sync.Mutex | |
| 85 | + | lastQuery string // Prevent duplicate searches | |
| 85 | 86 | } | |
| 86 | 87 | ||
| 87 | 88 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -331,11 +332,26 @@ func (o *Omnibox) onEntryChanged() { | |||
| 331 | 332 | // performSearch executes the search based on current view mode and query. | |
| 332 | 333 | func (o *Omnibox) performSearch() { | |
| 333 | 334 | o.mu.RLock() | |
| 335 | + | visible := o.visible | |
| 334 | 336 | mode := o.viewMode | |
| 335 | 337 | o.mu.RUnlock() | |
| 336 | 338 | ||
| 339 | + | // Skip search if omnibox is hidden | |
| 340 | + | if !visible { | |
| 341 | + | return | |
| 342 | + | } | |
| 343 | + | ||
| 337 | 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 | 355 | if mode == ViewModeFavorites { | |
| 340 | 356 | o.loadFavorites(query) | |
| 341 | 357 | return | |
| @@ -352,12 +368,9 @@ func (o *Omnibox) performSearch() { | |||
| 352 | 368 | ctx := o.ctx | |
| 353 | 369 | log := logging.FromContext(ctx) | |
| 354 | 370 | if o.historyUC == nil { | |
| 355 | - | log.Debug().Msg("omnibox search: historyUC is nil") | |
| 356 | 371 | return | |
| 357 | 372 | } | |
| 358 | 373 | ||
| 359 | - | log.Debug().Str("query", query).Int("limit", omniboxMaxResults).Msg("omnibox performing history search") | |
| 360 | - | ||
| 361 | 374 | input := usecase.SearchInput{ | |
| 362 | 375 | Query: query, | |
| 363 | 376 | Limit: omniboxMaxResults, | |
| @@ -368,8 +381,6 @@ func (o *Omnibox) performSearch() { | |||
| 368 | 381 | return | |
| 369 | 382 | } | |
| 370 | 383 | ||
| 371 | - | log.Debug().Int("results", len(output.Matches)).Str("query", query).Msg("omnibox search completed") | |
| 372 | - | ||
| 373 | 384 | suggestions := make([]Suggestion, 0, len(output.Matches)) | |
| 374 | 385 | for _, r := range output.Matches { | |
| 375 | 386 | suggestions = append(suggestions, Suggestion{ | |
| @@ -394,18 +405,13 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 394 | 405 | log := logging.FromContext(ctx) | |
| 395 | 406 | var suggestions []Suggestion | |
| 396 | 407 | ||
| 397 | - | log.Debug().Str("behavior", o.initialBehavior).Msg("omnibox loading initial history") | |
| 398 | - | ||
| 399 | 408 | switch o.initialBehavior { | |
| 400 | 409 | case "none": | |
| 401 | - | // Show nothing | |
| 402 | - | log.Debug().Msg("omnibox initial behavior: none") | |
| 403 | 410 | o.idleAddUpdateSuggestions(nil) | |
| 404 | 411 | return | |
| 405 | 412 | ||
| 406 | 413 | case "most_visited": | |
| 407 | 414 | // TODO: Implement GetMostVisited in use case if needed | |
| 408 | - | // For now, fall through to recent | |
| 409 | 415 | fallthrough | |
| 410 | 416 | ||
| 411 | 417 | case "recent", "": | |
| @@ -414,7 +420,6 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 414 | 420 | log.Error().Err(err).Msg("failed to load recent history") | |
| 415 | 421 | return | |
| 416 | 422 | } | |
| 417 | - | log.Debug().Int("results", len(results)).Msg("omnibox loaded recent history") | |
| 418 | 423 | suggestions = make([]Suggestion, 0, len(results)) | |
| 419 | 424 | for _, r := range results { | |
| 420 | 425 | suggestions = append(suggestions, Suggestion{ | |
| @@ -543,8 +548,9 @@ func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | |||
| 543 | 548 | if displayText == "" { | |
| 544 | 549 | displayText = s.URL | |
| 545 | 550 | } | |
| 546 | - | label := gtk.NewLabel(&displayText) | |
| 551 | + | label := gtk.NewLabel(nil) | |
| 547 | 552 | if label != nil { | |
| 553 | + | label.SetText(displayText) // Explicitly set text | |
| 548 | 554 | label.AddCssClass("omnibox-suggestion-title") | |
| 549 | 555 | label.SetHalign(gtk.AlignStartValue) | |
| 550 | 556 | label.SetHexpand(true) | |
| @@ -552,19 +558,16 @@ func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | |||
| 552 | 558 | box.Append(&label.Widget) | |
| 553 | 559 | } | |
| 554 | 560 | ||
| 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) | |
| 559 | 564 | 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") | |
| 568 | 571 | box.Append(&shortcutLabel.Widget) | |
| 569 | 572 | } | |
| 570 | 573 | } | |
| @@ -592,8 +595,9 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 592 | 595 | if displayText == "" { | |
| 593 | 596 | displayText = f.URL | |
| 594 | 597 | } | |
| 595 | - | label := gtk.NewLabel(&displayText) | |
| 598 | + | label := gtk.NewLabel(nil) | |
| 596 | 599 | if label != nil { | |
| 600 | + | label.SetText(displayText) | |
| 597 | 601 | label.AddCssClass("omnibox-suggestion-title") | |
| 598 | 602 | label.SetHalign(gtk.AlignStartValue) | |
| 599 | 603 | label.SetHexpand(true) | |
| @@ -601,19 +605,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 601 | 605 | box.Append(&label.Widget) | |
| 602 | 606 | } | |
| 603 | 607 | ||
| 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) | |
| 608 | 611 | 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") | |
| 617 | 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 | 626 | // selectIndex selects a row by index. | |
| 626 | 627 | 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 | |
| 630 | 630 | row := o.listBox.GetRowAtIndex(index) | |
| 631 | 631 | if row != nil { | |
| 632 | 632 | o.listBox.SelectRow(row) | |
| 633 | - | o.selectedIndex = index | |
| 633 | + | // selectedIndex is updated by rowSelectedCb | |
| 634 | 634 | } | |
| 635 | 635 | } | |
| 636 | 636 | ||
| @@ -877,11 +877,9 @@ func (o *Omnibox) SetOnClose(fn func()) { | |||
| 877 | 877 | ||
| 878 | 878 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | |
| 879 | 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 | 880 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 883 | 881 | o.updateSuggestions(suggestions) | |
| 884 | - | return false // One-shot callback, return false to remove source | |
| 882 | + | return false // One-shot callback | |
| 885 | 883 | } | |
| 886 | 884 | glib.IdleAdd(&cb, 0) | |
| 887 | 885 | } | |
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | const ( | 17 | const ( | ||
| 18 | omniboxWidth = 600 | 18 | omniboxWidth = 600 | ||
| 19 | omniboxMaxResults = 10 | 19 | omniboxMaxResults = 10 | ||
| 20 | - | debounceDelayMs = 120 | 20 | + | 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 | // Debouncing | 82 | // Debouncing | ||
| 83 | debounceTimer *time.Timer | 83 | debounceTimer *time.Timer | ||
| 84 | debounceMu sync.Mutex | 84 | 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.viewMode | 336 | 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 | return | 357 | return | ||
| @@ -352,12 +368,9 @@ func (o *Omnibox) performSearch() { | |||||
| 352 | ctx := o.ctx | 368 | 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 | return | 371 | 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 | return | 381 | 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 []Suggestion | 406 | 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 | return | 411 | return | ||
| 405 | 412 | ||||
| 406 | case "most_visited": | 413 | case "most_visited": | ||
| 407 | // TODO: Implement GetMostVisited in use case if needed | 414 | // TODO: Implement GetMostVisited in use case if needed | ||
| 408 | - | // For now, fall through to recent | |||
| 409 | fallthrough | 415 | 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 | return | 421 | 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.URL | 549 | 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.URL | 596 | 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 label | 608 | + | // 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 = index | 633 | + | // 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 source | 882 | + | 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 @@ | |||
| 2 | 2 | package component | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 11 | "github.com/bnema/dumber/internal/ui/layout" | |
| 10 | 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 | 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 | 75 | container := factory.NewBox(layout.OrientationVertical, 0) | |
| 72 | 76 | container.SetHexpand(true) | |
| 73 | 77 | container.SetVexpand(true) | |
| @@ -76,12 +80,12 @@ func NewWorkspaceView(factory layout.WidgetFactory, logger zerolog.Logger) *Work | |||
| 76 | 80 | wv := &WorkspaceView{ | |
| 77 | 81 | factory: factory, | |
| 78 | 82 | container: container, | |
| 79 | - | logger: logger.With().Str("component", "workspace-view").Logger(), | |
| 83 | + | logger: log.With().Str("component", "workspace-view").Logger(), | |
| 80 | 84 | paneViews: make(map[entity.PaneID]*PaneView), | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 83 | 87 | // 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 | 90 | return wv | |
| 87 | 91 | } | |
| @@ -2,10 +2,12 @@ | |||||
| 2 | package component | 2 | 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 factory | 87 | // 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 wv | 90 | return wv | ||
| 87 | } | 91 | } | ||
M internal/ui/deps.go +4 -7
| @@ -9,8 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 11 | "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" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | 15 | // Dependencies holds all injected dependencies for the UI layer. | |
| @@ -19,7 +18,9 @@ type Dependencies struct { | |||
| 19 | 18 | // Core context and configuration | |
| 20 | 19 | Ctx context.Context | |
| 21 | 20 | Config *config.Config | |
| 22 | - | Logger *zerolog.Logger | |
| 21 | + | ||
| 22 | + | // Theme management | |
| 23 | + | Theme *theme.Manager | |
| 23 | 24 | ||
| 24 | 25 | // WebKit infrastructure | |
| 25 | 26 | WebContext *webkit.WebKitContext | |
| @@ -28,7 +29,6 @@ type Dependencies struct { | |||
| 28 | 29 | Settings *webkit.SettingsManager | |
| 29 | 30 | Injector *webkit.ContentInjector | |
| 30 | 31 | MessageRouter *webkit.MessageRouter | |
| 31 | - | Overlay *component.OverlayController | |
| 32 | 32 | ||
| 33 | 33 | // Repositories | |
| 34 | 34 | HistoryRepo repository.HistoryRepository | |
| @@ -53,9 +53,6 @@ func (d *Dependencies) Validate() error { | |||
| 53 | 53 | if d.Config == nil { | |
| 54 | 54 | return ErrMissingDependency("Config") | |
| 55 | 55 | } | |
| 56 | - | if d.Logger == nil { | |
| 57 | - | return ErrMissingDependency("Logger") | |
| 58 | - | } | |
| 59 | 56 | // WebKit dependencies are optional for testing without WebViews | |
| 60 | 57 | // Use cases are optional - can be nil if not needed | |
| 61 | 58 | 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 configuration | 18 | // Core context and configuration | ||
| 20 | Ctx context.Context | 19 | Ctx context.Context | ||
| 21 | Config *config.Config | 20 | Config *config.Config | ||
| 22 | - | Logger *zerolog.Logger | 21 | + | |
| 22 | + | // Theme management | |||
| 23 | + | Theme *theme.Manager | |||
| 23 | 24 | ||||
| 24 | // WebKit infrastructure | 25 | // WebKit infrastructure | ||
| 25 | WebContext *webkit.WebKitContext | 26 | WebContext *webkit.WebKitContext | ||
| @@ -28,7 +29,6 @@ type Dependencies struct { | |||||
| 28 | Settings *webkit.SettingsManager | 29 | Settings *webkit.SettingsManager | ||
| 29 | Injector *webkit.ContentInjector | 30 | Injector *webkit.ContentInjector | ||
| 30 | MessageRouter *webkit.MessageRouter | 31 | MessageRouter *webkit.MessageRouter | ||
| 31 | - | Overlay *component.OverlayController | |||
| 32 | 32 | ||||
| 33 | // Repositories | 33 | // Repositories | ||
| 34 | HistoryRepo repository.HistoryRepository | 34 | 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 WebViews | 56 | // WebKit dependencies are optional for testing without WebViews | ||
| 60 | // Use cases are optional - can be nil if not needed | 57 | // Use cases are optional - can be nil if not needed | ||
| 61 | return nil | 58 | 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 ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 10 | 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 11 | 12 | "github.com/rs/zerolog" | |
| @@ -30,7 +31,7 @@ type KeyboardHandler struct { | |||
| 30 | 31 | controller *gtk.EventControllerKey | |
| 31 | 32 | ||
| 32 | 33 | ctx context.Context | |
| 33 | - | logger *zerolog.Logger | |
| 34 | + | logger zerolog.Logger | |
| 34 | 35 | mu sync.RWMutex | |
| 35 | 36 | } | |
| 36 | 37 | ||
| @@ -38,14 +39,15 @@ type KeyboardHandler struct { | |||
| 38 | 39 | func NewKeyboardHandler( | |
| 39 | 40 | ctx context.Context, | |
| 40 | 41 | cfg *config.WorkspaceConfig, | |
| 41 | - | logger *zerolog.Logger, | |
| 42 | 42 | ) *KeyboardHandler { | |
| 43 | + | log := logging.FromContext(ctx) | |
| 44 | + | ||
| 43 | 45 | h := &KeyboardHandler{ | |
| 44 | 46 | shortcuts: NewShortcutSet(ctx, cfg), | |
| 45 | 47 | modal: NewModalState(ctx), | |
| 46 | 48 | cfg: cfg, | |
| 47 | 49 | ctx: ctx, | |
| 48 | - | logger: logger, | |
| 50 | + | logger: log.With().Str("component", "keyboard-handler").Logger(), | |
| 49 | 51 | } | |
| 50 | 52 | ||
| 51 | 53 | 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.EventControllerKey | 31 | controller *gtk.EventControllerKey | ||
| 31 | 32 | ||||
| 32 | ctx context.Context | 33 | ctx context.Context | ||
| 33 | - | logger *zerolog.Logger | 34 | + | logger zerolog.Logger |
| 34 | mu sync.RWMutex | 35 | 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 h | 53 | return h | ||
M internal/ui/layout/split.go +6 -2
| @@ -1,8 +1,10 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "sync" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 6 | 8 | "github.com/rs/zerolog" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| @@ -25,7 +27,9 @@ const maxRetryFrames = 120 | |||
| 25 | 27 | ||
| 26 | 28 | // NewSplitView creates a new split view with the given orientation and children. | |
| 27 | 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 | 33 | paned := factory.NewPaned(orientation) | |
| 30 | 34 | ||
| 31 | 35 | // Configure resize behavior - both children should resize | |
| @@ -41,7 +45,7 @@ func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orie | |||
| 41 | 45 | startChild: startChild, | |
| 42 | 46 | endChild: endChild, | |
| 43 | 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 | 51 | // Set children | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package layout | 1 | 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 resize | 35 | // 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 children | 51 | // Set children | ||
M internal/ui/layout/tree.go +9 -3
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | 10 | "github.com/rs/zerolog" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -29,16 +31,20 @@ type TreeRenderer struct { | |||
| 29 | 31 | paneViewFactory PaneViewFactory | |
| 30 | 32 | logger zerolog.Logger | |
| 31 | 33 | nodeToWidget map[string]Widget | |
| 34 | + | ctx context.Context | |
| 32 | 35 | mu sync.RWMutex | |
| 33 | 36 | } | |
| 34 | 37 | ||
| 35 | 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 | 42 | return &TreeRenderer{ | |
| 38 | 43 | factory: factory, | |
| 39 | 44 | paneViewFactory: paneViewFactory, | |
| 40 | - | logger: logger.With().Str("component", "tree-renderer").Logger(), | |
| 45 | + | logger: log.With().Str("component", "tree-renderer").Logger(), | |
| 41 | 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 | 130 | // 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 | 133 | return splitView.Widget(), nil | |
| 128 | 134 | } | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | package layout | 1 | 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 PaneViewFactory | 31 | paneViewFactory PaneViewFactory | ||
| 30 | logger zerolog.Logger | 32 | logger zerolog.Logger | ||
| 31 | nodeToWidget map[string]Widget | 33 | nodeToWidget map[string]Widget | ||
| 34 | + | ctx context.Context | |||
| 32 | mu sync.RWMutex | 35 | 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 view | 130 | // 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(), nil | 133 | return splitView.Widget(), nil | ||
| 128 | } | 134 | } | ||
M internal/ui/theme/css.go +113 -72
| @@ -2,55 +2,80 @@ | |||
| 2 | 2 | package theme | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 6 | - | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 5 | + | "strings" | |
| 7 | 6 | ) | |
| 8 | 7 | ||
| 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 */ | |
| 13 | 35 | .tab-bar { | |
| 14 | - | background-color: #2b2b2b; | |
| 15 | - | border-top: 2px solid #353535; | |
| 36 | + | background-color: var(--surface); | |
| 37 | + | border-top: 2px solid var(--border); | |
| 16 | 38 | padding: 0; | |
| 17 | 39 | min-height: 32px; | |
| 18 | 40 | } | |
| 19 | 41 | ||
| 20 | - | /* Tab button styling - matches stacked pane titles */ | |
| 42 | + | /* Tab button styling */ | |
| 21 | 43 | button.tab-button { | |
| 22 | - | background-color: #404040; | |
| 44 | + | background-color: var(--surface-variant); | |
| 23 | 45 | background-image: none; | |
| 24 | 46 | border: none; | |
| 25 | - | border-right: 1px solid #353535; | |
| 47 | + | border-right: 1px solid var(--border); | |
| 26 | 48 | border-radius: 0; | |
| 27 | 49 | padding: 4px 8px; | |
| 28 | 50 | transition: background-color 200ms ease-in-out; | |
| 29 | 51 | } | |
| 30 | 52 | ||
| 31 | 53 | button.tab-button:hover { | |
| 32 | - | background-color: #505050; | |
| 54 | + | background-color: shade(var(--surface-variant), 1.2); | |
| 33 | 55 | } | |
| 34 | 56 | ||
| 35 | 57 | button.tab-button.tab-button-active { | |
| 36 | - | background-color: #707070; | |
| 58 | + | background-color: shade(var(--surface-variant), 1.4); | |
| 37 | 59 | font-weight: 600; | |
| 38 | 60 | } | |
| 39 | 61 | ||
| 40 | 62 | /* Tab title text */ | |
| 41 | 63 | .tab-title { | |
| 42 | 64 | font-size: 12px; | |
| 43 | - | color: #ffffff; | |
| 65 | + | color: var(--text); | |
| 44 | 66 | font-weight: 500; | |
| 45 | 67 | } | |
| 68 | + | ` | |
| 69 | + | } | |
| 46 | 70 | ||
| 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 ===== */ | |
| 49 | 74 | ||
| 50 | - | /* Omnibox window - floating popup with subtle depth */ | |
| 75 | + | /* Omnibox window - floating popup */ | |
| 51 | 76 | window.omnibox-window { | |
| 52 | - | background-color: #2d2d2d; | |
| 53 | - | border: 1px solid #454545; | |
| 77 | + | background-color: var(--surface-variant); | |
| 78 | + | border: 1px solid var(--border); | |
| 54 | 79 | border-radius: 3px; | |
| 55 | 80 | } | |
| 56 | 81 | ||
| @@ -60,10 +85,10 @@ window.omnibox-window { | |||
| 60 | 85 | background-color: transparent; | |
| 61 | 86 | } | |
| 62 | 87 | ||
| 63 | - | /* Header with History/Favorites toggle - slightly elevated */ | |
| 88 | + | /* Header with History/Favorites toggle */ | |
| 64 | 89 | .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); | |
| 67 | 92 | padding: 6px 12px; | |
| 68 | 93 | } | |
| 69 | 94 | ||
| @@ -76,42 +101,42 @@ window.omnibox-window { | |||
| 76 | 101 | margin-right: 8px; | |
| 77 | 102 | font-size: 13px; | |
| 78 | 103 | font-weight: 500; | |
| 79 | - | color: #909090; | |
| 104 | + | color: var(--muted); | |
| 80 | 105 | transition: all 100ms ease-in-out; | |
| 81 | 106 | } | |
| 82 | 107 | ||
| 83 | 108 | .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); | |
| 86 | 111 | } | |
| 87 | 112 | ||
| 88 | 113 | .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); | |
| 91 | 116 | font-weight: 600; | |
| 92 | 117 | } | |
| 93 | 118 | ||
| 94 | - | /* Search entry field - recessed appearance */ | |
| 119 | + | /* Search entry field */ | |
| 95 | 120 | .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); | |
| 99 | 124 | border-radius: 2px; | |
| 100 | 125 | padding: 10px 12px; | |
| 101 | 126 | margin: 8px 12px; | |
| 102 | 127 | font-size: 16px; | |
| 103 | - | caret-color: #4a90e2; | |
| 128 | + | caret-color: var(--accent); | |
| 104 | 129 | } | |
| 105 | 130 | ||
| 106 | 131 | .omnibox-entry:focus { | |
| 107 | - | border-color: #4a90e2; | |
| 108 | - | background-color: #1e1e1e; | |
| 132 | + | border-color: var(--accent); | |
| 133 | + | background-color: shade(var(--bg), 1.05); | |
| 109 | 134 | } | |
| 110 | 135 | ||
| 111 | 136 | /* Scrolled window for suggestions */ | |
| 112 | 137 | .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); | |
| 115 | 140 | } | |
| 116 | 141 | ||
| 117 | 142 | /* List box */ | |
| @@ -119,13 +144,13 @@ window.omnibox-window { | |||
| 119 | 144 | background-color: transparent; | |
| 120 | 145 | } | |
| 121 | 146 | ||
| 122 | - | /* Suggestion rows - with left accent border */ | |
| 147 | + | /* Suggestion rows */ | |
| 123 | 148 | .omnibox-row { | |
| 124 | 149 | padding: 8px 12px; | |
| 125 | 150 | margin: 0; | |
| 126 | 151 | border-radius: 0; | |
| 127 | 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 | 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 | 161 | .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); | |
| 139 | 164 | } | |
| 140 | 165 | ||
| 141 | 166 | .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); | |
| 144 | 169 | } | |
| 145 | 170 | ||
| 146 | 171 | /* Suggestion title/URL */ | |
| 147 | 172 | .omnibox-suggestion-title { | |
| 148 | 173 | font-size: 14px; | |
| 149 | - | color: #e0e0e0; | |
| 174 | + | color: var(--text, #ffffff); | |
| 150 | 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 | 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) */ | |
| 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; | |
| 161 | 196 | font-family: monospace; | |
| 197 | + | margin-left: 8px; | |
| 162 | 198 | } | |
| 163 | 199 | ||
| 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); | |
| 167 | 203 | } | |
| 168 | 204 | ||
| 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); | |
| 179 | 208 | } | |
| 180 | 209 | ` | |
| 210 | + | } | |
| 181 | 211 | ||
| 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 ===== */ | |
| 187 | 215 | ||
| 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 | + | } | |
| 192 | 220 | ||
| 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 | + | ` | |
| 195 | 236 | } | |
| @@ -2,55 +2,80 @@ | |||||
| 2 | package theme | 2 | 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 | - | return | 218 | + | 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 @@ | |||
| 2 | 2 | package window | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 6 | + | ||
| 5 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 6 | 9 | "github.com/bnema/dumber/internal/ui/component" | |
| 7 | 10 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 8 | 11 | "github.com/rs/zerolog" | |
| @@ -22,14 +25,16 @@ type MainWindow struct { | |||
| 22 | 25 | contentArea *gtk.Box // Container for workspace content | |
| 23 | 26 | ||
| 24 | 27 | cfg *config.Config | |
| 25 | - | logger *zerolog.Logger | |
| 28 | + | logger zerolog.Logger | |
| 26 | 29 | } | |
| 27 | 30 | ||
| 28 | 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 | 35 | mw := &MainWindow{ | |
| 31 | 36 | cfg: cfg, | |
| 32 | - | logger: logger, | |
| 37 | + | logger: log.With().Str("component", "main-window").Logger(), | |
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | // Create the application window | |
| @@ -2,7 +2,10 @@ | |||||
| 2 | package window | 2 | 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 content | 25 | contentArea *gtk.Box // Container for workspace content | ||
| 23 | 26 | ||||
| 24 | cfg *config.Config | 27 | cfg *config.Config | ||
| 25 | - | logger *zerolog.Logger | 28 | + | 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 window | 40 | // Create the application window | ||