detached ee9c157

fix(webui-theme): refresh injected scripts after appearance save

commit
ee9c157d497c1ba2b5ec51d92257c65dc7c806ff
author
brice · · 2025-12-20 18:47 UTC
parent
5bd0e17
signature
unsigned
7 files · +305 -44
M cmd/dumber/main.go +2 -2
@@ -213,8 +213,8 @@ func runGUI() {
213213
214214 // Prepare theme CSS for WebUI pages (dumb://*)
215215 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
216- themeCSSVars := themeManager.GetCurrentPalette().ToWebCSSVars()
217- if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSVars}); err != nil {
216+ themeCSSText := themeManager.GetWebUIThemeCSS()
217+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
218218 logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
219219 }
220220
@@ -213,8 +213,8 @@ func runGUI() {
213 213
214 // Prepare theme CSS for WebUI pages (dumb://*)214 // Prepare theme CSS for WebUI pages (dumb://*)
215 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)215 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
216- themeCSSVars := themeManager.GetCurrentPalette().ToWebCSSVars()216+ themeCSSText := themeManager.GetWebUIThemeCSS()
217- if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSVars}); err != nil {217+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
218 logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")218 logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
219 }219 }
220 220
M internal/infrastructure/webkit/injector.go +10 -8
@@ -16,12 +16,12 @@ const (
1616 MessageHandlerName = "dumber"
1717 )
1818
19-// themeCSSScript injects CSS custom properties into the page.
20-// The %s placeholder is replaced with the CSS variable declarations (newlines escaped as \n).
19+// themeCSSScript injects theme CSS into the page.
20+// The %s placeholder is replaced with CSS text (newlines escaped as \n).
2121 const themeCSSScript = `(function() {
2222 var style = document.createElement('style');
23- style.setAttribute('data-dumber-theme', '');
24- style.textContent = ':root {%s}';
23+ style.setAttribute('data-dumber-theme-vars', '');
24+ style.textContent = '%s';
2525 (document.head || document.documentElement).appendChild(style);
2626 })();`
2727
@@ -48,7 +48,7 @@ const darkModeScript = `(function() {
4848
4949 // Inject root color-scheme style
5050 const style = document.createElement('style');
51- style.setAttribute('data-dumber-theme', '');
51+ style.setAttribute('data-dumber-color-scheme', '');
5252 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
5353 document.documentElement.appendChild(style);
5454
@@ -184,10 +184,12 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
184184 "dark-mode-handler",
185185 )
186186
187- // 4. Inject theme CSS variables for internal pages (dumb://* only)
187+ // 4. Inject theme CSS for internal pages (dumb://* only)
188188 if ci.themeCSSVars != "" {
189- // Escape newlines for JS string literal
190- escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\n", "\\n")
189+ // Escape for JS string literal
190+ escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
191+ escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
192+ escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
191193 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
192194 addScript(
193195 webkit.NewUserScript(
@@ -16,12 +16,12 @@ const (
16 MessageHandlerName = "dumber"16 MessageHandlerName = "dumber"
17 )17 )
18 18
19-// themeCSSScript injects CSS custom properties into the page.19+// themeCSSScript injects theme CSS into the page.
20-// The %s placeholder is replaced with the CSS variable declarations (newlines escaped as \n).20+// The %s placeholder is replaced with CSS text (newlines escaped as \n).
21 const themeCSSScript = `(function() {21 const themeCSSScript = `(function() {
22 var style = document.createElement('style');22 var style = document.createElement('style');
23- style.setAttribute('data-dumber-theme', '');23+ style.setAttribute('data-dumber-theme-vars', '');
24- style.textContent = ':root {%s}';24+ style.textContent = '%s';
25 (document.head || document.documentElement).appendChild(style);25 (document.head || document.documentElement).appendChild(style);
26 })();`26 })();`
27 27
@@ -48,7 +48,7 @@ const darkModeScript = `(function() {
48 48
49 // Inject root color-scheme style49 // Inject root color-scheme style
50 const style = document.createElement('style');50 const style = document.createElement('style');
51- style.setAttribute('data-dumber-theme', '');51+ style.setAttribute('data-dumber-color-scheme', '');
52 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';52 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
53 document.documentElement.appendChild(style);53 document.documentElement.appendChild(style);
54 54
@@ -184,10 +184,12 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
184 "dark-mode-handler",184 "dark-mode-handler",
185 )185 )
186 186
187- // 4. Inject theme CSS variables for internal pages (dumb://* only)187+ // 4. Inject theme CSS for internal pages (dumb://* only)
188 if ci.themeCSSVars != "" {188 if ci.themeCSSVars != "" {
189- // Escape newlines for JS string literal189+ // Escape for JS string literal
190- escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\n", "\\n")190+ escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
191+ escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
192+ escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
191 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)193 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
192 addScript(194 addScript(
193 webkit.NewUserScript(195 webkit.NewUserScript(
M internal/ui/app.go +103 -10
@@ -7,14 +7,12 @@ import (
77 "os"
88 "sync"
99
10- "github.com/jwijenbergh/puregotk/v4/gio"
11- "github.com/jwijenbergh/puregotk/v4/glib"
12- "github.com/jwijenbergh/puregotk/v4/gtk"
13-
1410 "github.com/bnema/dumber/internal/application/usecase"
1511 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
1613 "github.com/bnema/dumber/internal/infrastructure/filtering"
1714 "github.com/bnema/dumber/internal/infrastructure/webkit"
15+
1816 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
1917 "github.com/bnema/dumber/internal/logging"
2018 "github.com/bnema/dumber/internal/ui/adapter"
@@ -25,6 +23,10 @@ import (
2523 "github.com/bnema/dumber/internal/ui/input"
2624 "github.com/bnema/dumber/internal/ui/layout"
2725 "github.com/bnema/dumber/internal/ui/window"
26+ "github.com/jwijenbergh/puregotk/v4/gdk"
27+ "github.com/jwijenbergh/puregotk/v4/gio"
28+ "github.com/jwijenbergh/puregotk/v4/glib"
29+ "github.com/jwijenbergh/puregotk/v4/gtk"
2830 )
2931
3032 const (
@@ -43,11 +45,12 @@ type App struct {
4345 tabsUC *usecase.ManageTabsUseCase
4446
4547 // Coordinators (new architecture)
46- contentCoord *coordinator.ContentCoordinator
47- tabCoord *coordinator.TabCoordinator
48- wsCoord *coordinator.WorkspaceCoordinator
49- navCoord *coordinator.NavigationCoordinator
50- kbDispatcher *dispatcher.KeyboardDispatcher
48+ contentCoord *coordinator.ContentCoordinator
49+ tabCoord *coordinator.TabCoordinator
50+ wsCoord *coordinator.WorkspaceCoordinator
51+ navCoord *coordinator.NavigationCoordinator
52+ kbDispatcher *dispatcher.KeyboardDispatcher
53+ configManager *config.Manager
5154
5255 // Pane management (used by coordinators)
5356 panesUC *usecase.ManagePanesUseCase
@@ -102,6 +105,7 @@ func New(deps *Dependencies) (*App, error) {
102105 injector: deps.Injector,
103106 router: deps.MessageRouter,
104107 settings: deps.Settings,
108+ configManager: config.GetManager(),
105109 cancel: cancel,
106110 }
107111 if app.router == nil {
@@ -109,7 +113,7 @@ func New(deps *Dependencies) (*App, error) {
109113 }
110114
111115 // Register message handlers
112- if app.router != nil && deps.HistoryUC != nil && deps.FavoritesUC != nil {
116+ if app.router != nil {
113117 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
114118 HistoryUC: deps.HistoryUC,
115119 FavoritesUC: deps.FavoritesUC,
@@ -254,6 +258,13 @@ func (a *App) onActivate(ctx context.Context) {
254258
255259 log.Info().Msg("main window displayed")
256260
261+ // Start watching config for appearance changes
262+ a.initConfigWatcher(ctx)
263+
264+ // Ensure the current appearance config is applied once at startup.
265+ // This avoids the WebUI showing default tokens until the first config change event.
266+ a.applyAppearanceConfig(ctx, a.deps.Config)
267+
257268 // Start async filter loading after window is visible
258269 a.initFilteringAsync(ctx)
259270 }
@@ -635,6 +646,88 @@ func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
635646 }
636647
637648 // initFilteringAsync starts background filter loading with toast feedback.
649+func (a *App) initConfigWatcher(ctx context.Context) {
650+ log := logging.FromContext(ctx)
651+
652+ if a.configManager == nil {
653+ log.Debug().Msg("no config manager available, skipping watcher")
654+ return
655+ }
656+
657+ // Start viper watcher
658+ if err := a.configManager.Watch(); err != nil {
659+ log.Warn().Err(err).Msg("failed to start config watcher")
660+ return
661+ }
662+
663+ // Only appearance is hot-reloaded for now.
664+ a.configManager.OnConfigChange(func(newCfg *config.Config) {
665+ cfgCopy := newCfg
666+ var cb glib.SourceFunc
667+ cb = func(_ uintptr) bool {
668+ a.applyAppearanceConfig(ctx, cfgCopy)
669+ return false
670+ }
671+ glib.IdleAdd(&cb, 0)
672+ })
673+
674+ log.Debug().Msg("config watcher initialized")
675+}
676+
677+func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
678+ log := logging.FromContext(ctx)
679+ if cfg == nil {
680+ return
681+ }
682+
683+ // Update the shared config pointer in-place so existing references see changes.
684+ if a.deps != nil && a.deps.Config != nil {
685+ *a.deps.Config = *cfg
686+ }
687+
688+ // Update WebKit settings manager
689+ if a.settings != nil {
690+ a.settings.UpdateFromConfig(ctx, cfg)
691+ }
692+
693+ // Apply settings to existing webviews via coordinator
694+ if a.contentCoord != nil {
695+ a.contentCoord.ApplySettingsToAll(ctx, a.settings)
696+ }
697+
698+ // Update GTK theme and injected WebUI theme vars
699+ if a.deps != nil && a.deps.Theme != nil {
700+ var display *gdk.Display
701+ if a.mainWindow != nil && a.mainWindow.Window() != nil {
702+ display = a.mainWindow.Window().GetDisplay()
703+ }
704+ a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
705+
706+ // Keep injector's dark-mode flag in sync for future navigations
707+ if a.injector != nil {
708+ a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
709+ }
710+
711+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
712+ cssText := a.deps.Theme.GetWebUIThemeCSS()
713+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
714+ log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
715+ }
716+
717+ // Refresh injected scripts so future navigations use the latest theme.
718+ if a.contentCoord != nil && a.injector != nil {
719+ a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
720+ }
721+
722+ // Apply WebUI theme to already-loaded dumb:// pages
723+ if a.contentCoord != nil {
724+ a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
725+ }
726+ }
727+
728+ log.Info().Msg("appearance config updated")
729+}
730+
638731 func (a *App) initFilteringAsync(ctx context.Context) {
639732 if a.deps.FilterManager == nil {
640733 return
@@ -7,14 +7,12 @@ import (
7 "os"7 "os"
8 "sync"8 "sync"
9 9
10- "github.com/jwijenbergh/puregotk/v4/gio"
11- "github.com/jwijenbergh/puregotk/v4/glib"
12- "github.com/jwijenbergh/puregotk/v4/gtk"
13-
14 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
16 "github.com/bnema/dumber/internal/infrastructure/filtering"13 "github.com/bnema/dumber/internal/infrastructure/filtering"
17 "github.com/bnema/dumber/internal/infrastructure/webkit"14 "github.com/bnema/dumber/internal/infrastructure/webkit"
15+
18 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"16 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
19 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
20 "github.com/bnema/dumber/internal/ui/adapter"18 "github.com/bnema/dumber/internal/ui/adapter"
@@ -25,6 +23,10 @@ import (
25 "github.com/bnema/dumber/internal/ui/input"23 "github.com/bnema/dumber/internal/ui/input"
26 "github.com/bnema/dumber/internal/ui/layout"24 "github.com/bnema/dumber/internal/ui/layout"
27 "github.com/bnema/dumber/internal/ui/window"25 "github.com/bnema/dumber/internal/ui/window"
26+ "github.com/jwijenbergh/puregotk/v4/gdk"
27+ "github.com/jwijenbergh/puregotk/v4/gio"
28+ "github.com/jwijenbergh/puregotk/v4/glib"
29+ "github.com/jwijenbergh/puregotk/v4/gtk"
28 )30 )
29 31
30 const (32 const (
@@ -43,11 +45,12 @@ type App struct {
43 tabsUC *usecase.ManageTabsUseCase45 tabsUC *usecase.ManageTabsUseCase
44 46
45 // Coordinators (new architecture)47 // Coordinators (new architecture)
46- contentCoord *coordinator.ContentCoordinator48+ contentCoord *coordinator.ContentCoordinator
47- tabCoord *coordinator.TabCoordinator49+ tabCoord *coordinator.TabCoordinator
48- wsCoord *coordinator.WorkspaceCoordinator50+ wsCoord *coordinator.WorkspaceCoordinator
49- navCoord *coordinator.NavigationCoordinator51+ navCoord *coordinator.NavigationCoordinator
50- kbDispatcher *dispatcher.KeyboardDispatcher52+ kbDispatcher *dispatcher.KeyboardDispatcher
53+ configManager *config.Manager
51 54
52 // Pane management (used by coordinators)55 // Pane management (used by coordinators)
53 panesUC *usecase.ManagePanesUseCase56 panesUC *usecase.ManagePanesUseCase
@@ -102,6 +105,7 @@ func New(deps *Dependencies) (*App, error) {
102 injector: deps.Injector,105 injector: deps.Injector,
103 router: deps.MessageRouter,106 router: deps.MessageRouter,
104 settings: deps.Settings,107 settings: deps.Settings,
108+ configManager: config.GetManager(),
105 cancel: cancel,109 cancel: cancel,
106 }110 }
107 if app.router == nil {111 if app.router == nil {
@@ -109,7 +113,7 @@ func New(deps *Dependencies) (*App, error) {
109 }113 }
110 114
111 // Register message handlers115 // Register message handlers
112- if app.router != nil && deps.HistoryUC != nil && deps.FavoritesUC != nil {116+ if app.router != nil {
113 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{117 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
114 HistoryUC: deps.HistoryUC,118 HistoryUC: deps.HistoryUC,
115 FavoritesUC: deps.FavoritesUC,119 FavoritesUC: deps.FavoritesUC,
@@ -254,6 +258,13 @@ func (a *App) onActivate(ctx context.Context) {
254 258
255 log.Info().Msg("main window displayed")259 log.Info().Msg("main window displayed")
256 260
261+ // Start watching config for appearance changes
262+ a.initConfigWatcher(ctx)
263+
264+ // Ensure the current appearance config is applied once at startup.
265+ // This avoids the WebUI showing default tokens until the first config change event.
266+ a.applyAppearanceConfig(ctx, a.deps.Config)
267+
257 // Start async filter loading after window is visible268 // Start async filter loading after window is visible
258 a.initFilteringAsync(ctx)269 a.initFilteringAsync(ctx)
259 }270 }
@@ -635,6 +646,88 @@ func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
635 }646 }
636 647
637 // initFilteringAsync starts background filter loading with toast feedback.648 // initFilteringAsync starts background filter loading with toast feedback.
649+func (a *App) initConfigWatcher(ctx context.Context) {
650+ log := logging.FromContext(ctx)
651+
652+ if a.configManager == nil {
653+ log.Debug().Msg("no config manager available, skipping watcher")
654+ return
655+ }
656+
657+ // Start viper watcher
658+ if err := a.configManager.Watch(); err != nil {
659+ log.Warn().Err(err).Msg("failed to start config watcher")
660+ return
661+ }
662+
663+ // Only appearance is hot-reloaded for now.
664+ a.configManager.OnConfigChange(func(newCfg *config.Config) {
665+ cfgCopy := newCfg
666+ var cb glib.SourceFunc
667+ cb = func(_ uintptr) bool {
668+ a.applyAppearanceConfig(ctx, cfgCopy)
669+ return false
670+ }
671+ glib.IdleAdd(&cb, 0)
672+ })
673+
674+ log.Debug().Msg("config watcher initialized")
675+}
676+
677+func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
678+ log := logging.FromContext(ctx)
679+ if cfg == nil {
680+ return
681+ }
682+
683+ // Update the shared config pointer in-place so existing references see changes.
684+ if a.deps != nil && a.deps.Config != nil {
685+ *a.deps.Config = *cfg
686+ }
687+
688+ // Update WebKit settings manager
689+ if a.settings != nil {
690+ a.settings.UpdateFromConfig(ctx, cfg)
691+ }
692+
693+ // Apply settings to existing webviews via coordinator
694+ if a.contentCoord != nil {
695+ a.contentCoord.ApplySettingsToAll(ctx, a.settings)
696+ }
697+
698+ // Update GTK theme and injected WebUI theme vars
699+ if a.deps != nil && a.deps.Theme != nil {
700+ var display *gdk.Display
701+ if a.mainWindow != nil && a.mainWindow.Window() != nil {
702+ display = a.mainWindow.Window().GetDisplay()
703+ }
704+ a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
705+
706+ // Keep injector's dark-mode flag in sync for future navigations
707+ if a.injector != nil {
708+ a.injector.SetPrefersDark(a.deps.Theme.PrefersDark())
709+ }
710+
711+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
712+ cssText := a.deps.Theme.GetWebUIThemeCSS()
713+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
714+ log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
715+ }
716+
717+ // Refresh injected scripts so future navigations use the latest theme.
718+ if a.contentCoord != nil && a.injector != nil {
719+ a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
720+ }
721+
722+ // Apply WebUI theme to already-loaded dumb:// pages
723+ if a.contentCoord != nil {
724+ a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
725+ }
726+ }
727+
728+ log.Info().Msg("appearance config updated")
729+}
730+
638 func (a *App) initFilteringAsync(ctx context.Context) {731 func (a *App) initFilteringAsync(ctx context.Context) {
639 if a.deps.FilterManager == nil {732 if a.deps.FilterManager == nil {
640 return733 return
M internal/ui/coordinator/content.go +120 -0
@@ -2,7 +2,9 @@ package coordinator
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
7+ "strings"
68 "sync"
79 "time"
810
@@ -310,6 +312,124 @@ func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webk
310312 }
311313 }
312314
315+// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
316+func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
317+ log := logging.FromContext(ctx)
318+ if sm == nil {
319+ return
320+ }
321+
322+ for paneID, wv := range c.webViews {
323+ if wv == nil || wv.IsDestroyed() {
324+ continue
325+ }
326+ sm.ApplyToWebView(ctx, wv.Widget())
327+ log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
328+ }
329+}
330+
331+// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
332+//
333+// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
334+// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
335+// the scripts so future navigations pick up the latest values.
336+func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
337+ log := logging.FromContext(ctx)
338+ if injector == nil {
339+ return
340+ }
341+
342+ for paneID, wv := range c.webViews {
343+ if wv == nil || wv.IsDestroyed() {
344+ continue
345+ }
346+ ucm := wv.UserContentManager()
347+ if ucm == nil {
348+ continue
349+ }
350+
351+ ucm.RemoveAllScripts()
352+ injector.InjectScripts(ctx, ucm, wv.ID())
353+ log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
354+ }
355+}
356+
357+// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
358+// This is necessary because user scripts only run on navigation.
359+func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
360+ log := logging.FromContext(ctx)
361+ if cssText == "" {
362+ return
363+ }
364+
365+ // Safely embed CSS in JS
366+ cssJSON, err := json.Marshal(cssText)
367+ if err != nil {
368+ log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
369+ return
370+ }
371+
372+ script := fmt.Sprintf(`(function(){
373+ try {
374+ var cssText = %s;
375+ var prefersDark = %t;
376+
377+ // Keep the global flag in sync
378+ window.__dumber_gtk_prefers_dark = prefersDark;
379+
380+ // Update dark/light class
381+ if (prefersDark) {
382+ document.documentElement.classList.add('dark');
383+ document.documentElement.classList.remove('light');
384+ } else {
385+ document.documentElement.classList.add('light');
386+ document.documentElement.classList.remove('dark');
387+ }
388+
389+ // Update or insert theme style
390+ var style = document.querySelector('style[data-dumber-theme-vars]');
391+ if (!style) {
392+ style = document.createElement('style');
393+ style.setAttribute('data-dumber-theme-vars', '');
394+ (document.head || document.documentElement).appendChild(style);
395+ }
396+ style.textContent = cssText;
397+
398+ // Notify any running WebUI that theme changed
399+ try {
400+ window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
401+ detail: { prefersDark: prefersDark }
402+ }));
403+ } catch (e) {
404+ // ignore
405+ }
406+
407+ // Keep color-scheme consistent
408+ var meta = document.querySelector('meta[name="color-scheme"]');
409+ if (!meta) {
410+ meta = document.createElement('meta');
411+ meta.name = 'color-scheme';
412+ document.documentElement.appendChild(meta);
413+ }
414+ meta.content = prefersDark ? 'dark light' : 'light dark';
415+ } catch (e) {
416+ console.error('[dumber] failed to apply theme', e);
417+ }
418+})();`, string(cssJSON), prefersDark)
419+
420+ for paneID, wv := range c.webViews {
421+ if wv == nil || wv.IsDestroyed() {
422+ continue
423+ }
424+ uri := wv.URI()
425+ if !strings.HasPrefix(uri, "dumb://") {
426+ continue
427+ }
428+ wv.RunJavaScript(ctx, script, "")
429+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
430+ }
431+}
432+
313433 // GetTitle returns the current title for a pane.
314434 func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
315435 c.titleMu.RLock()
@@ -2,7 +2,9 @@ package coordinator
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "fmt"6 "fmt"
7+ "strings"
6 "sync"8 "sync"
7 "time"9 "time"
8 10
@@ -310,6 +312,124 @@ func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webk
310 }312 }
311 }313 }
312 314
315+// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
316+func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
317+ log := logging.FromContext(ctx)
318+ if sm == nil {
319+ return
320+ }
321+
322+ for paneID, wv := range c.webViews {
323+ if wv == nil || wv.IsDestroyed() {
324+ continue
325+ }
326+ sm.ApplyToWebView(ctx, wv.Widget())
327+ log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
328+ }
329+}
330+
331+// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
332+//
333+// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
334+// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
335+// the scripts so future navigations pick up the latest values.
336+func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
337+ log := logging.FromContext(ctx)
338+ if injector == nil {
339+ return
340+ }
341+
342+ for paneID, wv := range c.webViews {
343+ if wv == nil || wv.IsDestroyed() {
344+ continue
345+ }
346+ ucm := wv.UserContentManager()
347+ if ucm == nil {
348+ continue
349+ }
350+
351+ ucm.RemoveAllScripts()
352+ injector.InjectScripts(ctx, ucm, wv.ID())
353+ log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
354+ }
355+}
356+
357+// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
358+// This is necessary because user scripts only run on navigation.
359+func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
360+ log := logging.FromContext(ctx)
361+ if cssText == "" {
362+ return
363+ }
364+
365+ // Safely embed CSS in JS
366+ cssJSON, err := json.Marshal(cssText)
367+ if err != nil {
368+ log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
369+ return
370+ }
371+
372+ script := fmt.Sprintf(`(function(){
373+ try {
374+ var cssText = %s;
375+ var prefersDark = %t;
376+
377+ // Keep the global flag in sync
378+ window.__dumber_gtk_prefers_dark = prefersDark;
379+
380+ // Update dark/light class
381+ if (prefersDark) {
382+ document.documentElement.classList.add('dark');
383+ document.documentElement.classList.remove('light');
384+ } else {
385+ document.documentElement.classList.add('light');
386+ document.documentElement.classList.remove('dark');
387+ }
388+
389+ // Update or insert theme style
390+ var style = document.querySelector('style[data-dumber-theme-vars]');
391+ if (!style) {
392+ style = document.createElement('style');
393+ style.setAttribute('data-dumber-theme-vars', '');
394+ (document.head || document.documentElement).appendChild(style);
395+ }
396+ style.textContent = cssText;
397+
398+ // Notify any running WebUI that theme changed
399+ try {
400+ window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
401+ detail: { prefersDark: prefersDark }
402+ }));
403+ } catch (e) {
404+ // ignore
405+ }
406+
407+ // Keep color-scheme consistent
408+ var meta = document.querySelector('meta[name="color-scheme"]');
409+ if (!meta) {
410+ meta = document.createElement('meta');
411+ meta.name = 'color-scheme';
412+ document.documentElement.appendChild(meta);
413+ }
414+ meta.content = prefersDark ? 'dark light' : 'light dark';
415+ } catch (e) {
416+ console.error('[dumber] failed to apply theme', e);
417+ }
418+})();`, string(cssJSON), prefersDark)
419+
420+ for paneID, wv := range c.webViews {
421+ if wv == nil || wv.IsDestroyed() {
422+ continue
423+ }
424+ uri := wv.URI()
425+ if !strings.HasPrefix(uri, "dumb://") {
426+ continue
427+ }
428+ wv.RunJavaScript(ctx, script, "")
429+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
430+ }
431+}
432+
313 // GetTitle returns the current title for a pane.433 // GetTitle returns the current title for a pane.
314 func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {434 func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
315 c.titleMu.RLock()435 c.titleMu.RLock()
M internal/ui/theme/css.go +10 -10
@@ -237,13 +237,13 @@ entry.omnibox-entry > *:focus-visible {
237237 /* Suggestion title/URL */
238238 .omnibox-suggestion-title {
239239 font-size: 0.875em;
240- color: var(--text, #ffffff);
240+ color: var(--text);
241241 font-weight: 400;
242242 }
243243
244244 /* Also style labels inside omnibox rows directly */
245245 .omnibox-row label {
246- color: var(--text, #ffffff);
246+ color: var(--text);
247247 }
248248
249249 .omnibox-row:selected .omnibox-suggestion-title {
@@ -264,8 +264,8 @@ entry.omnibox-entry > *:focus-visible {
264264
265265 /* Keyboard shortcut badge */
266266 .omnibox-shortcut-badge {
267- background-color: alpha(var(--muted, #909090), 0.3);
268- color: var(--muted, #909090);
267+ background-color: alpha(var(--muted), 0.3);
268+ color: var(--muted);
269269 border-radius: 0.25em;
270270 padding: 0.125em 0.375em;
271271 font-size: 0.625em;
@@ -275,19 +275,19 @@ entry.omnibox-entry > *:focus-visible {
275275 }
276276
277277 .omnibox-row:hover .omnibox-shortcut-badge {
278- background-color: alpha(var(--accent, #4ade80), 0.2);
279- color: var(--accent, #4ade80);
278+ background-color: alpha(var(--accent), 0.2);
279+ color: var(--accent);
280280 }
281281
282282 .omnibox-row:selected .omnibox-shortcut-badge {
283- background-color: alpha(var(--accent, #4ade80), 0.3);
284- color: var(--accent, #4ade80);
283+ background-color: alpha(var(--accent), 0.3);
284+ color: var(--accent);
285285 }
286286
287287 /* Zoom indicator in omnibox header */
288288 .omnibox-zoom-indicator {
289- background-color: alpha(var(--accent, #4ade80), 0.2);
290- color: var(--accent, #4ade80);
289+ background-color: alpha(var(--accent), 0.2);
290+ color: var(--accent);
291291 border-radius: 0.25em;
292292 padding: 0.125em 0.5em;
293293 font-size: 0.6875em;
@@ -237,13 +237,13 @@ entry.omnibox-entry > *:focus-visible {
237 /* Suggestion title/URL */237 /* Suggestion title/URL */
238 .omnibox-suggestion-title {238 .omnibox-suggestion-title {
239 font-size: 0.875em;239 font-size: 0.875em;
240- color: var(--text, #ffffff);240+ color: var(--text);
241 font-weight: 400;241 font-weight: 400;
242 }242 }
243 243
244 /* Also style labels inside omnibox rows directly */244 /* Also style labels inside omnibox rows directly */
245 .omnibox-row label {245 .omnibox-row label {
246- color: var(--text, #ffffff);246+ color: var(--text);
247 }247 }
248 248
249 .omnibox-row:selected .omnibox-suggestion-title {249 .omnibox-row:selected .omnibox-suggestion-title {
@@ -264,8 +264,8 @@ entry.omnibox-entry > *:focus-visible {
264 264
265 /* Keyboard shortcut badge */265 /* Keyboard shortcut badge */
266 .omnibox-shortcut-badge {266 .omnibox-shortcut-badge {
267- background-color: alpha(var(--muted, #909090), 0.3);267+ background-color: alpha(var(--muted), 0.3);
268- color: var(--muted, #909090);268+ color: var(--muted);
269 border-radius: 0.25em;269 border-radius: 0.25em;
270 padding: 0.125em 0.375em;270 padding: 0.125em 0.375em;
271 font-size: 0.625em;271 font-size: 0.625em;
@@ -275,19 +275,19 @@ entry.omnibox-entry > *:focus-visible {
275 }275 }
276 276
277 .omnibox-row:hover .omnibox-shortcut-badge {277 .omnibox-row:hover .omnibox-shortcut-badge {
278- background-color: alpha(var(--accent, #4ade80), 0.2);278+ background-color: alpha(var(--accent), 0.2);
279- color: var(--accent, #4ade80);279+ color: var(--accent);
280 }280 }
281 281
282 .omnibox-row:selected .omnibox-shortcut-badge {282 .omnibox-row:selected .omnibox-shortcut-badge {
283- background-color: alpha(var(--accent, #4ade80), 0.3);283+ background-color: alpha(var(--accent), 0.3);
284- color: var(--accent, #4ade80);284+ color: var(--accent);
285 }285 }
286 286
287 /* Zoom indicator in omnibox header */287 /* Zoom indicator in omnibox header */
288 .omnibox-zoom-indicator {288 .omnibox-zoom-indicator {
289- background-color: alpha(var(--accent, #4ade80), 0.2);289+ background-color: alpha(var(--accent), 0.2);
290- color: var(--accent, #4ade80);290+ color: var(--accent);
291 border-radius: 0.25em;291 border-radius: 0.25em;
292 padding: 0.125em 0.5em;292 padding: 0.125em 0.5em;
293 font-size: 0.6875em;293 font-size: 0.6875em;
M internal/ui/theme/manager.go +46 -0
@@ -2,6 +2,7 @@ package theme
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/dumber/internal/infrastructure/config"
78 "github.com/bnema/dumber/internal/logging"
@@ -86,6 +87,14 @@ func (m *Manager) GetDarkPalette() Palette {
8687 return m.darkPalette
8788 }
8889
90+// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
91+// WebUI uses `:root` for light and `.dark` overrides for dark.
92+func (m *Manager) GetWebUIThemeCSS() string {
93+ lightVars := m.lightPalette.ToWebCSSVars()
94+ darkVars := m.darkPalette.ToWebCSSVars()
95+ return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
96+}
97+
8998 // ApplyToDisplay loads the theme CSS into the display.
9099 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
91100 log := logging.FromContext(ctx)
@@ -139,3 +148,40 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
139148 m.ApplyToDisplay(ctx, display)
140149 }
141150 }
151+
152+// UpdateFromConfig updates the theme manager state from a new config.
153+func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {
154+ log := logging.FromContext(ctx)
155+
156+ if cfg == nil {
157+ return
158+ }
159+
160+ // Update scheme and resolve prefersDark
161+ scheme := "system"
162+ if cfg.Appearance.ColorScheme != "" {
163+ scheme = cfg.Appearance.ColorScheme
164+ }
165+ m.scheme = scheme
166+ m.prefersDark = ResolveColorScheme(scheme)
167+
168+ // Update palettes
169+ m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
170+ m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
171+
172+ // Update UI scale
173+ if cfg.DefaultUIScale > 0 {
174+ m.uiScale = cfg.DefaultUIScale
175+ }
176+
177+ log.Info().
178+ Str("scheme", m.scheme).
179+ Bool("prefers_dark", m.prefersDark).
180+ Float64("ui_scale", m.uiScale).
181+ Msg("theme manager updated from config")
182+
183+ // Re-apply CSS if display is available
184+ if display != nil {
185+ m.ApplyToDisplay(ctx, display)
186+ }
187+}
@@ -2,6 +2,7 @@ package theme
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 6
6 "github.com/bnema/dumber/internal/infrastructure/config"7 "github.com/bnema/dumber/internal/infrastructure/config"
7 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
@@ -86,6 +87,14 @@ func (m *Manager) GetDarkPalette() Palette {
86 return m.darkPalette87 return m.darkPalette
87 }88 }
88 89
90+// GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
91+// WebUI uses `:root` for light and `.dark` overrides for dark.
92+func (m *Manager) GetWebUIThemeCSS() string {
93+ lightVars := m.lightPalette.ToWebCSSVars()
94+ darkVars := m.darkPalette.ToWebCSSVars()
95+ return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
96+}
97+
89 // ApplyToDisplay loads the theme CSS into the display.98 // ApplyToDisplay loads the theme CSS into the display.
90 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {99 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
91 log := logging.FromContext(ctx)100 log := logging.FromContext(ctx)
@@ -139,3 +148,40 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
139 m.ApplyToDisplay(ctx, display)148 m.ApplyToDisplay(ctx, display)
140 }149 }
141 }150 }
151+
152+// UpdateFromConfig updates the theme manager state from a new config.
153+func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {
154+ log := logging.FromContext(ctx)
155+
156+ if cfg == nil {
157+ return
158+ }
159+
160+ // Update scheme and resolve prefersDark
161+ scheme := "system"
162+ if cfg.Appearance.ColorScheme != "" {
163+ scheme = cfg.Appearance.ColorScheme
164+ }
165+ m.scheme = scheme
166+ m.prefersDark = ResolveColorScheme(scheme)
167+
168+ // Update palettes
169+ m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
170+ m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
171+
172+ // Update UI scale
173+ if cfg.DefaultUIScale > 0 {
174+ m.uiScale = cfg.DefaultUIScale
175+ }
176+
177+ log.Info().
178+ Str("scheme", m.scheme).
179+ Bool("prefers_dark", m.prefersDark).
180+ Float64("ui_scale", m.uiScale).
181+ Msg("theme manager updated from config")
182+
183+ // Re-apply CSS if display is available
184+ if display != nil {
185+ m.ApplyToDisplay(ctx, display)
186+ }
187+}
M internal/ui/theme/palette.go +14 -14
@@ -132,20 +132,20 @@ func (p Palette) ToCSSVars() string {
132132 func (p Palette) ToWebCSSVars() string {
133133 var sb strings.Builder
134134 // Tailwind-compatible variables
135- sb.WriteString(" --background: " + p.Background + ";\n")
136- sb.WriteString(" --foreground: " + p.Text + ";\n")
137- sb.WriteString(" --card: " + p.Surface + ";\n")
138- sb.WriteString(" --card-foreground: " + p.Text + ";\n")
139- sb.WriteString(" --primary: " + p.Accent + ";\n")
140- sb.WriteString(" --primary-foreground: " + p.Background + ";\n")
141- sb.WriteString(" --muted: " + p.SurfaceVariant + ";\n")
142- sb.WriteString(" --muted-foreground: " + p.Muted + ";\n")
143- sb.WriteString(" --border: " + p.Border + ";\n")
135+ sb.WriteString(" --background: " + p.Background + " !important;\n")
136+ sb.WriteString(" --foreground: " + p.Text + " !important;\n")
137+ sb.WriteString(" --card: " + p.Surface + " !important;\n")
138+ sb.WriteString(" --card-foreground: " + p.Text + " !important;\n")
139+ sb.WriteString(" --primary: " + p.Accent + " !important;\n")
140+ sb.WriteString(" --primary-foreground: " + p.Background + " !important;\n")
141+ sb.WriteString(" --muted: " + p.SurfaceVariant + " !important;\n")
142+ sb.WriteString(" --muted-foreground: " + p.Muted + " !important;\n")
143+ sb.WriteString(" --border: " + p.Border + " !important;\n")
144144 // Keep GTK names for compatibility
145- sb.WriteString(" --bg: " + p.Background + ";\n")
146- sb.WriteString(" --surface: " + p.Surface + ";\n")
147- sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
148- sb.WriteString(" --text: " + p.Text + ";\n")
149- sb.WriteString(" --accent: " + p.Accent + ";\n")
145+ sb.WriteString(" --bg: " + p.Background + " !important;\n")
146+ sb.WriteString(" --surface: " + p.Surface + " !important;\n")
147+ sb.WriteString(" --surface-variant: " + p.SurfaceVariant + " !important;\n")
148+ sb.WriteString(" --text: " + p.Text + " !important;\n")
149+ sb.WriteString(" --accent: " + p.Accent + " !important;\n")
150150 return sb.String()
151151 }
@@ -132,20 +132,20 @@ func (p Palette) ToCSSVars() string {
132 func (p Palette) ToWebCSSVars() string {132 func (p Palette) ToWebCSSVars() string {
133 var sb strings.Builder133 var sb strings.Builder
134 // Tailwind-compatible variables134 // Tailwind-compatible variables
135- sb.WriteString(" --background: " + p.Background + ";\n")135+ sb.WriteString(" --background: " + p.Background + " !important;\n")
136- sb.WriteString(" --foreground: " + p.Text + ";\n")136+ sb.WriteString(" --foreground: " + p.Text + " !important;\n")
137- sb.WriteString(" --card: " + p.Surface + ";\n")137+ sb.WriteString(" --card: " + p.Surface + " !important;\n")
138- sb.WriteString(" --card-foreground: " + p.Text + ";\n")138+ sb.WriteString(" --card-foreground: " + p.Text + " !important;\n")
139- sb.WriteString(" --primary: " + p.Accent + ";\n")139+ sb.WriteString(" --primary: " + p.Accent + " !important;\n")
140- sb.WriteString(" --primary-foreground: " + p.Background + ";\n")140+ sb.WriteString(" --primary-foreground: " + p.Background + " !important;\n")
141- sb.WriteString(" --muted: " + p.SurfaceVariant + ";\n")141+ sb.WriteString(" --muted: " + p.SurfaceVariant + " !important;\n")
142- sb.WriteString(" --muted-foreground: " + p.Muted + ";\n")142+ sb.WriteString(" --muted-foreground: " + p.Muted + " !important;\n")
143- sb.WriteString(" --border: " + p.Border + ";\n")143+ sb.WriteString(" --border: " + p.Border + " !important;\n")
144 // Keep GTK names for compatibility144 // Keep GTK names for compatibility
145- sb.WriteString(" --bg: " + p.Background + ";\n")145+ sb.WriteString(" --bg: " + p.Background + " !important;\n")
146- sb.WriteString(" --surface: " + p.Surface + ";\n")146+ sb.WriteString(" --surface: " + p.Surface + " !important;\n")
147- sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")147+ sb.WriteString(" --surface-variant: " + p.SurfaceVariant + " !important;\n")
148- sb.WriteString(" --text: " + p.Text + ";\n")148+ sb.WriteString(" --text: " + p.Text + " !important;\n")
149- sb.WriteString(" --accent: " + p.Accent + ";\n")149+ sb.WriteString(" --accent: " + p.Accent + " !important;\n")
150 return sb.String()150 return sb.String()
151 }151 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close