Merge pull request #54 from bnema/ui/refinements
fix: eliminate dark mode navigation flash
12 files · +379 -50
M CHANGELOG.md +1 -0
| @@ -39,6 +39,7 @@ All notable changes to this project will be documented in this file. | |||
| 39 | 39 | ### Fixed | |
| 40 | 40 | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | |
| 41 | 41 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | |
| 42 | + | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | |
| 42 | 43 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | |
| 43 | 44 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | |
| 44 | 45 | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | |
| @@ -39,6 +39,7 @@ All notable changes to this project will be documented in this file. | |||||
| 39 | ### Fixed | 39 | ### Fixed | ||
| 40 | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | 40 | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | ||
| 41 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | 41 | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | ||
| 42 | + | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | |||
| 42 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | 43 | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | ||
| 43 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | 44 | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | ||
| 44 | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | 45 | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | ||
M internal/bootstrap/webkit_stack.go +6 -0
| @@ -95,6 +95,9 @@ func BuildWebKitStack( | |||
| 95 | 95 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 96 | 96 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | |
| 97 | 97 | ||
| 98 | + | // Set background color on injector for early CSS injection (prevents white flash) | |
| 99 | + | injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background) | |
| 100 | + | ||
| 98 | 101 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | |
| 99 | 102 | themeCSSText := themeManager.GetWebUIThemeCSS() | |
| 100 | 103 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | |
| @@ -108,6 +111,9 @@ func BuildWebKitStack( | |||
| 108 | 111 | poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount | |
| 109 | 112 | } | |
| 110 | 113 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | |
| 114 | + | // Ensure prewarmed WebViews pick up the theme background color. | |
| 115 | + | bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA() | |
| 116 | + | pool.SetBackgroundColor(bgR, bgG, bgB, bgA) | |
| 111 | 117 | ||
| 112 | 118 | if filterManager != nil { | |
| 113 | 119 | pool.SetFilterApplier(filterManager) | |
| @@ -95,6 +95,9 @@ func BuildWebKitStack( | |||||
| 95 | settings := webkit.NewSettingsManager(ctx, cfg) | 95 | settings := webkit.NewSettingsManager(ctx, cfg) | ||
| 96 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | 96 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | ||
| 97 | 97 | ||||
| 98 | + | // Set background color on injector for early CSS injection (prevents white flash) | |||
| 99 | + | injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background) | |||
| 100 | + | ||||
| 98 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | 101 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | ||
| 99 | themeCSSText := themeManager.GetWebUIThemeCSS() | 102 | themeCSSText := themeManager.GetWebUIThemeCSS() | ||
| 100 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | 103 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | ||
| @@ -108,6 +111,9 @@ func BuildWebKitStack( | |||||
| 108 | poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount | 111 | poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount | ||
| 109 | } | 112 | } | ||
| 110 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | 113 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | ||
| 114 | + | // Ensure prewarmed WebViews pick up the theme background color. | |||
| 115 | + | bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA() | |||
| 116 | + | pool.SetBackgroundColor(bgR, bgG, bgB, bgA) | |||
| 111 | 117 | ||||
| 112 | if filterManager != nil { | 118 | if filterManager != nil { | ||
| 113 | pool.SetFilterApplier(filterManager) | 119 | pool.SetFilterApplier(filterManager) | ||
A internal/infrastructure/webkit/bgcolor.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // bgColor holds RGBA components for WebView background color (eliminates white flash). | |
| 10 | + | // It is thread-safe and can be embedded in pool/factory structs. | |
| 11 | + | type bgColor struct { | |
| 12 | + | r, g, b, a float32 | |
| 13 | + | mu sync.RWMutex | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // set stores the background color components. | |
| 17 | + | func (c *bgColor) set(r, g, b, a float32) { | |
| 18 | + | c.mu.Lock() | |
| 19 | + | c.r, c.g, c.b, c.a = r, g, b, a | |
| 20 | + | c.mu.Unlock() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // get returns the background color components. | |
| 24 | + | func (c *bgColor) get() (r, g, b, a float32) { | |
| 25 | + | c.mu.RLock() | |
| 26 | + | r, g, b, a = c.r, c.g, c.b, c.a | |
| 27 | + | c.mu.RUnlock() | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // toGdkRGBA returns a *gdk.RGBA if a valid color is configured (alpha > 0), nil otherwise. | |
| 32 | + | func (c *bgColor) toGdkRGBA() *gdk.RGBA { | |
| 33 | + | r, g, b, a := c.get() | |
| 34 | + | if a <= 0 { | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | return &gdk.RGBA{Red: r, Green: g, Blue: b, Alpha: a} | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // bgColor holds RGBA components for WebView background color (eliminates white flash). | |||
| 10 | + | // It is thread-safe and can be embedded in pool/factory structs. | |||
| 11 | + | type bgColor struct { | |||
| 12 | + | r, g, b, a float32 | |||
| 13 | + | mu sync.RWMutex | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // set stores the background color components. | |||
| 17 | + | func (c *bgColor) set(r, g, b, a float32) { | |||
| 18 | + | c.mu.Lock() | |||
| 19 | + | c.r, c.g, c.b, c.a = r, g, b, a | |||
| 20 | + | c.mu.Unlock() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // get returns the background color components. | |||
| 24 | + | func (c *bgColor) get() (r, g, b, a float32) { | |||
| 25 | + | c.mu.RLock() | |||
| 26 | + | r, g, b, a = c.r, c.g, c.b, c.a | |||
| 27 | + | c.mu.RUnlock() | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // toGdkRGBA returns a *gdk.RGBA if a valid color is configured (alpha > 0), nil otherwise. | |||
| 32 | + | func (c *bgColor) toGdkRGBA() *gdk.RGBA { | |||
| 33 | + | r, g, b, a := c.get() | |||
| 34 | + | if a <= 0 { | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | return &gdk.RGBA{Red: r, Green: g, Blue: b, Alpha: a} | |||
| 38 | + | } | |||
M internal/infrastructure/webkit/factory.go +6 -18
| @@ -3,7 +3,6 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "sync" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| @@ -21,8 +20,7 @@ type WebViewFactory struct { | |||
| 21 | 20 | filterApplier FilterApplier // Optional content filter applier | |
| 22 | 21 | ||
| 23 | 22 | // Background color for WebViews (eliminates white flash) | |
| 24 | - | bgR, bgG, bgB, bgA float32 | |
| 25 | - | bgMu sync.RWMutex | |
| 23 | + | bg bgColor | |
| 26 | 24 | } | |
| 27 | 25 | ||
| 28 | 26 | // NewWebViewFactory creates a new WebViewFactory. | |
| @@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) { | |||
| 56 | 54 | // SetBackgroundColor sets the background color for newly created WebViews. | |
| 57 | 55 | // This color is shown before content is painted, eliminating white flash. | |
| 58 | 56 | func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | |
| 59 | - | f.bgMu.Lock() | |
| 60 | - | f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a | |
| 61 | - | f.bgMu.Unlock() | |
| 57 | + | f.bg.set(r, g, b, a) | |
| 62 | 58 | // Also propagate to the pool if present | |
| 63 | 59 | if f.pool != nil { | |
| 64 | 60 | f.pool.SetBackgroundColor(r, g, b, a) | |
| @@ -106,10 +102,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 106 | 102 | } | |
| 107 | 103 | ||
| 108 | 104 | // Set background color to match theme (eliminates white flash) | |
| 109 | - | f.bgMu.RLock() | |
| 110 | - | r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA | |
| 111 | - | f.bgMu.RUnlock() | |
| 112 | - | if a > 0 { | |
| 105 | + | if r, g, b, a := f.bg.get(); a > 0 { | |
| 113 | 106 | wv.SetBackgroundColor(r, g, b, a) | |
| 114 | 107 | } | |
| 115 | 108 | ||
| @@ -138,18 +131,13 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 138 | 131 | func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |
| 139 | 132 | log := logging.FromContext(ctx) | |
| 140 | 133 | ||
| 141 | - | wv, err := NewWebView(ctx, f.wkCtx, f.settings) | |
| 134 | + | wv, err := NewWebView(ctx, f.wkCtx, f.settings, f.bg.toGdkRGBA()) | |
| 142 | 135 | if err != nil { | |
| 143 | 136 | return nil, err | |
| 144 | 137 | } | |
| 145 | 138 | ||
| 146 | - | // Set background color to match theme (eliminates white flash) | |
| 147 | - | f.bgMu.RLock() | |
| 148 | - | r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA | |
| 149 | - | f.bgMu.RUnlock() | |
| 150 | - | if a > 0 { | |
| 151 | - | wv.SetBackgroundColor(r, g, b, a) | |
| 152 | - | } | |
| 139 | + | // Add CSS class for theme background styling (prevents white flash) | |
| 140 | + | wv.inner.AddCssClass("webview-themed") | |
| 153 | 141 | ||
| 154 | 142 | // Keep hidden until content is painted | |
| 155 | 143 | wv.inner.SetVisible(false) | |
| @@ -3,7 +3,6 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "sync" | |||
| 7 | 6 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -21,8 +20,7 @@ type WebViewFactory struct { | |||||
| 21 | filterApplier FilterApplier // Optional content filter applier | 20 | filterApplier FilterApplier // Optional content filter applier | ||
| 22 | 21 | ||||
| 23 | // Background color for WebViews (eliminates white flash) | 22 | // Background color for WebViews (eliminates white flash) | ||
| 24 | - | bgR, bgG, bgB, bgA float32 | 23 | + | bg bgColor |
| 25 | - | bgMu sync.RWMutex | |||
| 26 | } | 24 | } | ||
| 27 | 25 | ||||
| 28 | // NewWebViewFactory creates a new WebViewFactory. | 26 | // NewWebViewFactory creates a new WebViewFactory. | ||
| @@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) { | |||||
| 56 | // SetBackgroundColor sets the background color for newly created WebViews. | 54 | // SetBackgroundColor sets the background color for newly created WebViews. | ||
| 57 | // This color is shown before content is painted, eliminating white flash. | 55 | // This color is shown before content is painted, eliminating white flash. | ||
| 58 | func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | 56 | func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | ||
| 59 | - | f.bgMu.Lock() | 57 | + | f.bg.set(r, g, b, a) |
| 60 | - | f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a | |||
| 61 | - | f.bgMu.Unlock() | |||
| 62 | // Also propagate to the pool if present | 58 | // Also propagate to the pool if present | ||
| 63 | if f.pool != nil { | 59 | if f.pool != nil { | ||
| 64 | f.pool.SetBackgroundColor(r, g, b, a) | 60 | f.pool.SetBackgroundColor(r, g, b, a) | ||
| @@ -106,10 +102,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 106 | } | 102 | } | ||
| 107 | 103 | ||||
| 108 | // Set background color to match theme (eliminates white flash) | 104 | // Set background color to match theme (eliminates white flash) | ||
| 109 | - | f.bgMu.RLock() | 105 | + | if r, g, b, a := f.bg.get(); a > 0 { |
| 110 | - | r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA | |||
| 111 | - | f.bgMu.RUnlock() | |||
| 112 | - | if a > 0 { | |||
| 113 | wv.SetBackgroundColor(r, g, b, a) | 106 | wv.SetBackgroundColor(r, g, b, a) | ||
| 114 | } | 107 | } | ||
| 115 | 108 | ||||
| @@ -138,18 +131,13 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 138 | func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | 131 | func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | ||
| 139 | log := logging.FromContext(ctx) | 132 | log := logging.FromContext(ctx) | ||
| 140 | 133 | ||||
| 141 | - | wv, err := NewWebView(ctx, f.wkCtx, f.settings) | 134 | + | wv, err := NewWebView(ctx, f.wkCtx, f.settings, f.bg.toGdkRGBA()) |
| 142 | if err != nil { | 135 | if err != nil { | ||
| 143 | return nil, err | 136 | return nil, err | ||
| 144 | } | 137 | } | ||
| 145 | 138 | ||||
| 146 | - | // Set background color to match theme (eliminates white flash) | 139 | + | // Add CSS class for theme background styling (prevents white flash) |
| 147 | - | f.bgMu.RLock() | 140 | + | wv.inner.AddCssClass("webview-themed") |
| 148 | - | r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA | |||
| 149 | - | f.bgMu.RUnlock() | |||
| 150 | - | if a > 0 { | |||
| 151 | - | wv.SetBackgroundColor(r, g, b, a) | |||
| 152 | - | } | |||
| 153 | 141 | ||||
| 154 | // Keep hidden until content is painted | 142 | // Keep hidden until content is painted | ||
| 155 | wv.inner.SetVisible(false) | 143 | wv.inner.SetVisible(false) | ||
M internal/infrastructure/webkit/injector.go +56 -5
| @@ -25,6 +25,34 @@ const themeCSSScript = `(function() { | |||
| 25 | 25 | (document.head || document.documentElement).appendChild(style); | |
| 26 | 26 | })();` | |
| 27 | 27 | ||
| 28 | + | // earlyBackgroundScript injects a temporary background color to prevent white flash. | |
| 29 | + | // It's injected at document start and removed after the page loads. | |
| 30 | + | // The %s placeholder is replaced with the hex color (e.g., "#0a0a0b"). | |
| 31 | + | const earlyBackgroundScript = `(function() { | |
| 32 | + | var style = document.createElement('style'); | |
| 33 | + | style.id = '__dumber_early_bg'; | |
| 34 | + | style.textContent = 'html,body{background-color:%s!important}'; | |
| 35 | + | document.documentElement.appendChild(style); | |
| 36 | + | ||
| 37 | + | // Remove the style after page content loads to avoid overriding page styles | |
| 38 | + | function removeEarlyBg() { | |
| 39 | + | var s = document.getElementById('__dumber_early_bg'); | |
| 40 | + | if (s) s.remove(); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Try multiple events to ensure removal | |
| 44 | + | if (document.readyState === 'complete') { | |
| 45 | + | setTimeout(removeEarlyBg, 100); | |
| 46 | + | } else { | |
| 47 | + | window.addEventListener('DOMContentLoaded', function() { | |
| 48 | + | setTimeout(removeEarlyBg, 100); | |
| 49 | + | }, {once: true}); | |
| 50 | + | window.addEventListener('load', function() { | |
| 51 | + | setTimeout(removeEarlyBg, 100); | |
| 52 | + | }, {once: true}); | |
| 53 | + | } | |
| 54 | + | })();` | |
| 55 | + | ||
| 28 | 56 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | |
| 29 | 57 | // It must be injected at document start, after __dumber_gtk_prefers_dark is set. | |
| 30 | 58 | // This script: | |
| @@ -132,6 +160,7 @@ type ContentInjector struct { | |||
| 132 | 160 | prefersDark bool | |
| 133 | 161 | themeCSSVars string // CSS custom property declarations for WebUI | |
| 134 | 162 | findCSS string // CSS for find-in-page highlight styling | |
| 163 | + | bgColor string // Background color hex for early injection (prevents white flash) | |
| 135 | 164 | } | |
| 136 | 165 | ||
| 137 | 166 | // NewContentInjector creates a new injector instance. | |
| @@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) { | |||
| 171 | 200 | ci.prefersDark = prefersDark | |
| 172 | 201 | } | |
| 173 | 202 | ||
| 203 | + | // SetBackgroundColor sets the background color for early CSS injection. | |
| 204 | + | // This color is applied to html/body at document start to prevent white flash. | |
| 205 | + | // The hex color should include the # prefix (e.g., "#0a0a0b"). | |
| 206 | + | func (ci *ContentInjector) SetBackgroundColor(hex string) { | |
| 207 | + | ci.bgColor = hex | |
| 208 | + | } | |
| 209 | + | ||
| 174 | 210 | // InjectScripts adds the minimal dark mode detection scripts to the given content manager. | |
| 175 | 211 | // Only injects: | |
| 176 | 212 | // - window.__dumber_gtk_prefers_dark flag | |
| @@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 193 | 229 | log.Debug().Str("script", label).Msg("injected user script") | |
| 194 | 230 | } | |
| 195 | 231 | ||
| 196 | - | // 1. Inject GTK dark mode preference (must be before dark mode handler) | |
| 232 | + | // 1. Inject early background color to prevent white flash (must be first!) | |
| 233 | + | if ci.bgColor != "" { | |
| 234 | + | earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor) | |
| 235 | + | addScript( | |
| 236 | + | webkit.NewUserScript( | |
| 237 | + | earlyBgScript, | |
| 238 | + | webkit.UserContentInjectTopFrameValue, | |
| 239 | + | webkit.UserScriptInjectAtDocumentStartValue, | |
| 240 | + | nil, | |
| 241 | + | nil, | |
| 242 | + | ), | |
| 243 | + | "early-background", | |
| 244 | + | ) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // 2. Inject GTK dark mode preference (must be before dark mode handler) | |
| 197 | 248 | darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | |
| 198 | 249 | addScript( | |
| 199 | 250 | webkit.NewUserScript( | |
| @@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 206 | 257 | "gtk-dark-mode-pref", | |
| 207 | 258 | ) | |
| 208 | 259 | ||
| 209 | - | // 2. Inject WebView ID for debugging | |
| 260 | + | // 3. Inject WebView ID for debugging | |
| 210 | 261 | if webviewID != 0 { | |
| 211 | 262 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 212 | 263 | addScript( | |
| @@ -221,7 +272,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 221 | 272 | ) | |
| 222 | 273 | } | |
| 223 | 274 | ||
| 224 | - | // 3. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark) | |
| 275 | + | // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark) | |
| 225 | 276 | addScript( | |
| 226 | 277 | webkit.NewUserScript( | |
| 227 | 278 | darkModeScript, | |
| @@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 233 | 284 | "dark-mode-handler", | |
| 234 | 285 | ) | |
| 235 | 286 | ||
| 236 | - | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 287 | + | // 5. Inject theme CSS for internal pages (dumb://* only) | |
| 237 | 288 | if ci.themeCSSVars != "" { | |
| 238 | 289 | // Escape for JS string literal | |
| 239 | 290 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | |
| @@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 253 | 304 | log.Debug().Msg("theme CSS vars injection configured") | |
| 254 | 305 | } | |
| 255 | 306 | ||
| 256 | - | // 5. Inject find highlight CSS for all pages | |
| 307 | + | // 6. Inject find highlight CSS for all pages | |
| 257 | 308 | if ci.findCSS != "" { | |
| 258 | 309 | stylesheet := webkit.NewUserStyleSheet( | |
| 259 | 310 | ci.findCSS, | |
| @@ -25,6 +25,34 @@ const themeCSSScript = `(function() { | |||||
| 25 | (document.head || document.documentElement).appendChild(style); | 25 | (document.head || document.documentElement).appendChild(style); | ||
| 26 | })();` | 26 | })();` | ||
| 27 | 27 | ||||
| 28 | + | // earlyBackgroundScript injects a temporary background color to prevent white flash. | |||
| 29 | + | // It's injected at document start and removed after the page loads. | |||
| 30 | + | // The %s placeholder is replaced with the hex color (e.g., "#0a0a0b"). | |||
| 31 | + | const earlyBackgroundScript = `(function() { | |||
| 32 | + | var style = document.createElement('style'); | |||
| 33 | + | style.id = '__dumber_early_bg'; | |||
| 34 | + | style.textContent = 'html,body{background-color:%s!important}'; | |||
| 35 | + | document.documentElement.appendChild(style); | |||
| 36 | + | ||||
| 37 | + | // Remove the style after page content loads to avoid overriding page styles | |||
| 38 | + | function removeEarlyBg() { | |||
| 39 | + | var s = document.getElementById('__dumber_early_bg'); | |||
| 40 | + | if (s) s.remove(); | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // Try multiple events to ensure removal | |||
| 44 | + | if (document.readyState === 'complete') { | |||
| 45 | + | setTimeout(removeEarlyBg, 100); | |||
| 46 | + | } else { | |||
| 47 | + | window.addEventListener('DOMContentLoaded', function() { | |||
| 48 | + | setTimeout(removeEarlyBg, 100); | |||
| 49 | + | }, {once: true}); | |||
| 50 | + | window.addEventListener('load', function() { | |||
| 51 | + | setTimeout(removeEarlyBg, 100); | |||
| 52 | + | }, {once: true}); | |||
| 53 | + | } | |||
| 54 | + | })();` | |||
| 55 | + | ||||
| 28 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | 56 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | ||
| 29 | // It must be injected at document start, after __dumber_gtk_prefers_dark is set. | 57 | // It must be injected at document start, after __dumber_gtk_prefers_dark is set. | ||
| 30 | // This script: | 58 | // This script: | ||
| @@ -132,6 +160,7 @@ type ContentInjector struct { | |||||
| 132 | prefersDark bool | 160 | prefersDark bool | ||
| 133 | themeCSSVars string // CSS custom property declarations for WebUI | 161 | themeCSSVars string // CSS custom property declarations for WebUI | ||
| 134 | findCSS string // CSS for find-in-page highlight styling | 162 | findCSS string // CSS for find-in-page highlight styling | ||
| 163 | + | bgColor string // Background color hex for early injection (prevents white flash) | |||
| 135 | } | 164 | } | ||
| 136 | 165 | ||||
| 137 | // NewContentInjector creates a new injector instance. | 166 | // NewContentInjector creates a new injector instance. | ||
| @@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) { | |||||
| 171 | ci.prefersDark = prefersDark | 200 | ci.prefersDark = prefersDark | ||
| 172 | } | 201 | } | ||
| 173 | 202 | ||||
| 203 | + | // SetBackgroundColor sets the background color for early CSS injection. | |||
| 204 | + | // This color is applied to html/body at document start to prevent white flash. | |||
| 205 | + | // The hex color should include the # prefix (e.g., "#0a0a0b"). | |||
| 206 | + | func (ci *ContentInjector) SetBackgroundColor(hex string) { | |||
| 207 | + | ci.bgColor = hex | |||
| 208 | + | } | |||
| 209 | + | ||||
| 174 | // InjectScripts adds the minimal dark mode detection scripts to the given content manager. | 210 | // InjectScripts adds the minimal dark mode detection scripts to the given content manager. | ||
| 175 | // Only injects: | 211 | // Only injects: | ||
| 176 | // - window.__dumber_gtk_prefers_dark flag | 212 | // - window.__dumber_gtk_prefers_dark flag | ||
| @@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 193 | log.Debug().Str("script", label).Msg("injected user script") | 229 | log.Debug().Str("script", label).Msg("injected user script") | ||
| 194 | } | 230 | } | ||
| 195 | 231 | ||||
| 196 | - | // 1. Inject GTK dark mode preference (must be before dark mode handler) | 232 | + | // 1. Inject early background color to prevent white flash (must be first!) |
| 233 | + | if ci.bgColor != "" { | |||
| 234 | + | earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor) | |||
| 235 | + | addScript( | |||
| 236 | + | webkit.NewUserScript( | |||
| 237 | + | earlyBgScript, | |||
| 238 | + | webkit.UserContentInjectTopFrameValue, | |||
| 239 | + | webkit.UserScriptInjectAtDocumentStartValue, | |||
| 240 | + | nil, | |||
| 241 | + | nil, | |||
| 242 | + | ), | |||
| 243 | + | "early-background", | |||
| 244 | + | ) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // 2. Inject GTK dark mode preference (must be before dark mode handler) | |||
| 197 | darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | 248 | darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | ||
| 198 | addScript( | 249 | addScript( | ||
| 199 | webkit.NewUserScript( | 250 | webkit.NewUserScript( | ||
| @@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 206 | "gtk-dark-mode-pref", | 257 | "gtk-dark-mode-pref", | ||
| 207 | ) | 258 | ) | ||
| 208 | 259 | ||||
| 209 | - | // 2. Inject WebView ID for debugging | 260 | + | // 3. Inject WebView ID for debugging |
| 210 | if webviewID != 0 { | 261 | if webviewID != 0 { | ||
| 211 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | 262 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | ||
| 212 | addScript( | 263 | addScript( | ||
| @@ -221,7 +272,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 221 | ) | 272 | ) | ||
| 222 | } | 273 | } | ||
| 223 | 274 | ||||
| 224 | - | // 3. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark) | 275 | + | // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark) |
| 225 | addScript( | 276 | addScript( | ||
| 226 | webkit.NewUserScript( | 277 | webkit.NewUserScript( | ||
| 227 | darkModeScript, | 278 | darkModeScript, | ||
| @@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 233 | "dark-mode-handler", | 284 | "dark-mode-handler", | ||
| 234 | ) | 285 | ) | ||
| 235 | 286 | ||||
| 236 | - | // 4. Inject theme CSS for internal pages (dumb://* only) | 287 | + | // 5. Inject theme CSS for internal pages (dumb://* only) |
| 237 | if ci.themeCSSVars != "" { | 288 | if ci.themeCSSVars != "" { | ||
| 238 | // Escape for JS string literal | 289 | // Escape for JS string literal | ||
| 239 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | 290 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | ||
| @@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 253 | log.Debug().Msg("theme CSS vars injection configured") | 304 | log.Debug().Msg("theme CSS vars injection configured") | ||
| 254 | } | 305 | } | ||
| 255 | 306 | ||||
| 256 | - | // 5. Inject find highlight CSS for all pages | 307 | + | // 6. Inject find highlight CSS for all pages |
| 257 | if ci.findCSS != "" { | 308 | if ci.findCSS != "" { | ||
| 258 | stylesheet := webkit.NewUserStyleSheet( | 309 | stylesheet := webkit.NewUserStyleSheet( | ||
| 259 | ci.findCSS, | 310 | ci.findCSS, | ||
M internal/infrastructure/webkit/pool.go +12 -13
| @@ -55,8 +55,7 @@ type WebViewPool struct { | |||
| 55 | 55 | filterApplier FilterApplier // Optional content filter applier | |
| 56 | 56 | ||
| 57 | 57 | // Background color for WebViews (eliminates white flash) | |
| 58 | - | bgR, bgG, bgB, bgA float32 | |
| 59 | - | bgMu sync.RWMutex | |
| 58 | + | bg bgColor | |
| 60 | 59 | ||
| 61 | 60 | closed atomic.Bool | |
| 62 | 61 | wg sync.WaitGroup | |
| @@ -108,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||
| 108 | 107 | // SetBackgroundColor sets the background color for newly created WebViews. | |
| 109 | 108 | // This color is shown before content is painted, eliminating white flash. | |
| 110 | 109 | func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |
| 111 | - | p.bgMu.Lock() | |
| 112 | - | p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a | |
| 113 | - | p.bgMu.Unlock() | |
| 110 | + | p.bg.set(r, g, b, a) | |
| 114 | 111 | } | |
| 115 | 112 | ||
| 116 | 113 | // Acquire gets a WebView from the pool or creates a new one. | |
| @@ -125,6 +122,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 125 | 122 | select { | |
| 126 | 123 | case wv := <-p.pool: | |
| 127 | 124 | if wv != nil && !wv.IsDestroyed() { | |
| 125 | + | if r, g, b, a := p.bg.get(); a > 0 { | |
| 126 | + | wv.SetBackgroundColor(r, g, b, a) | |
| 127 | + | } | |
| 128 | + | wv.inner.AddCssClass("webview-themed") | |
| 129 | + | // Keep pooled WebViews hidden until we explicitly reveal them. | |
| 130 | + | wv.inner.SetVisible(false) | |
| 131 | + | ||
| 128 | 132 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | |
| 129 | 133 | _ = wv.AttachFrontend(ctx, p.injector, p.router) | |
| 130 | 134 | // Apply filters if available (may not have been applied during prewarm) | |
| @@ -147,18 +151,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 147 | 151 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |
| 148 | 152 | log := logging.FromContext(ctx) | |
| 149 | 153 | ||
| 150 | - | wv, err := NewWebView(ctx, p.wkCtx, p.settings) | |
| 154 | + | wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA()) | |
| 151 | 155 | if err != nil { | |
| 152 | 156 | return nil, err | |
| 153 | 157 | } | |
| 154 | 158 | ||
| 155 | - | // Set background color to match theme (eliminates white flash) | |
| 156 | - | p.bgMu.RLock() | |
| 157 | - | r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA | |
| 158 | - | p.bgMu.RUnlock() | |
| 159 | - | if a > 0 { // Only set if a valid color was configured | |
| 160 | - | wv.SetBackgroundColor(r, g, b, a) | |
| 161 | - | } | |
| 159 | + | // Add CSS class for theme background styling (prevents white flash) | |
| 160 | + | wv.inner.AddCssClass("webview-themed") | |
| 162 | 161 | ||
| 163 | 162 | // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) | |
| 164 | 163 | wv.inner.SetVisible(false) | |
| @@ -55,8 +55,7 @@ type WebViewPool struct { | |||||
| 55 | filterApplier FilterApplier // Optional content filter applier | 55 | filterApplier FilterApplier // Optional content filter applier | ||
| 56 | 56 | ||||
| 57 | // Background color for WebViews (eliminates white flash) | 57 | // Background color for WebViews (eliminates white flash) | ||
| 58 | - | bgR, bgG, bgB, bgA float32 | 58 | + | bg bgColor |
| 59 | - | bgMu sync.RWMutex | |||
| 60 | 59 | ||||
| 61 | closed atomic.Bool | 60 | closed atomic.Bool | ||
| 62 | wg sync.WaitGroup | 61 | wg sync.WaitGroup | ||
| @@ -108,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||||
| 108 | // SetBackgroundColor sets the background color for newly created WebViews. | 107 | // SetBackgroundColor sets the background color for newly created WebViews. | ||
| 109 | // This color is shown before content is painted, eliminating white flash. | 108 | // This color is shown before content is painted, eliminating white flash. | ||
| 110 | func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | 109 | func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | ||
| 111 | - | p.bgMu.Lock() | 110 | + | p.bg.set(r, g, b, a) |
| 112 | - | p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a | |||
| 113 | - | p.bgMu.Unlock() | |||
| 114 | } | 111 | } | ||
| 115 | 112 | ||||
| 116 | // Acquire gets a WebView from the pool or creates a new one. | 113 | // Acquire gets a WebView from the pool or creates a new one. | ||
| @@ -125,6 +122,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 125 | select { | 122 | select { | ||
| 126 | case wv := <-p.pool: | 123 | case wv := <-p.pool: | ||
| 127 | if wv != nil && !wv.IsDestroyed() { | 124 | if wv != nil && !wv.IsDestroyed() { | ||
| 125 | + | if r, g, b, a := p.bg.get(); a > 0 { | |||
| 126 | + | wv.SetBackgroundColor(r, g, b, a) | |||
| 127 | + | } | |||
| 128 | + | wv.inner.AddCssClass("webview-themed") | |||
| 129 | + | // Keep pooled WebViews hidden until we explicitly reveal them. | |||
| 130 | + | wv.inner.SetVisible(false) | |||
| 131 | + | ||||
| 128 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | 132 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | ||
| 129 | _ = wv.AttachFrontend(ctx, p.injector, p.router) | 133 | _ = wv.AttachFrontend(ctx, p.injector, p.router) | ||
| 130 | // Apply filters if available (may not have been applied during prewarm) | 134 | // Apply filters if available (may not have been applied during prewarm) | ||
| @@ -147,18 +151,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 147 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | 151 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | ||
| 148 | log := logging.FromContext(ctx) | 152 | log := logging.FromContext(ctx) | ||
| 149 | 153 | ||||
| 150 | - | wv, err := NewWebView(ctx, p.wkCtx, p.settings) | 154 | + | wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA()) |
| 151 | if err != nil { | 155 | if err != nil { | ||
| 152 | return nil, err | 156 | return nil, err | ||
| 153 | } | 157 | } | ||
| 154 | 158 | ||||
| 155 | - | // Set background color to match theme (eliminates white flash) | 159 | + | // Add CSS class for theme background styling (prevents white flash) |
| 156 | - | p.bgMu.RLock() | 160 | + | wv.inner.AddCssClass("webview-themed") |
| 157 | - | r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA | |||
| 158 | - | p.bgMu.RUnlock() | |||
| 159 | - | if a > 0 { // Only set if a valid color was configured | |||
| 160 | - | wv.SetBackgroundColor(r, g, b, a) | |||
| 161 | - | } | |||
| 162 | 161 | ||||
| 163 | // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) | 162 | // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) | ||
| 164 | wv.inner.SetVisible(false) | 163 | wv.inner.SetVisible(false) | ||
M internal/infrastructure/webkit/webview.go +8 -1
| @@ -240,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) { | |||
| 240 | 240 | ||
| 241 | 241 | // NewWebView creates a new WebView with the given context and settings. | |
| 242 | 242 | // Uses the persistent NetworkSession from wkCtx for cookie/data persistence. | |
| 243 | - | func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) { | |
| 243 | + | // bgColor is optional - if provided, sets background immediately to prevent white flash. | |
| 244 | + | func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) { | |
| 244 | 245 | log := logging.FromContext(ctx) | |
| 245 | 246 | ||
| 246 | 247 | if wkCtx == nil || !wkCtx.IsInitialized() { | |
| @@ -253,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||
| 253 | 254 | return nil, fmt.Errorf("failed to create webkit webview with network session") | |
| 254 | 255 | } | |
| 255 | 256 | ||
| 257 | + | // Set background color IMMEDIATELY after creation, before any other operations. | |
| 258 | + | // This prevents white flash by ensuring WebKit's renderer starts with correct color. | |
| 259 | + | if bgColor != nil { | |
| 260 | + | inner.SetBackgroundColor(bgColor) | |
| 261 | + | } | |
| 262 | + | ||
| 256 | 263 | wv := &WebView{ | |
| 257 | 264 | inner: inner, | |
| 258 | 265 | ucm: inner.GetUserContentManager(), | |
| @@ -240,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) { | |||||
| 240 | 240 | ||||
| 241 | // NewWebView creates a new WebView with the given context and settings. | 241 | // NewWebView creates a new WebView with the given context and settings. | ||
| 242 | // Uses the persistent NetworkSession from wkCtx for cookie/data persistence. | 242 | // Uses the persistent NetworkSession from wkCtx for cookie/data persistence. | ||
| 243 | - | func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) { | 243 | + | // bgColor is optional - if provided, sets background immediately to prevent white flash. |
| 244 | + | func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) { | |||
| 244 | log := logging.FromContext(ctx) | 245 | log := logging.FromContext(ctx) | ||
| 245 | 246 | ||||
| 246 | if wkCtx == nil || !wkCtx.IsInitialized() { | 247 | if wkCtx == nil || !wkCtx.IsInitialized() { | ||
| @@ -253,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||||
| 253 | return nil, fmt.Errorf("failed to create webkit webview with network session") | 254 | return nil, fmt.Errorf("failed to create webkit webview with network session") | ||
| 254 | } | 255 | } | ||
| 255 | 256 | ||||
| 257 | + | // Set background color IMMEDIATELY after creation, before any other operations. | |||
| 258 | + | // This prevents white flash by ensuring WebKit's renderer starts with correct color. | |||
| 259 | + | if bgColor != nil { | |||
| 260 | + | inner.SetBackgroundColor(bgColor) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 256 | wv := &WebView{ | 263 | wv := &WebView{ | ||
| 257 | inner: inner, | 264 | inner: inner, | ||
| 258 | ucm: inner.GetUserContentManager(), | 265 | ucm: inner.GetUserContentManager(), | ||
M internal/ui/component/pane_view.go +7 -1
| @@ -148,15 +148,21 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||
| 148 | 148 | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | |
| 149 | 149 | // already painted, so the loading skeleton should be hidden immediately. | |
| 150 | 150 | hadParent := widget.GetParent() != nil | |
| 151 | + | wasVisible := false | |
| 152 | + | if hadParent { | |
| 153 | + | wasVisible = widget.IsVisible() | |
| 154 | + | } | |
| 151 | 155 | ||
| 152 | 156 | // Unparent widget from any previous parent (critical for rebuild scenarios) | |
| 153 | 157 | // In GTK4, a widget can only have one parent at a time | |
| 154 | 158 | if hadParent { | |
| 155 | 159 | widget.Unparent() | |
| 156 | 160 | } | |
| 157 | - | widget.SetVisible(true) | |
| 158 | 161 | pv.overlay.SetChild(widget) | |
| 159 | 162 | ||
| 163 | + | if hadParent && wasVisible { | |
| 164 | + | widget.SetVisible(true) | |
| 165 | + | } | |
| 160 | 166 | if hadParent && pv.loading != nil { | |
| 161 | 167 | pv.loading.SetVisible(false) | |
| 162 | 168 | } | |
| @@ -148,15 +148,21 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||||
| 148 | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | 148 | // (e.g. after rebuilding/moving panes). In that case the WebView has likely | ||
| 149 | // already painted, so the loading skeleton should be hidden immediately. | 149 | // already painted, so the loading skeleton should be hidden immediately. | ||
| 150 | hadParent := widget.GetParent() != nil | 150 | hadParent := widget.GetParent() != nil | ||
| 151 | + | wasVisible := false | |||
| 152 | + | if hadParent { | |||
| 153 | + | wasVisible = widget.IsVisible() | |||
| 154 | + | } | |||
| 151 | 155 | ||||
| 152 | // Unparent widget from any previous parent (critical for rebuild scenarios) | 156 | // Unparent widget from any previous parent (critical for rebuild scenarios) | ||
| 153 | // In GTK4, a widget can only have one parent at a time | 157 | // In GTK4, a widget can only have one parent at a time | ||
| 154 | if hadParent { | 158 | if hadParent { | ||
| 155 | widget.Unparent() | 159 | widget.Unparent() | ||
| 156 | } | 160 | } | ||
| 157 | - | widget.SetVisible(true) | |||
| 158 | pv.overlay.SetChild(widget) | 161 | pv.overlay.SetChild(widget) | ||
| 159 | 162 | ||||
| 163 | + | if hadParent && wasVisible { | |||
| 164 | + | widget.SetVisible(true) | |||
| 165 | + | } | |||
| 160 | if hadParent && pv.loading != nil { | 166 | if hadParent && pv.loading != nil { | ||
| 161 | pv.loading.SetVisible(false) | 167 | pv.loading.SetVisible(false) | ||
| 162 | } | 168 | } | ||
M internal/ui/component/pane_view_test.go +0 -2
| @@ -211,7 +211,6 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) { | |||
| 211 | 211 | // Expect removal of old widget and addition of new | |
| 212 | 212 | mockOverlay.EXPECT().SetChild(nil).Once() | |
| 213 | 213 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| 214 | - | mockNewWebView.EXPECT().SetVisible(true).Once() | |
| 215 | 214 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | |
| 216 | 215 | ||
| 217 | 216 | // Act | |
| @@ -234,7 +233,6 @@ func TestSetWebViewWidget_FromNil(t *testing.T) { | |||
| 234 | 233 | ||
| 235 | 234 | // Expect only setting new child (no removal since old was nil) | |
| 236 | 235 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| 237 | - | mockNewWebView.EXPECT().SetVisible(true).Once() | |
| 238 | 236 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | |
| 239 | 237 | ||
| 240 | 238 | // Act | |
| @@ -211,7 +211,6 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) { | |||||
| 211 | // Expect removal of old widget and addition of new | 211 | // Expect removal of old widget and addition of new | ||
| 212 | mockOverlay.EXPECT().SetChild(nil).Once() | 212 | mockOverlay.EXPECT().SetChild(nil).Once() | ||
| 213 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | 213 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | ||
| 214 | - | mockNewWebView.EXPECT().SetVisible(true).Once() | |||
| 215 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | 214 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | ||
| 216 | 215 | ||||
| 217 | // Act | 216 | // Act | ||
| @@ -234,7 +233,6 @@ func TestSetWebViewWidget_FromNil(t *testing.T) { | |||||
| 234 | 233 | ||||
| 235 | // Expect only setting new child (no removal since old was nil) | 234 | // Expect only setting new child (no removal since old was nil) | ||
| 236 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | 235 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | ||
| 237 | - | mockNewWebView.EXPECT().SetVisible(true).Once() | |||
| 238 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | 236 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | ||
| 239 | 237 | ||||
| 240 | // Act | 238 | // Act | ||
M internal/ui/coordinator/content.go +57 -6
| @@ -55,6 +55,9 @@ type ContentCoordinator struct { | |||
| 55 | 55 | // Callback when the WebView becomes visible (first real commit) | |
| 56 | 56 | onWebViewShown func(paneID entity.PaneID) | |
| 57 | 57 | ||
| 58 | + | revealMu sync.Mutex | |
| 59 | + | pendingReveal map[entity.PaneID]bool | |
| 60 | + | ||
| 58 | 61 | // Gesture action handler for mouse button navigation | |
| 59 | 62 | gestureActionHandler input.ActionHandler | |
| 60 | 63 | ||
| @@ -100,6 +103,7 @@ func NewContentCoordinator( | |||
| 100 | 103 | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 101 | 104 | paneTitles: make(map[entity.PaneID]string), | |
| 102 | 105 | navOrigins: make(map[entity.PaneID]string), | |
| 106 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 103 | 107 | getActiveWS: getActiveWS, | |
| 104 | 108 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 105 | 109 | } | |
| @@ -721,15 +725,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 721 | 725 | // Avoid updating UI/domain state to about:blank when we know the pane is | |
| 722 | 726 | // navigating to a different URL. This prevents the omnibox/window title from | |
| 723 | 727 | // briefly showing about:blank on cold start. | |
| 728 | + | c.clearPendingReveal(paneID) | |
| 724 | 729 | return | |
| 725 | 730 | } | |
| 726 | 731 | ||
| 727 | - | if inner := wv.Widget(); inner != nil { | |
| 728 | - | inner.SetVisible(true) | |
| 729 | - | log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("webview shown on load committed") | |
| 730 | - | } | |
| 731 | - | if c.onWebViewShown != nil { | |
| 732 | - | c.onWebViewShown(paneID) | |
| 732 | + | c.markPendingReveal(paneID) | |
| 733 | + | if wv.EstimatedProgress() > 0 { | |
| 734 | + | c.revealIfPending(ctx, paneID, url, "progress-after-commit") | |
| 733 | 735 | } | |
| 734 | 736 | ||
| 735 | 737 | // Update domain model with current URI for session snapshots | |
| @@ -800,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) { | |||
| 800 | 802 | if paneView != nil { | |
| 801 | 803 | paneView.SetLoading(false) | |
| 802 | 804 | } | |
| 805 | + | ||
| 806 | + | c.revealIfPending(context.Background(), paneID, "", "load-finished") | |
| 803 | 807 | } | |
| 804 | 808 | ||
| 805 | 809 | // onProgressChanged updates the progress bar with current load progress. | |
| 806 | 810 | func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) { | |
| 811 | + | if progress > 0 { | |
| 812 | + | c.revealIfPending(context.Background(), paneID, "", "progress") | |
| 813 | + | } | |
| 814 | + | ||
| 807 | 815 | _, wsView := c.getActiveWS() | |
| 808 | 816 | if wsView == nil { | |
| 809 | 817 | return | |
| @@ -815,6 +823,49 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl | |||
| 815 | 823 | } | |
| 816 | 824 | } | |
| 817 | 825 | ||
| 826 | + | func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) { | |
| 827 | + | c.revealMu.Lock() | |
| 828 | + | c.pendingReveal[paneID] = true | |
| 829 | + | c.revealMu.Unlock() | |
| 830 | + | } | |
| 831 | + | ||
| 832 | + | func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) { | |
| 833 | + | c.revealMu.Lock() | |
| 834 | + | delete(c.pendingReveal, paneID) | |
| 835 | + | c.revealMu.Unlock() | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) { | |
| 839 | + | c.revealMu.Lock() | |
| 840 | + | pending := c.pendingReveal[paneID] | |
| 841 | + | if pending { | |
| 842 | + | delete(c.pendingReveal, paneID) | |
| 843 | + | } | |
| 844 | + | c.revealMu.Unlock() | |
| 845 | + | ||
| 846 | + | if !pending { | |
| 847 | + | return | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | wv := c.webViews[paneID] | |
| 851 | + | if wv == nil || wv.IsDestroyed() { | |
| 852 | + | return | |
| 853 | + | } | |
| 854 | + | ||
| 855 | + | if inner := wv.Widget(); inner != nil { | |
| 856 | + | inner.SetVisible(true) | |
| 857 | + | logging.FromContext(ctx). | |
| 858 | + | Debug(). | |
| 859 | + | Str("pane_id", string(paneID)). | |
| 860 | + | Str("url", url). | |
| 861 | + | Str("reason", reason). | |
| 862 | + | Msg("webview revealed") | |
| 863 | + | } | |
| 864 | + | if c.onWebViewShown != nil { | |
| 865 | + | c.onWebViewShown(paneID) | |
| 866 | + | } | |
| 867 | + | } | |
| 868 | + | ||
| 818 | 869 | // onLinkHover updates the link status overlay when hovering over links. | |
| 819 | 870 | func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { | |
| 820 | 871 | _, wsView := c.getActiveWS() | |
| @@ -55,6 +55,9 @@ type ContentCoordinator struct { | |||||
| 55 | // Callback when the WebView becomes visible (first real commit) | 55 | // Callback when the WebView becomes visible (first real commit) | ||
| 56 | onWebViewShown func(paneID entity.PaneID) | 56 | onWebViewShown func(paneID entity.PaneID) | ||
| 57 | 57 | ||||
| 58 | + | revealMu sync.Mutex | |||
| 59 | + | pendingReveal map[entity.PaneID]bool | |||
| 60 | + | ||||
| 58 | // Gesture action handler for mouse button navigation | 61 | // Gesture action handler for mouse button navigation | ||
| 59 | gestureActionHandler input.ActionHandler | 62 | gestureActionHandler input.ActionHandler | ||
| 60 | 63 | ||||
| @@ -100,6 +103,7 @@ func NewContentCoordinator( | |||||
| 100 | webViews: make(map[entity.PaneID]*webkit.WebView), | 103 | webViews: make(map[entity.PaneID]*webkit.WebView), | ||
| 101 | paneTitles: make(map[entity.PaneID]string), | 104 | paneTitles: make(map[entity.PaneID]string), | ||
| 102 | navOrigins: make(map[entity.PaneID]string), | 105 | navOrigins: make(map[entity.PaneID]string), | ||
| 106 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 103 | getActiveWS: getActiveWS, | 107 | getActiveWS: getActiveWS, | ||
| 104 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | 108 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | ||
| 105 | } | 109 | } | ||
| @@ -721,15 +725,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 721 | // Avoid updating UI/domain state to about:blank when we know the pane is | 725 | // Avoid updating UI/domain state to about:blank when we know the pane is | ||
| 722 | // navigating to a different URL. This prevents the omnibox/window title from | 726 | // navigating to a different URL. This prevents the omnibox/window title from | ||
| 723 | // briefly showing about:blank on cold start. | 727 | // briefly showing about:blank on cold start. | ||
| 728 | + | c.clearPendingReveal(paneID) | |||
| 724 | return | 729 | return | ||
| 725 | } | 730 | } | ||
| 726 | 731 | ||||
| 727 | - | if inner := wv.Widget(); inner != nil { | 732 | + | c.markPendingReveal(paneID) |
| 728 | - | inner.SetVisible(true) | 733 | + | if wv.EstimatedProgress() > 0 { |
| 729 | - | log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("webview shown on load committed") | 734 | + | c.revealIfPending(ctx, paneID, url, "progress-after-commit") |
| 730 | - | } | |||
| 731 | - | if c.onWebViewShown != nil { | |||
| 732 | - | c.onWebViewShown(paneID) | |||
| 733 | } | 735 | } | ||
| 734 | 736 | ||||
| 735 | // Update domain model with current URI for session snapshots | 737 | // Update domain model with current URI for session snapshots | ||
| @@ -800,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) { | |||||
| 800 | if paneView != nil { | 802 | if paneView != nil { | ||
| 801 | paneView.SetLoading(false) | 803 | paneView.SetLoading(false) | ||
| 802 | } | 804 | } | ||
| 805 | + | ||||
| 806 | + | c.revealIfPending(context.Background(), paneID, "", "load-finished") | |||
| 803 | } | 807 | } | ||
| 804 | 808 | ||||
| 805 | // onProgressChanged updates the progress bar with current load progress. | 809 | // onProgressChanged updates the progress bar with current load progress. | ||
| 806 | func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) { | 810 | func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) { | ||
| 811 | + | if progress > 0 { | |||
| 812 | + | c.revealIfPending(context.Background(), paneID, "", "progress") | |||
| 813 | + | } | |||
| 814 | + | ||||
| 807 | _, wsView := c.getActiveWS() | 815 | _, wsView := c.getActiveWS() | ||
| 808 | if wsView == nil { | 816 | if wsView == nil { | ||
| 809 | return | 817 | return | ||
| @@ -815,6 +823,49 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl | |||||
| 815 | } | 823 | } | ||
| 816 | } | 824 | } | ||
| 817 | 825 | ||||
| 826 | + | func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) { | |||
| 827 | + | c.revealMu.Lock() | |||
| 828 | + | c.pendingReveal[paneID] = true | |||
| 829 | + | c.revealMu.Unlock() | |||
| 830 | + | } | |||
| 831 | + | ||||
| 832 | + | func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) { | |||
| 833 | + | c.revealMu.Lock() | |||
| 834 | + | delete(c.pendingReveal, paneID) | |||
| 835 | + | c.revealMu.Unlock() | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) { | |||
| 839 | + | c.revealMu.Lock() | |||
| 840 | + | pending := c.pendingReveal[paneID] | |||
| 841 | + | if pending { | |||
| 842 | + | delete(c.pendingReveal, paneID) | |||
| 843 | + | } | |||
| 844 | + | c.revealMu.Unlock() | |||
| 845 | + | ||||
| 846 | + | if !pending { | |||
| 847 | + | return | |||
| 848 | + | } | |||
| 849 | + | ||||
| 850 | + | wv := c.webViews[paneID] | |||
| 851 | + | if wv == nil || wv.IsDestroyed() { | |||
| 852 | + | return | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | if inner := wv.Widget(); inner != nil { | |||
| 856 | + | inner.SetVisible(true) | |||
| 857 | + | logging.FromContext(ctx). | |||
| 858 | + | Debug(). | |||
| 859 | + | Str("pane_id", string(paneID)). | |||
| 860 | + | Str("url", url). | |||
| 861 | + | Str("reason", reason). | |||
| 862 | + | Msg("webview revealed") | |||
| 863 | + | } | |||
| 864 | + | if c.onWebViewShown != nil { | |||
| 865 | + | c.onWebViewShown(paneID) | |||
| 866 | + | } | |||
| 867 | + | } | |||
| 868 | + | ||||
| 818 | // onLinkHover updates the link status overlay when hovering over links. | 869 | // onLinkHover updates the link status overlay when hovering over links. | ||
| 819 | func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { | 870 | func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { | ||
| 820 | _, wsView := c.getActiveWS() | 871 | _, wsView := c.getActiveWS() | ||
A internal/ui/coordinator/content_reveal_test.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | package coordinator | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // TestPendingReveal tests the pending reveal state management. | |
| 15 | + | // These are internal package tests that verify the state transitions | |
| 16 | + | // without needing to mock WebKit dependencies. | |
| 17 | + | ||
| 18 | + | func TestMarkPendingReveal_SetsState(t *testing.T) { | |
| 19 | + | c := &ContentCoordinator{ | |
| 20 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 21 | + | } | |
| 22 | + | paneID := entity.PaneID("pane-1") | |
| 23 | + | ||
| 24 | + | c.markPendingReveal(paneID) | |
| 25 | + | ||
| 26 | + | c.revealMu.Lock() | |
| 27 | + | defer c.revealMu.Unlock() | |
| 28 | + | assert.True(t, c.pendingReveal[paneID]) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestClearPendingReveal_RemovesState(t *testing.T) { | |
| 32 | + | c := &ContentCoordinator{ | |
| 33 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 34 | + | } | |
| 35 | + | paneID := entity.PaneID("pane-1") | |
| 36 | + | ||
| 37 | + | // First mark as pending | |
| 38 | + | c.markPendingReveal(paneID) | |
| 39 | + | ||
| 40 | + | // Then clear | |
| 41 | + | c.clearPendingReveal(paneID) | |
| 42 | + | ||
| 43 | + | c.revealMu.Lock() | |
| 44 | + | defer c.revealMu.Unlock() | |
| 45 | + | assert.False(t, c.pendingReveal[paneID]) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) { | |
| 49 | + | callbackCalled := false | |
| 50 | + | c := &ContentCoordinator{ | |
| 51 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 52 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 53 | + | onWebViewShown: func(paneID entity.PaneID) { | |
| 54 | + | callbackCalled = true | |
| 55 | + | }, | |
| 56 | + | } | |
| 57 | + | paneID := entity.PaneID("pane-1") | |
| 58 | + | ||
| 59 | + | // Call reveal without marking pending first | |
| 60 | + | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | |
| 61 | + | ||
| 62 | + | assert.False(t, callbackCalled, "callback should not be called when not pending") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) { | |
| 66 | + | c := &ContentCoordinator{ | |
| 67 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 68 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 69 | + | } | |
| 70 | + | paneID := entity.PaneID("pane-1") | |
| 71 | + | ||
| 72 | + | // Mark as pending | |
| 73 | + | c.markPendingReveal(paneID) | |
| 74 | + | ||
| 75 | + | // Reveal (will return early since webview is nil, but should still clear state) | |
| 76 | + | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | |
| 77 | + | ||
| 78 | + | c.revealMu.Lock() | |
| 79 | + | defer c.revealMu.Unlock() | |
| 80 | + | assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) { | |
| 84 | + | revealCount := 0 | |
| 85 | + | c := &ContentCoordinator{ | |
| 86 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 87 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 88 | + | onWebViewShown: func(paneID entity.PaneID) { | |
| 89 | + | revealCount++ | |
| 90 | + | }, | |
| 91 | + | } | |
| 92 | + | paneID := entity.PaneID("pane-1") | |
| 93 | + | ||
| 94 | + | // Mark as pending | |
| 95 | + | c.markPendingReveal(paneID) | |
| 96 | + | ||
| 97 | + | // Call reveal multiple times | |
| 98 | + | c.revealIfPending(context.Background(), paneID, "", "first") | |
| 99 | + | c.revealIfPending(context.Background(), paneID, "", "second") | |
| 100 | + | c.revealIfPending(context.Background(), paneID, "", "third") | |
| 101 | + | ||
| 102 | + | // Callback should not be called since webview is nil, but state should be cleared | |
| 103 | + | assert.Equal(t, 0, revealCount, "callback not called when webview is nil") | |
| 104 | + | ||
| 105 | + | // Verify state is cleared | |
| 106 | + | c.revealMu.Lock() | |
| 107 | + | defer c.revealMu.Unlock() | |
| 108 | + | assert.False(t, c.pendingReveal[paneID]) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestPendingReveal_ConcurrentAccess(t *testing.T) { | |
| 112 | + | c := &ContentCoordinator{ | |
| 113 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 114 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | var wg sync.WaitGroup | |
| 118 | + | paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"} | |
| 119 | + | ||
| 120 | + | // Concurrently mark and clear panes | |
| 121 | + | for i := 0; i < 100; i++ { | |
| 122 | + | for _, paneID := range paneIDs { | |
| 123 | + | wg.Add(3) | |
| 124 | + | ||
| 125 | + | go func(id entity.PaneID) { | |
| 126 | + | defer wg.Done() | |
| 127 | + | c.markPendingReveal(id) | |
| 128 | + | }(paneID) | |
| 129 | + | ||
| 130 | + | go func(id entity.PaneID) { | |
| 131 | + | defer wg.Done() | |
| 132 | + | c.clearPendingReveal(id) | |
| 133 | + | }(paneID) | |
| 134 | + | ||
| 135 | + | go func(id entity.PaneID) { | |
| 136 | + | defer wg.Done() | |
| 137 | + | c.revealIfPending(context.Background(), id, "", "concurrent") | |
| 138 | + | }(paneID) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | wg.Wait() | |
| 143 | + | // Test passes if no race conditions occur | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestMarkPendingReveal_MultiplePanes(t *testing.T) { | |
| 147 | + | c := &ContentCoordinator{ | |
| 148 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | pane1 := entity.PaneID("pane-1") | |
| 152 | + | pane2 := entity.PaneID("pane-2") | |
| 153 | + | ||
| 154 | + | c.markPendingReveal(pane1) | |
| 155 | + | c.markPendingReveal(pane2) | |
| 156 | + | ||
| 157 | + | c.revealMu.Lock() | |
| 158 | + | defer c.revealMu.Unlock() | |
| 159 | + | assert.True(t, c.pendingReveal[pane1]) | |
| 160 | + | assert.True(t, c.pendingReveal[pane2]) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) { | |
| 164 | + | c := &ContentCoordinator{ | |
| 165 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | pane1 := entity.PaneID("pane-1") | |
| 169 | + | pane2 := entity.PaneID("pane-2") | |
| 170 | + | ||
| 171 | + | c.markPendingReveal(pane1) | |
| 172 | + | c.markPendingReveal(pane2) | |
| 173 | + | c.clearPendingReveal(pane1) | |
| 174 | + | ||
| 175 | + | c.revealMu.Lock() | |
| 176 | + | defer c.revealMu.Unlock() | |
| 177 | + | assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared") | |
| 178 | + | assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending") | |
| 179 | + | } | |
| @@ -0,0 +1,179 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // TestPendingReveal tests the pending reveal state management. | |||
| 15 | + | // These are internal package tests that verify the state transitions | |||
| 16 | + | // without needing to mock WebKit dependencies. | |||
| 17 | + | ||||
| 18 | + | func TestMarkPendingReveal_SetsState(t *testing.T) { | |||
| 19 | + | c := &ContentCoordinator{ | |||
| 20 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 21 | + | } | |||
| 22 | + | paneID := entity.PaneID("pane-1") | |||
| 23 | + | ||||
| 24 | + | c.markPendingReveal(paneID) | |||
| 25 | + | ||||
| 26 | + | c.revealMu.Lock() | |||
| 27 | + | defer c.revealMu.Unlock() | |||
| 28 | + | assert.True(t, c.pendingReveal[paneID]) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestClearPendingReveal_RemovesState(t *testing.T) { | |||
| 32 | + | c := &ContentCoordinator{ | |||
| 33 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 34 | + | } | |||
| 35 | + | paneID := entity.PaneID("pane-1") | |||
| 36 | + | ||||
| 37 | + | // First mark as pending | |||
| 38 | + | c.markPendingReveal(paneID) | |||
| 39 | + | ||||
| 40 | + | // Then clear | |||
| 41 | + | c.clearPendingReveal(paneID) | |||
| 42 | + | ||||
| 43 | + | c.revealMu.Lock() | |||
| 44 | + | defer c.revealMu.Unlock() | |||
| 45 | + | assert.False(t, c.pendingReveal[paneID]) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) { | |||
| 49 | + | callbackCalled := false | |||
| 50 | + | c := &ContentCoordinator{ | |||
| 51 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 52 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |||
| 53 | + | onWebViewShown: func(paneID entity.PaneID) { | |||
| 54 | + | callbackCalled = true | |||
| 55 | + | }, | |||
| 56 | + | } | |||
| 57 | + | paneID := entity.PaneID("pane-1") | |||
| 58 | + | ||||
| 59 | + | // Call reveal without marking pending first | |||
| 60 | + | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | |||
| 61 | + | ||||
| 62 | + | assert.False(t, callbackCalled, "callback should not be called when not pending") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) { | |||
| 66 | + | c := &ContentCoordinator{ | |||
| 67 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 68 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |||
| 69 | + | } | |||
| 70 | + | paneID := entity.PaneID("pane-1") | |||
| 71 | + | ||||
| 72 | + | // Mark as pending | |||
| 73 | + | c.markPendingReveal(paneID) | |||
| 74 | + | ||||
| 75 | + | // Reveal (will return early since webview is nil, but should still clear state) | |||
| 76 | + | c.revealIfPending(context.Background(), paneID, "http://example.com", "test") | |||
| 77 | + | ||||
| 78 | + | c.revealMu.Lock() | |||
| 79 | + | defer c.revealMu.Unlock() | |||
| 80 | + | assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) { | |||
| 84 | + | revealCount := 0 | |||
| 85 | + | c := &ContentCoordinator{ | |||
| 86 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 87 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |||
| 88 | + | onWebViewShown: func(paneID entity.PaneID) { | |||
| 89 | + | revealCount++ | |||
| 90 | + | }, | |||
| 91 | + | } | |||
| 92 | + | paneID := entity.PaneID("pane-1") | |||
| 93 | + | ||||
| 94 | + | // Mark as pending | |||
| 95 | + | c.markPendingReveal(paneID) | |||
| 96 | + | ||||
| 97 | + | // Call reveal multiple times | |||
| 98 | + | c.revealIfPending(context.Background(), paneID, "", "first") | |||
| 99 | + | c.revealIfPending(context.Background(), paneID, "", "second") | |||
| 100 | + | c.revealIfPending(context.Background(), paneID, "", "third") | |||
| 101 | + | ||||
| 102 | + | // Callback should not be called since webview is nil, but state should be cleared | |||
| 103 | + | assert.Equal(t, 0, revealCount, "callback not called when webview is nil") | |||
| 104 | + | ||||
| 105 | + | // Verify state is cleared | |||
| 106 | + | c.revealMu.Lock() | |||
| 107 | + | defer c.revealMu.Unlock() | |||
| 108 | + | assert.False(t, c.pendingReveal[paneID]) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestPendingReveal_ConcurrentAccess(t *testing.T) { | |||
| 112 | + | c := &ContentCoordinator{ | |||
| 113 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 114 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | var wg sync.WaitGroup | |||
| 118 | + | paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"} | |||
| 119 | + | ||||
| 120 | + | // Concurrently mark and clear panes | |||
| 121 | + | for i := 0; i < 100; i++ { | |||
| 122 | + | for _, paneID := range paneIDs { | |||
| 123 | + | wg.Add(3) | |||
| 124 | + | ||||
| 125 | + | go func(id entity.PaneID) { | |||
| 126 | + | defer wg.Done() | |||
| 127 | + | c.markPendingReveal(id) | |||
| 128 | + | }(paneID) | |||
| 129 | + | ||||
| 130 | + | go func(id entity.PaneID) { | |||
| 131 | + | defer wg.Done() | |||
| 132 | + | c.clearPendingReveal(id) | |||
| 133 | + | }(paneID) | |||
| 134 | + | ||||
| 135 | + | go func(id entity.PaneID) { | |||
| 136 | + | defer wg.Done() | |||
| 137 | + | c.revealIfPending(context.Background(), id, "", "concurrent") | |||
| 138 | + | }(paneID) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | wg.Wait() | |||
| 143 | + | // Test passes if no race conditions occur | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestMarkPendingReveal_MultiplePanes(t *testing.T) { | |||
| 147 | + | c := &ContentCoordinator{ | |||
| 148 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | pane1 := entity.PaneID("pane-1") | |||
| 152 | + | pane2 := entity.PaneID("pane-2") | |||
| 153 | + | ||||
| 154 | + | c.markPendingReveal(pane1) | |||
| 155 | + | c.markPendingReveal(pane2) | |||
| 156 | + | ||||
| 157 | + | c.revealMu.Lock() | |||
| 158 | + | defer c.revealMu.Unlock() | |||
| 159 | + | assert.True(t, c.pendingReveal[pane1]) | |||
| 160 | + | assert.True(t, c.pendingReveal[pane2]) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) { | |||
| 164 | + | c := &ContentCoordinator{ | |||
| 165 | + | pendingReveal: make(map[entity.PaneID]bool), | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | pane1 := entity.PaneID("pane-1") | |||
| 169 | + | pane2 := entity.PaneID("pane-2") | |||
| 170 | + | ||||
| 171 | + | c.markPendingReveal(pane1) | |||
| 172 | + | c.markPendingReveal(pane2) | |||
| 173 | + | c.clearPendingReveal(pane1) | |||
| 174 | + | ||||
| 175 | + | c.revealMu.Lock() | |||
| 176 | + | defer c.revealMu.Unlock() | |||
| 177 | + | assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared") | |||
| 178 | + | assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending") | |||
| 179 | + | } | |||
M internal/ui/theme/css.go +9 -4
| @@ -339,15 +339,15 @@ entry.omnibox-entry > *:focus-visible { | |||
| 339 | 339 | font-weight: 500; | |
| 340 | 340 | } | |
| 341 | 341 | ||
| 342 | - | /* URL text below title */ | |
| 343 | - | .omnibox-suggestion-url { | |
| 342 | + | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | |
| 343 | + | .omnibox-row .omnibox-suggestion-url { | |
| 344 | 344 | font-size: 0.75em; | |
| 345 | - | color: var(--muted); | |
| 345 | + | color: alpha(var(--muted), 0.65); | |
| 346 | 346 | font-weight: 400; | |
| 347 | 347 | } | |
| 348 | 348 | ||
| 349 | 349 | .omnibox-row:selected .omnibox-suggestion-url { | |
| 350 | - | color: var(--muted); | |
| 350 | + | color: alpha(var(--muted), 0.75); | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | 353 | /* Keyboard shortcut badge */ | |
| @@ -475,6 +475,11 @@ func generatePaneCSS(p Palette) string { | |||
| 475 | 475 | background-color: var(--bg); | |
| 476 | 476 | } | |
| 477 | 477 | ||
| 478 | + | /* WebView widget - theme background prevents white flash during page load */ | |
| 479 | + | .webview-themed { | |
| 480 | + | background-color: var(--bg); | |
| 481 | + | } | |
| 482 | + | ||
| 478 | 483 | /* Loading skeleton - shown until WebView paints */ | |
| 479 | 484 | .loading-skeleton { | |
| 480 | 485 | background-color: var(--bg); | |
| @@ -339,15 +339,15 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 339 | font-weight: 500; | 339 | font-weight: 500; | ||
| 340 | } | 340 | } | ||
| 341 | 341 | ||||
| 342 | - | /* URL text below title */ | 342 | + | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ |
| 343 | - | .omnibox-suggestion-url { | 343 | + | .omnibox-row .omnibox-suggestion-url { |
| 344 | font-size: 0.75em; | 344 | font-size: 0.75em; | ||
| 345 | - | color: var(--muted); | 345 | + | color: alpha(var(--muted), 0.65); |
| 346 | font-weight: 400; | 346 | font-weight: 400; | ||
| 347 | } | 347 | } | ||
| 348 | 348 | ||||
| 349 | .omnibox-row:selected .omnibox-suggestion-url { | 349 | .omnibox-row:selected .omnibox-suggestion-url { | ||
| 350 | - | color: var(--muted); | 350 | + | color: alpha(var(--muted), 0.75); |
| 351 | } | 351 | } | ||
| 352 | 352 | ||||
| 353 | /* Keyboard shortcut badge */ | 353 | /* Keyboard shortcut badge */ | ||
| @@ -475,6 +475,11 @@ func generatePaneCSS(p Palette) string { | |||||
| 475 | background-color: var(--bg); | 475 | background-color: var(--bg); | ||
| 476 | } | 476 | } | ||
| 477 | 477 | ||||
| 478 | + | /* WebView widget - theme background prevents white flash during page load */ | |||
| 479 | + | .webview-themed { | |||
| 480 | + | background-color: var(--bg); | |||
| 481 | + | } | |||
| 482 | + | ||||
| 478 | /* Loading skeleton - shown until WebView paints */ | 483 | /* Loading skeleton - shown until WebView paints */ | ||
| 479 | .loading-skeleton { | 484 | .loading-skeleton { | ||
| 480 | background-color: var(--bg); | 485 | background-color: var(--bg); | ||