fix: eliminate white flash and improve dark mode detection (fixes #14, #15)
- commit
- e7367918829122a5be0ed10ea5ba762743db4428
- author
- brice · · 2025-12-24 06:49 UTC
- committer
- brice · · 2025-12-24 16:11 UTC
- parent
- 1628a6b
- signature
- unsigned
13 files · +251 -38
M CHANGELOG.md +2 -0
| @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Fixed | |
| 8 | + | - **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits. | |
| 9 | + | - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference. | |
| 8 | 10 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | |
| 9 | 11 | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | |
| 10 | 12 | ||
| @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Fixed | 7 | ### Fixed | ||
| 8 | + | - **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits. | |||
| 9 | + | - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference. | |||
| 8 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | 10 | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | ||
| 9 | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | 11 | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | ||
| 10 | 12 | ||||
M internal/infrastructure/webkit/factory.go +37 -4
| @@ -3,6 +3,7 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "sync" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| @@ -18,6 +19,10 @@ type WebViewFactory struct { | |||
| 18 | 19 | injector *ContentInjector | |
| 19 | 20 | router *MessageRouter | |
| 20 | 21 | filterApplier FilterApplier // Optional content filter applier | |
| 22 | + | ||
| 23 | + | // Background color for WebViews (eliminates white flash) | |
| 24 | + | bgR, bgG, bgB, bgA float32 | |
| 25 | + | bgMu sync.RWMutex | |
| 21 | 26 | } | |
| 22 | 27 | ||
| 23 | 28 | // NewWebViewFactory creates a new WebViewFactory. | |
| @@ -48,6 +53,18 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) { | |||
| 48 | 53 | } | |
| 49 | 54 | } | |
| 50 | 55 | ||
| 56 | + | // SetBackgroundColor sets the background color for newly created WebViews. | |
| 57 | + | // This color is shown before content is painted, eliminating white flash. | |
| 58 | + | 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() | |
| 62 | + | // Also propagate to the pool if present | |
| 63 | + | if f.pool != nil { | |
| 64 | + | f.pool.SetBackgroundColor(r, g, b, a) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 51 | 68 | // Create creates a new WebView instance. | |
| 52 | 69 | // If a pool is configured, it will try to acquire from the pool first. | |
| 53 | 70 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | |
| @@ -88,8 +105,16 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 88 | 105 | return nil, fmt.Errorf("create related webview: %w", err) | |
| 89 | 106 | } | |
| 90 | 107 | ||
| 91 | - | // Ensure visible | |
| 92 | - | wv.inner.SetVisible(true) | |
| 108 | + | // 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 { | |
| 113 | + | wv.SetBackgroundColor(r, g, b, a) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Keep hidden until content is painted | |
| 117 | + | wv.inner.SetVisible(false) | |
| 93 | 118 | ||
| 94 | 119 | // Attach frontend (scripts, message handler) | |
| 95 | 120 | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | |
| @@ -118,8 +143,16 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||
| 118 | 143 | return nil, err | |
| 119 | 144 | } | |
| 120 | 145 | ||
| 121 | - | // Ensure visible | |
| 122 | - | wv.inner.SetVisible(true) | |
| 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 | + | } | |
| 153 | + | ||
| 154 | + | // Keep hidden until content is painted | |
| 155 | + | wv.inner.SetVisible(false) | |
| 123 | 156 | ||
| 124 | 157 | // Attach frontend | |
| 125 | 158 | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | |
| @@ -3,6 +3,7 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "sync" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -18,6 +19,10 @@ type WebViewFactory struct { | |||||
| 18 | injector *ContentInjector | 19 | injector *ContentInjector | ||
| 19 | router *MessageRouter | 20 | router *MessageRouter | ||
| 20 | filterApplier FilterApplier // Optional content filter applier | 21 | filterApplier FilterApplier // Optional content filter applier | ||
| 22 | + | ||||
| 23 | + | // Background color for WebViews (eliminates white flash) | |||
| 24 | + | bgR, bgG, bgB, bgA float32 | |||
| 25 | + | bgMu sync.RWMutex | |||
| 21 | } | 26 | } | ||
| 22 | 27 | ||||
| 23 | // NewWebViewFactory creates a new WebViewFactory. | 28 | // NewWebViewFactory creates a new WebViewFactory. | ||
| @@ -48,6 +53,18 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) { | |||||
| 48 | } | 53 | } | ||
| 49 | } | 54 | } | ||
| 50 | 55 | ||||
| 56 | + | // SetBackgroundColor sets the background color for newly created WebViews. | |||
| 57 | + | // This color is shown before content is painted, eliminating white flash. | |||
| 58 | + | 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() | |||
| 62 | + | // Also propagate to the pool if present | |||
| 63 | + | if f.pool != nil { | |||
| 64 | + | f.pool.SetBackgroundColor(r, g, b, a) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 51 | // Create creates a new WebView instance. | 68 | // Create creates a new WebView instance. | ||
| 52 | // If a pool is configured, it will try to acquire from the pool first. | 69 | // If a pool is configured, it will try to acquire from the pool first. | ||
| 53 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | 70 | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | ||
| @@ -88,8 +105,16 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 88 | return nil, fmt.Errorf("create related webview: %w", err) | 105 | return nil, fmt.Errorf("create related webview: %w", err) | ||
| 89 | } | 106 | } | ||
| 90 | 107 | ||||
| 91 | - | // Ensure visible | 108 | + | // Set background color to match theme (eliminates white flash) |
| 92 | - | wv.inner.SetVisible(true) | 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 { | |||
| 113 | + | wv.SetBackgroundColor(r, g, b, a) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Keep hidden until content is painted | |||
| 117 | + | wv.inner.SetVisible(false) | |||
| 93 | 118 | ||||
| 94 | // Attach frontend (scripts, message handler) | 119 | // Attach frontend (scripts, message handler) | ||
| 95 | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | 120 | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | ||
| @@ -118,8 +143,16 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||||
| 118 | return nil, err | 143 | return nil, err | ||
| 119 | } | 144 | } | ||
| 120 | 145 | ||||
| 121 | - | // Ensure visible | 146 | + | // Set background color to match theme (eliminates white flash) |
| 122 | - | wv.inner.SetVisible(true) | 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 | + | } | |||
| 153 | + | ||||
| 154 | + | // Keep hidden until content is painted | |||
| 155 | + | wv.inner.SetVisible(false) | |||
| 123 | 156 | ||||
| 124 | // Attach frontend | 157 | // Attach frontend | ||
| 125 | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | 158 | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | ||
M internal/infrastructure/webkit/injector.go +69 -29
| @@ -27,9 +27,19 @@ const themeCSSScript = `(function() { | |||
| 27 | 27 | ||
| 28 | 28 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | |
| 29 | 29 | // It must be injected at document start, after __dumber_gtk_prefers_dark is set. | |
| 30 | + | // This script: | |
| 31 | + | // 1. Sets dark/light class on <html> for CSS-based theme detection | |
| 32 | + | // 2. Injects color-scheme meta tag and CSS for browser UA style hints | |
| 33 | + | // 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries | |
| 34 | + | // | |
| 35 | + | // The matchMedia patch handles various query formats: | |
| 36 | + | // - (prefers-color-scheme: dark) | |
| 37 | + | // - (prefers-color-scheme:dark) -- no space | |
| 38 | + | // - screen and (prefers-color-scheme: dark) | |
| 39 | + | // And provides proper addEventListener/removeEventListener stubs for compatibility. | |
| 30 | 40 | const darkModeScript = `(function() { | |
| 31 | - | const prefersDark = window.__dumber_gtk_prefers_dark || false; | |
| 32 | - | const originalMatchMedia = window.matchMedia.bind(window); | |
| 41 | + | var prefersDark = window.__dumber_gtk_prefers_dark || false; | |
| 42 | + | var originalMatchMedia = window.matchMedia.bind(window); | |
| 33 | 43 | ||
| 34 | 44 | // Apply dark/light class to document element for CSS theming | |
| 35 | 45 | if (prefersDark) { | |
| @@ -41,44 +51,74 @@ const darkModeScript = `(function() { | |||
| 41 | 51 | } | |
| 42 | 52 | ||
| 43 | 53 | // Inject color-scheme meta tag | |
| 44 | - | const meta = document.createElement('meta'); | |
| 54 | + | var meta = document.createElement('meta'); | |
| 45 | 55 | meta.name = 'color-scheme'; | |
| 46 | 56 | meta.content = prefersDark ? 'dark light' : 'light dark'; | |
| 47 | 57 | document.documentElement.appendChild(meta); | |
| 48 | 58 | ||
| 49 | 59 | // Inject root color-scheme style | |
| 50 | - | const style = document.createElement('style'); | |
| 60 | + | var style = document.createElement('style'); | |
| 51 | 61 | style.setAttribute('data-dumber-color-scheme', ''); | |
| 52 | 62 | style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}'; | |
| 53 | 63 | document.documentElement.appendChild(style); | |
| 54 | 64 | ||
| 65 | + | // Helper: Check if query is a prefers-color-scheme query | |
| 66 | + | // Normalizes by removing whitespace and lowercasing | |
| 67 | + | function isColorSchemeQuery(query, scheme) { | |
| 68 | + | if (typeof query !== 'string') return false; | |
| 69 | + | var normalized = query.replace(/\s+/g, '').toLowerCase(); | |
| 70 | + | return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Create a fake MediaQueryList that implements the full interface | |
| 74 | + | function createFakeMediaQueryList(query, matches) { | |
| 75 | + | var listeners = []; | |
| 76 | + | var onchangeHandler = null; | |
| 77 | + | ||
| 78 | + | return { | |
| 79 | + | matches: matches, | |
| 80 | + | media: query, | |
| 81 | + | get onchange() { return onchangeHandler; }, | |
| 82 | + | set onchange(fn) { onchangeHandler = fn; }, | |
| 83 | + | // Deprecated but still used by some sites | |
| 84 | + | addListener: function(cb) { | |
| 85 | + | if (typeof cb === 'function') listeners.push(cb); | |
| 86 | + | }, | |
| 87 | + | removeListener: function(cb) { | |
| 88 | + | var idx = listeners.indexOf(cb); | |
| 89 | + | if (idx !== -1) listeners.splice(idx, 1); | |
| 90 | + | }, | |
| 91 | + | // Modern API | |
| 92 | + | addEventListener: function(type, cb) { | |
| 93 | + | if (type === 'change' && typeof cb === 'function') { | |
| 94 | + | listeners.push(cb); | |
| 95 | + | } | |
| 96 | + | }, | |
| 97 | + | removeEventListener: function(type, cb) { | |
| 98 | + | if (type === 'change') { | |
| 99 | + | var idx = listeners.indexOf(cb); | |
| 100 | + | if (idx !== -1) listeners.splice(idx, 1); | |
| 101 | + | } | |
| 102 | + | }, | |
| 103 | + | dispatchEvent: function(event) { | |
| 104 | + | for (var i = 0; i < listeners.length; i++) { | |
| 105 | + | try { listeners[i](event); } catch (e) {} | |
| 106 | + | } | |
| 107 | + | if (onchangeHandler) { | |
| 108 | + | try { onchangeHandler(event); } catch (e) {} | |
| 109 | + | } | |
| 110 | + | return true; | |
| 111 | + | } | |
| 112 | + | }; | |
| 113 | + | } | |
| 114 | + | ||
| 55 | 115 | // Patch matchMedia for prefers-color-scheme queries | |
| 56 | 116 | window.matchMedia = function(query) { | |
| 57 | - | if (typeof query === 'string') { | |
| 58 | - | if (query.includes('prefers-color-scheme: dark')) { | |
| 59 | - | return { | |
| 60 | - | matches: prefersDark, | |
| 61 | - | media: query, | |
| 62 | - | onchange: null, | |
| 63 | - | addListener: function() {}, | |
| 64 | - | removeListener: function() {}, | |
| 65 | - | addEventListener: function() {}, | |
| 66 | - | removeEventListener: function() {}, | |
| 67 | - | dispatchEvent: function() { return false; } | |
| 68 | - | }; | |
| 69 | - | } | |
| 70 | - | if (query.includes('prefers-color-scheme: light')) { | |
| 71 | - | return { | |
| 72 | - | matches: !prefersDark, | |
| 73 | - | media: query, | |
| 74 | - | onchange: null, | |
| 75 | - | addListener: function() {}, | |
| 76 | - | removeListener: function() {}, | |
| 77 | - | addEventListener: function() {}, | |
| 78 | - | removeEventListener: function() {}, | |
| 79 | - | dispatchEvent: function() { return false; } | |
| 80 | - | }; | |
| 81 | - | } | |
| 117 | + | if (isColorSchemeQuery(query, 'dark')) { | |
| 118 | + | return createFakeMediaQueryList(query, prefersDark); | |
| 119 | + | } | |
| 120 | + | if (isColorSchemeQuery(query, 'light')) { | |
| 121 | + | return createFakeMediaQueryList(query, !prefersDark); | |
| 82 | 122 | } | |
| 83 | 123 | return originalMatchMedia(query); | |
| 84 | 124 | }; | |
| @@ -27,9 +27,19 @@ const themeCSSScript = `(function() { | |||||
| 27 | 27 | ||||
| 28 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | 28 | // 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. | 29 | // It must be injected at document start, after __dumber_gtk_prefers_dark is set. | ||
| 30 | + | // This script: | |||
| 31 | + | // 1. Sets dark/light class on <html> for CSS-based theme detection | |||
| 32 | + | // 2. Injects color-scheme meta tag and CSS for browser UA style hints | |||
| 33 | + | // 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries | |||
| 34 | + | // | |||
| 35 | + | // The matchMedia patch handles various query formats: | |||
| 36 | + | // - (prefers-color-scheme: dark) | |||
| 37 | + | // - (prefers-color-scheme:dark) -- no space | |||
| 38 | + | // - screen and (prefers-color-scheme: dark) | |||
| 39 | + | // And provides proper addEventListener/removeEventListener stubs for compatibility. | |||
| 30 | const darkModeScript = `(function() { | 40 | const darkModeScript = `(function() { | ||
| 31 | - | const prefersDark = window.__dumber_gtk_prefers_dark || false; | 41 | + | var prefersDark = window.__dumber_gtk_prefers_dark || false; |
| 32 | - | const originalMatchMedia = window.matchMedia.bind(window); | 42 | + | var originalMatchMedia = window.matchMedia.bind(window); |
| 33 | 43 | ||||
| 34 | // Apply dark/light class to document element for CSS theming | 44 | // Apply dark/light class to document element for CSS theming | ||
| 35 | if (prefersDark) { | 45 | if (prefersDark) { | ||
| @@ -41,44 +51,74 @@ const darkModeScript = `(function() { | |||||
| 41 | } | 51 | } | ||
| 42 | 52 | ||||
| 43 | // Inject color-scheme meta tag | 53 | // Inject color-scheme meta tag | ||
| 44 | - | const meta = document.createElement('meta'); | 54 | + | var meta = document.createElement('meta'); |
| 45 | meta.name = 'color-scheme'; | 55 | meta.name = 'color-scheme'; | ||
| 46 | meta.content = prefersDark ? 'dark light' : 'light dark'; | 56 | meta.content = prefersDark ? 'dark light' : 'light dark'; | ||
| 47 | document.documentElement.appendChild(meta); | 57 | document.documentElement.appendChild(meta); | ||
| 48 | 58 | ||||
| 49 | // Inject root color-scheme style | 59 | // Inject root color-scheme style | ||
| 50 | - | const style = document.createElement('style'); | 60 | + | var style = document.createElement('style'); |
| 51 | style.setAttribute('data-dumber-color-scheme', ''); | 61 | style.setAttribute('data-dumber-color-scheme', ''); | ||
| 52 | style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}'; | 62 | style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}'; | ||
| 53 | document.documentElement.appendChild(style); | 63 | document.documentElement.appendChild(style); | ||
| 54 | 64 | ||||
| 65 | + | // Helper: Check if query is a prefers-color-scheme query | |||
| 66 | + | // Normalizes by removing whitespace and lowercasing | |||
| 67 | + | function isColorSchemeQuery(query, scheme) { | |||
| 68 | + | if (typeof query !== 'string') return false; | |||
| 69 | + | var normalized = query.replace(/\s+/g, '').toLowerCase(); | |||
| 70 | + | return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1; | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Create a fake MediaQueryList that implements the full interface | |||
| 74 | + | function createFakeMediaQueryList(query, matches) { | |||
| 75 | + | var listeners = []; | |||
| 76 | + | var onchangeHandler = null; | |||
| 77 | + | ||||
| 78 | + | return { | |||
| 79 | + | matches: matches, | |||
| 80 | + | media: query, | |||
| 81 | + | get onchange() { return onchangeHandler; }, | |||
| 82 | + | set onchange(fn) { onchangeHandler = fn; }, | |||
| 83 | + | // Deprecated but still used by some sites | |||
| 84 | + | addListener: function(cb) { | |||
| 85 | + | if (typeof cb === 'function') listeners.push(cb); | |||
| 86 | + | }, | |||
| 87 | + | removeListener: function(cb) { | |||
| 88 | + | var idx = listeners.indexOf(cb); | |||
| 89 | + | if (idx !== -1) listeners.splice(idx, 1); | |||
| 90 | + | }, | |||
| 91 | + | // Modern API | |||
| 92 | + | addEventListener: function(type, cb) { | |||
| 93 | + | if (type === 'change' && typeof cb === 'function') { | |||
| 94 | + | listeners.push(cb); | |||
| 95 | + | } | |||
| 96 | + | }, | |||
| 97 | + | removeEventListener: function(type, cb) { | |||
| 98 | + | if (type === 'change') { | |||
| 99 | + | var idx = listeners.indexOf(cb); | |||
| 100 | + | if (idx !== -1) listeners.splice(idx, 1); | |||
| 101 | + | } | |||
| 102 | + | }, | |||
| 103 | + | dispatchEvent: function(event) { | |||
| 104 | + | for (var i = 0; i < listeners.length; i++) { | |||
| 105 | + | try { listeners[i](event); } catch (e) {} | |||
| 106 | + | } | |||
| 107 | + | if (onchangeHandler) { | |||
| 108 | + | try { onchangeHandler(event); } catch (e) {} | |||
| 109 | + | } | |||
| 110 | + | return true; | |||
| 111 | + | } | |||
| 112 | + | }; | |||
| 113 | + | } | |||
| 114 | + | ||||
| 55 | // Patch matchMedia for prefers-color-scheme queries | 115 | // Patch matchMedia for prefers-color-scheme queries | ||
| 56 | window.matchMedia = function(query) { | 116 | window.matchMedia = function(query) { | ||
| 57 | - | if (typeof query === 'string') { | 117 | + | if (isColorSchemeQuery(query, 'dark')) { |
| 58 | - | if (query.includes('prefers-color-scheme: dark')) { | 118 | + | return createFakeMediaQueryList(query, prefersDark); |
| 59 | - | return { | 119 | + | } |
| 60 | - | matches: prefersDark, | 120 | + | if (isColorSchemeQuery(query, 'light')) { |
| 61 | - | media: query, | 121 | + | return createFakeMediaQueryList(query, !prefersDark); |
| 62 | - | onchange: null, | |||
| 63 | - | addListener: function() {}, | |||
| 64 | - | removeListener: function() {}, | |||
| 65 | - | addEventListener: function() {}, | |||
| 66 | - | removeEventListener: function() {}, | |||
| 67 | - | dispatchEvent: function() { return false; } | |||
| 68 | - | }; | |||
| 69 | - | } | |||
| 70 | - | if (query.includes('prefers-color-scheme: light')) { | |||
| 71 | - | return { | |||
| 72 | - | matches: !prefersDark, | |||
| 73 | - | media: query, | |||
| 74 | - | onchange: null, | |||
| 75 | - | addListener: function() {}, | |||
| 76 | - | removeListener: function() {}, | |||
| 77 | - | addEventListener: function() {}, | |||
| 78 | - | removeEventListener: function() {}, | |||
| 79 | - | dispatchEvent: function() { return false; } | |||
| 80 | - | }; | |||
| 81 | - | } | |||
| 82 | } | 122 | } | ||
| 83 | return originalMatchMedia(query); | 123 | return originalMatchMedia(query); | ||
| 84 | }; | 124 | }; | ||
M internal/infrastructure/webkit/pool.go +22 -2
| @@ -49,6 +49,10 @@ type WebViewPool struct { | |||
| 49 | 49 | router *MessageRouter | |
| 50 | 50 | filterApplier FilterApplier // Optional content filter applier | |
| 51 | 51 | ||
| 52 | + | // Background color for WebViews (eliminates white flash) | |
| 53 | + | bgR, bgG, bgB, bgA float32 | |
| 54 | + | bgMu sync.RWMutex | |
| 55 | + | ||
| 52 | 56 | closed atomic.Bool | |
| 53 | 57 | wg sync.WaitGroup | |
| 54 | 58 | } | |
| @@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||
| 96 | 100 | p.filterApplier = applier | |
| 97 | 101 | } | |
| 98 | 102 | ||
| 103 | + | // SetBackgroundColor sets the background color for newly created WebViews. | |
| 104 | + | // This color is shown before content is painted, eliminating white flash. | |
| 105 | + | func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |
| 106 | + | p.bgMu.Lock() | |
| 107 | + | p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a | |
| 108 | + | p.bgMu.Unlock() | |
| 109 | + | } | |
| 110 | + | ||
| 99 | 111 | // Acquire gets a WebView from the pool or creates a new one. | |
| 100 | 112 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |
| 101 | 113 | log := logging.FromContext(ctx) | |
| @@ -135,8 +147,16 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||
| 135 | 147 | return nil, err | |
| 136 | 148 | } | |
| 137 | 149 | ||
| 138 | - | // Ensure WebView is visible when used | |
| 139 | - | wv.inner.SetVisible(true) | |
| 150 | + | // Set background color to match theme (eliminates white flash) | |
| 151 | + | p.bgMu.RLock() | |
| 152 | + | r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA | |
| 153 | + | p.bgMu.RUnlock() | |
| 154 | + | if a > 0 { // Only set if a valid color was configured | |
| 155 | + | wv.SetBackgroundColor(r, g, b, a) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) | |
| 159 | + | wv.inner.SetVisible(false) | |
| 140 | 160 | ||
| 141 | 161 | if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil { | |
| 142 | 162 | log.Warn().Err(err).Msg("failed to attach frontend to new webview") | |
| @@ -49,6 +49,10 @@ type WebViewPool struct { | |||||
| 49 | router *MessageRouter | 49 | router *MessageRouter | ||
| 50 | filterApplier FilterApplier // Optional content filter applier | 50 | filterApplier FilterApplier // Optional content filter applier | ||
| 51 | 51 | ||||
| 52 | + | // Background color for WebViews (eliminates white flash) | |||
| 53 | + | bgR, bgG, bgB, bgA float32 | |||
| 54 | + | bgMu sync.RWMutex | |||
| 55 | + | ||||
| 52 | closed atomic.Bool | 56 | closed atomic.Bool | ||
| 53 | wg sync.WaitGroup | 57 | wg sync.WaitGroup | ||
| 54 | } | 58 | } | ||
| @@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||||
| 96 | p.filterApplier = applier | 100 | p.filterApplier = applier | ||
| 97 | } | 101 | } | ||
| 98 | 102 | ||||
| 103 | + | // SetBackgroundColor sets the background color for newly created WebViews. | |||
| 104 | + | // This color is shown before content is painted, eliminating white flash. | |||
| 105 | + | func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||
| 106 | + | p.bgMu.Lock() | |||
| 107 | + | p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a | |||
| 108 | + | p.bgMu.Unlock() | |||
| 109 | + | } | |||
| 110 | + | ||||
| 99 | // Acquire gets a WebView from the pool or creates a new one. | 111 | // Acquire gets a WebView from the pool or creates a new one. | ||
| 100 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | 112 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | ||
| 101 | log := logging.FromContext(ctx) | 113 | log := logging.FromContext(ctx) | ||
| @@ -135,8 +147,16 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||||
| 135 | return nil, err | 147 | return nil, err | ||
| 136 | } | 148 | } | ||
| 137 | 149 | ||||
| 138 | - | // Ensure WebView is visible when used | 150 | + | // Set background color to match theme (eliminates white flash) |
| 139 | - | wv.inner.SetVisible(true) | 151 | + | p.bgMu.RLock() |
| 152 | + | r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA | |||
| 153 | + | p.bgMu.RUnlock() | |||
| 154 | + | if a > 0 { // Only set if a valid color was configured | |||
| 155 | + | wv.SetBackgroundColor(r, g, b, a) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) | |||
| 159 | + | wv.inner.SetVisible(false) | |||
| 140 | 160 | ||||
| 141 | if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil { | 161 | if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil { | ||
| 142 | log.Warn().Err(err).Msg("failed to attach frontend to new webview") | 162 | log.Warn().Err(err).Msg("failed to attach frontend to new webview") | ||
M internal/infrastructure/webkit/webview.go +16 -0
| @@ -729,6 +729,22 @@ func (wv *WebView) GetZoomLevel() float64 { | |||
| 729 | 729 | return wv.inner.GetZoomLevel() | |
| 730 | 730 | } | |
| 731 | 731 | ||
| 732 | + | // SetBackgroundColor sets the WebView background color. | |
| 733 | + | // This color is shown before content is painted, eliminating white flash. | |
| 734 | + | // Values are in range 0.0-1.0 for red, green, blue, alpha. | |
| 735 | + | func (wv *WebView) SetBackgroundColor(r, g, b, a float32) { | |
| 736 | + | if wv.destroyed.Load() { | |
| 737 | + | return | |
| 738 | + | } | |
| 739 | + | rgba := &gdk.RGBA{ | |
| 740 | + | Red: r, | |
| 741 | + | Green: g, | |
| 742 | + | Blue: b, | |
| 743 | + | Alpha: a, | |
| 744 | + | } | |
| 745 | + | wv.inner.SetBackgroundColor(rgba) | |
| 746 | + | } | |
| 747 | + | ||
| 732 | 748 | // State returns the current WebView state as a snapshot. | |
| 733 | 749 | func (wv *WebView) State() port.WebViewState { | |
| 734 | 750 | return port.WebViewState{ | |
| @@ -729,6 +729,22 @@ func (wv *WebView) GetZoomLevel() float64 { | |||||
| 729 | return wv.inner.GetZoomLevel() | 729 | return wv.inner.GetZoomLevel() | ||
| 730 | } | 730 | } | ||
| 731 | 731 | ||||
| 732 | + | // SetBackgroundColor sets the WebView background color. | |||
| 733 | + | // This color is shown before content is painted, eliminating white flash. | |||
| 734 | + | // Values are in range 0.0-1.0 for red, green, blue, alpha. | |||
| 735 | + | func (wv *WebView) SetBackgroundColor(r, g, b, a float32) { | |||
| 736 | + | if wv.destroyed.Load() { | |||
| 737 | + | return | |||
| 738 | + | } | |||
| 739 | + | rgba := &gdk.RGBA{ | |||
| 740 | + | Red: r, | |||
| 741 | + | Green: g, | |||
| 742 | + | Blue: b, | |||
| 743 | + | Alpha: a, | |||
| 744 | + | } | |||
| 745 | + | wv.inner.SetBackgroundColor(rgba) | |||
| 746 | + | } | |||
| 747 | + | ||||
| 732 | // State returns the current WebView state as a snapshot. | 748 | // State returns the current WebView state as a snapshot. | ||
| 733 | func (wv *WebView) State() port.WebViewState { | 749 | func (wv *WebView) State() port.WebViewState { | ||
| 734 | return port.WebViewState{ | 750 | return port.WebViewState{ | ||
M internal/ui/app.go +12 -0
| @@ -210,6 +210,13 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | 212 | func (a *App) prewarmWebViewPool(ctx context.Context) { | |
| 213 | + | // Set theme background color on pool to eliminate white flash. | |
| 214 | + | // Must be done before prewarming so WebViews get the correct color. | |
| 215 | + | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | |
| 216 | + | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |
| 217 | + | a.pool.SetBackgroundColor(r, g, b, alpha) | |
| 218 | + | } | |
| 219 | + | ||
| 213 | 220 | // Prewarm WebView pool now that GTK is initialized. | |
| 214 | 221 | if a.pool == nil { | |
| 215 | 222 | return | |
| @@ -504,6 +511,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 504 | 511 | a.injector, | |
| 505 | 512 | a.router, | |
| 506 | 513 | ) | |
| 514 | + | // Set theme background color on factory to eliminate white flash | |
| 515 | + | if a.deps.Theme != nil { | |
| 516 | + | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |
| 517 | + | a.webViewFactory.SetBackgroundColor(r, g, b, alpha) | |
| 518 | + | } | |
| 507 | 519 | a.contentCoord.SetPopupConfig( | |
| 508 | 520 | a.webViewFactory, | |
| 509 | 521 | &a.deps.Config.Workspace.Popups, | |
| @@ -210,6 +210,13 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| 212 | func (a *App) prewarmWebViewPool(ctx context.Context) { | 212 | func (a *App) prewarmWebViewPool(ctx context.Context) { | ||
| 213 | + | // Set theme background color on pool to eliminate white flash. | |||
| 214 | + | // Must be done before prewarming so WebViews get the correct color. | |||
| 215 | + | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | |||
| 216 | + | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |||
| 217 | + | a.pool.SetBackgroundColor(r, g, b, alpha) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 213 | // Prewarm WebView pool now that GTK is initialized. | 220 | // Prewarm WebView pool now that GTK is initialized. | ||
| 214 | if a.pool == nil { | 221 | if a.pool == nil { | ||
| 215 | return | 222 | return | ||
| @@ -504,6 +511,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 504 | a.injector, | 511 | a.injector, | ||
| 505 | a.router, | 512 | a.router, | ||
| 506 | ) | 513 | ) | ||
| 514 | + | // Set theme background color on factory to eliminate white flash | |||
| 515 | + | if a.deps.Theme != nil { | |||
| 516 | + | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |||
| 517 | + | a.webViewFactory.SetBackgroundColor(r, g, b, alpha) | |||
| 518 | + | } | |||
| 507 | a.contentCoord.SetPopupConfig( | 519 | a.contentCoord.SetPopupConfig( | ||
| 508 | a.webViewFactory, | 520 | a.webViewFactory, | ||
| 509 | &a.deps.Config.Workspace.Popups, | 521 | &a.deps.Config.Workspace.Popups, | ||
M internal/ui/component/pane_view.go +2 -1
| @@ -42,10 +42,11 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg | |||
| 42 | 42 | overlay.SetHexpand(true) | |
| 43 | 43 | overlay.SetVexpand(true) | |
| 44 | 44 | overlay.SetVisible(true) | |
| 45 | + | overlay.AddCssClass("pane-overlay") // Theme background prevents white flash | |
| 45 | 46 | ||
| 46 | 47 | // Set the WebView as the main child | |
| 48 | + | // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted | |
| 47 | 49 | if webViewWidget != nil { | |
| 48 | - | webViewWidget.SetVisible(true) | |
| 49 | 50 | overlay.SetChild(webViewWidget) | |
| 50 | 51 | } | |
| 51 | 52 | ||
| @@ -42,10 +42,11 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg | |||||
| 42 | overlay.SetHexpand(true) | 42 | overlay.SetHexpand(true) | ||
| 43 | overlay.SetVexpand(true) | 43 | overlay.SetVexpand(true) | ||
| 44 | overlay.SetVisible(true) | 44 | overlay.SetVisible(true) | ||
| 45 | + | overlay.AddCssClass("pane-overlay") // Theme background prevents white flash | |||
| 45 | 46 | ||||
| 46 | // Set the WebView as the main child | 47 | // Set the WebView as the main child | ||
| 48 | + | // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted | |||
| 47 | if webViewWidget != nil { | 49 | if webViewWidget != nil { | ||
| 48 | - | webViewWidget.SetVisible(true) | |||
| 49 | overlay.SetChild(webViewWidget) | 50 | overlay.SetChild(webViewWidget) | ||
| 50 | } | 51 | } | ||
| 51 | 52 | ||||
M internal/ui/component/pane_view_test.go +4 -2
| @@ -25,7 +25,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) { | |||
| 25 | 25 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 26 | 26 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 27 | 27 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 28 | - | mockWebView.EXPECT().SetVisible(true).Once() | |
| 28 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 29 | 29 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | |
| 30 | 30 | ||
| 31 | 31 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| @@ -59,6 +59,7 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||
| 59 | 59 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 60 | 60 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 61 | 61 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 62 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 62 | 63 | // SetChild should NOT be called when webview is nil | |
| 63 | 64 | ||
| 64 | 65 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| @@ -504,7 +505,7 @@ func setupPaneViewMocks( | |||
| 504 | 505 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 505 | 506 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 506 | 507 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 507 | - | mockWebView.EXPECT().SetVisible(true).Once() | |
| 508 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 508 | 509 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | |
| 509 | 510 | ||
| 510 | 511 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| @@ -527,6 +528,7 @@ func setupPaneViewMocksNoWebView( | |||
| 527 | 528 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 528 | 529 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 529 | 530 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 531 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 530 | 532 | ||
| 531 | 533 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 532 | 534 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| @@ -25,7 +25,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) { | |||||
| 25 | mockOverlay.EXPECT().SetHexpand(true).Once() | 25 | mockOverlay.EXPECT().SetHexpand(true).Once() | ||
| 26 | mockOverlay.EXPECT().SetVexpand(true).Once() | 26 | mockOverlay.EXPECT().SetVexpand(true).Once() | ||
| 27 | mockOverlay.EXPECT().SetVisible(true).Once() | 27 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 28 | - | mockWebView.EXPECT().SetVisible(true).Once() | 28 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background |
| 29 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | 29 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | ||
| 30 | 30 | ||||
| 31 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 31 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| @@ -59,6 +59,7 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||||
| 59 | mockOverlay.EXPECT().SetHexpand(true).Once() | 59 | mockOverlay.EXPECT().SetHexpand(true).Once() | ||
| 60 | mockOverlay.EXPECT().SetVexpand(true).Once() | 60 | mockOverlay.EXPECT().SetVexpand(true).Once() | ||
| 61 | mockOverlay.EXPECT().SetVisible(true).Once() | 61 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 62 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |||
| 62 | // SetChild should NOT be called when webview is nil | 63 | // SetChild should NOT be called when webview is nil | ||
| 63 | 64 | ||||
| 64 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 65 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| @@ -504,7 +505,7 @@ func setupPaneViewMocks( | |||||
| 504 | mockOverlay.EXPECT().SetHexpand(true).Once() | 505 | mockOverlay.EXPECT().SetHexpand(true).Once() | ||
| 505 | mockOverlay.EXPECT().SetVexpand(true).Once() | 506 | mockOverlay.EXPECT().SetVexpand(true).Once() | ||
| 506 | mockOverlay.EXPECT().SetVisible(true).Once() | 507 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 507 | - | mockWebView.EXPECT().SetVisible(true).Once() | 508 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background |
| 508 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | 509 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | ||
| 509 | 510 | ||||
| 510 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 511 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| @@ -527,6 +528,7 @@ func setupPaneViewMocksNoWebView( | |||||
| 527 | mockOverlay.EXPECT().SetHexpand(true).Once() | 528 | mockOverlay.EXPECT().SetHexpand(true).Once() | ||
| 528 | mockOverlay.EXPECT().SetVexpand(true).Once() | 529 | mockOverlay.EXPECT().SetVexpand(true).Once() | ||
| 529 | mockOverlay.EXPECT().SetVisible(true).Once() | 530 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 531 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |||
| 530 | 532 | ||||
| 531 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 533 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 532 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 534 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
M internal/ui/component/workspace_view_test.go +1 -0
| @@ -41,6 +41,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||
| 41 | 41 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 42 | 42 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 43 | 43 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 44 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() | |
| 44 | 45 | ||
| 45 | 46 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 46 | 47 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| @@ -41,6 +41,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||||
| 41 | mockOverlay.EXPECT().SetHexpand(true).Once() | 41 | mockOverlay.EXPECT().SetHexpand(true).Once() | ||
| 42 | mockOverlay.EXPECT().SetVexpand(true).Once() | 42 | mockOverlay.EXPECT().SetVexpand(true).Once() | ||
| 43 | mockOverlay.EXPECT().SetVisible(true).Once() | 43 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 44 | + | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() | |||
| 44 | 45 | ||||
| 45 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 46 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 46 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 47 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
M internal/ui/coordinator/content.go +10 -0
| @@ -639,7 +639,17 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en | |||
| 639 | 639 | // onLoadCommitted re-applies zoom when page content starts loading and records history. | |
| 640 | 640 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | |
| 641 | 641 | // History is recorded here because the URI is guaranteed to be correct after commit. | |
| 642 | + | // Also shows the WebView widget (it's hidden during creation to avoid white flash). | |
| 642 | 643 | func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 644 | + | log := logging.FromContext(ctx) | |
| 645 | + | ||
| 646 | + | // Show the WebView now that content is being painted | |
| 647 | + | // (WebViews are hidden on creation to avoid white flash) | |
| 648 | + | if inner := wv.Widget(); inner != nil { | |
| 649 | + | inner.SetVisible(true) | |
| 650 | + | log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed") | |
| 651 | + | } | |
| 652 | + | ||
| 643 | 653 | url := wv.URI() | |
| 644 | 654 | if url == "" { | |
| 645 | 655 | return | |
| @@ -639,7 +639,17 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en | |||||
| 639 | // onLoadCommitted re-applies zoom when page content starts loading and records history. | 639 | // onLoadCommitted re-applies zoom when page content starts loading and records history. | ||
| 640 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | 640 | // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. | ||
| 641 | // History is recorded here because the URI is guaranteed to be correct after commit. | 641 | // History is recorded here because the URI is guaranteed to be correct after commit. | ||
| 642 | + | // Also shows the WebView widget (it's hidden during creation to avoid white flash). | |||
| 642 | func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | 643 | func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | ||
| 644 | + | log := logging.FromContext(ctx) | |||
| 645 | + | ||||
| 646 | + | // Show the WebView now that content is being painted | |||
| 647 | + | // (WebViews are hidden on creation to avoid white flash) | |||
| 648 | + | if inner := wv.Widget(); inner != nil { | |||
| 649 | + | inner.SetVisible(true) | |||
| 650 | + | log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed") | |||
| 651 | + | } | |||
| 652 | + | ||||
| 643 | url := wv.URI() | 653 | url := wv.URI() | ||
| 644 | if url == "" { | 654 | if url == "" { | ||
| 645 | return | 655 | return | ||
M internal/ui/theme/css.go +5 -0
| @@ -434,6 +434,11 @@ entry.find-bar-entry:focus-visible { | |||
| 434 | 434 | func generatePaneCSS(p Palette) string { | |
| 435 | 435 | return `/* ===== Pane Styling ===== */ | |
| 436 | 436 | ||
| 437 | + | /* Pane overlay container - theme background prevents white flash */ | |
| 438 | + | .pane-overlay { | |
| 439 | + | background-color: var(--bg); | |
| 440 | + | } | |
| 441 | + | ||
| 437 | 442 | /* Pane border - default transparent */ | |
| 438 | 443 | .pane-border { | |
| 439 | 444 | border: 0.0625em solid transparent; | |
| @@ -434,6 +434,11 @@ entry.find-bar-entry:focus-visible { | |||||
| 434 | func generatePaneCSS(p Palette) string { | 434 | func generatePaneCSS(p Palette) string { | ||
| 435 | return `/* ===== Pane Styling ===== */ | 435 | return `/* ===== Pane Styling ===== */ | ||
| 436 | 436 | ||||
| 437 | + | /* Pane overlay container - theme background prevents white flash */ | |||
| 438 | + | .pane-overlay { | |||
| 439 | + | background-color: var(--bg); | |||
| 440 | + | } | |||
| 441 | + | ||||
| 437 | /* Pane border - default transparent */ | 442 | /* Pane border - default transparent */ | ||
| 438 | .pane-border { | 443 | .pane-border { | ||
| 439 | border: 0.0625em solid transparent; | 444 | border: 0.0625em solid transparent; | ||
M internal/ui/theme/manager.go +7 -0
| @@ -87,6 +87,13 @@ func (m *Manager) GetCurrentPalette() Palette { | |||
| 87 | 87 | return m.lightPalette | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | // GetBackgroundRGBA returns the current theme's background color as RGBA float32 values. | |
| 91 | + | // Used to set WebView background color to eliminate white flash during loading. | |
| 92 | + | func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) { | |
| 93 | + | palette := m.GetCurrentPalette() | |
| 94 | + | return HexToRGBA(palette.Background) | |
| 95 | + | } | |
| 96 | + | ||
| 90 | 97 | // GetLightPalette returns the light theme palette. | |
| 91 | 98 | func (m *Manager) GetLightPalette() Palette { | |
| 92 | 99 | return m.lightPalette | |
| @@ -87,6 +87,13 @@ func (m *Manager) GetCurrentPalette() Palette { | |||||
| 87 | return m.lightPalette | 87 | return m.lightPalette | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | // GetBackgroundRGBA returns the current theme's background color as RGBA float32 values. | |||
| 91 | + | // Used to set WebView background color to eliminate white flash during loading. | |||
| 92 | + | func (m *Manager) GetBackgroundRGBA() (r, g, b, a float32) { | |||
| 93 | + | palette := m.GetCurrentPalette() | |||
| 94 | + | return HexToRGBA(palette.Background) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 90 | // GetLightPalette returns the light theme palette. | 97 | // GetLightPalette returns the light theme palette. | ||
| 91 | func (m *Manager) GetLightPalette() Palette { | 98 | func (m *Manager) GetLightPalette() Palette { | ||
| 92 | return m.lightPalette | 99 | return m.lightPalette | ||
M internal/ui/theme/palette.go +64 -0
| @@ -97,6 +97,70 @@ func Coalesce(values ...string) string { | |||
| 97 | 97 | // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA). | |
| 98 | 98 | var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`) | |
| 99 | 99 | ||
| 100 | + | // Hex format length constants. | |
| 101 | + | const ( | |
| 102 | + | hexLenShort = 3 // #RGB | |
| 103 | + | hexLenStandard = 6 // #RRGGBB | |
| 104 | + | hexLenWithAlha = 8 // #RRGGBBAA | |
| 105 | + | maxColorValue = 255 // Maximum value for 8-bit color channel | |
| 106 | + | ) | |
| 107 | + | ||
| 108 | + | // HexToRGBA converts a hex color string to RGBA float32 values (0.0-1.0). | |
| 109 | + | // Supports #RGB, #RRGGBB, and #RRGGBBAA formats. | |
| 110 | + | // Returns opaque black (0,0,0,1) if parsing fails. | |
| 111 | + | func HexToRGBA(hex string) (r, g, b, a float32) { | |
| 112 | + | // Default to opaque black | |
| 113 | + | if hex == "" || hex[0] != '#' { | |
| 114 | + | return 0, 0, 0, 1 | |
| 115 | + | } | |
| 116 | + | hex = hex[1:] | |
| 117 | + | ||
| 118 | + | var ri, gi, bi, ai uint64 | |
| 119 | + | switch len(hex) { | |
| 120 | + | case hexLenShort: // #RGB | |
| 121 | + | ri, _ = parseHex(hex[0:1]) | |
| 122 | + | gi, _ = parseHex(hex[1:2]) | |
| 123 | + | bi, _ = parseHex(hex[2:3]) | |
| 124 | + | ri *= 17 // 0xF -> 0xFF | |
| 125 | + | gi *= 17 | |
| 126 | + | bi *= 17 | |
| 127 | + | ai = maxColorValue | |
| 128 | + | case hexLenStandard: // #RRGGBB | |
| 129 | + | ri, _ = parseHex(hex[0:2]) | |
| 130 | + | gi, _ = parseHex(hex[2:4]) | |
| 131 | + | bi, _ = parseHex(hex[4:6]) | |
| 132 | + | ai = maxColorValue | |
| 133 | + | case hexLenWithAlha: // #RRGGBBAA | |
| 134 | + | ri, _ = parseHex(hex[0:2]) | |
| 135 | + | gi, _ = parseHex(hex[2:4]) | |
| 136 | + | bi, _ = parseHex(hex[4:6]) | |
| 137 | + | ai, _ = parseHex(hex[6:8]) | |
| 138 | + | default: | |
| 139 | + | return 0, 0, 0, 1 | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | return float32(ri) / maxColorValue, float32(gi) / maxColorValue, float32(bi) / maxColorValue, float32(ai) / maxColorValue | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // parseHex parses a hex string to uint64. | |
| 146 | + | func parseHex(s string) (uint64, error) { | |
| 147 | + | var result uint64 | |
| 148 | + | for _, c := range s { | |
| 149 | + | result *= 16 | |
| 150 | + | switch { | |
| 151 | + | case c >= '0' && c <= '9': | |
| 152 | + | result += uint64(c - '0') | |
| 153 | + | case c >= 'a' && c <= 'f': | |
| 154 | + | result += uint64(c - 'a' + 10) | |
| 155 | + | case c >= 'A' && c <= 'F': | |
| 156 | + | result += uint64(c - 'A' + 10) | |
| 157 | + | default: | |
| 158 | + | return 0, fmt.Errorf("invalid hex char: %c", c) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return result, nil | |
| 162 | + | } | |
| 163 | + | ||
| 100 | 164 | // ValidateHexColor checks if a string is a valid hex color. | |
| 101 | 165 | func ValidateHexColor(color string) error { | |
| 102 | 166 | if color == "" { | |
| @@ -97,6 +97,70 @@ func Coalesce(values ...string) string { | |||||
| 97 | // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA). | 97 | // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA). | ||
| 98 | var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`) | 98 | var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`) | ||
| 99 | 99 | ||||
| 100 | + | // Hex format length constants. | |||
| 101 | + | const ( | |||
| 102 | + | hexLenShort = 3 // #RGB | |||
| 103 | + | hexLenStandard = 6 // #RRGGBB | |||
| 104 | + | hexLenWithAlha = 8 // #RRGGBBAA | |||
| 105 | + | maxColorValue = 255 // Maximum value for 8-bit color channel | |||
| 106 | + | ) | |||
| 107 | + | ||||
| 108 | + | // HexToRGBA converts a hex color string to RGBA float32 values (0.0-1.0). | |||
| 109 | + | // Supports #RGB, #RRGGBB, and #RRGGBBAA formats. | |||
| 110 | + | // Returns opaque black (0,0,0,1) if parsing fails. | |||
| 111 | + | func HexToRGBA(hex string) (r, g, b, a float32) { | |||
| 112 | + | // Default to opaque black | |||
| 113 | + | if hex == "" || hex[0] != '#' { | |||
| 114 | + | return 0, 0, 0, 1 | |||
| 115 | + | } | |||
| 116 | + | hex = hex[1:] | |||
| 117 | + | ||||
| 118 | + | var ri, gi, bi, ai uint64 | |||
| 119 | + | switch len(hex) { | |||
| 120 | + | case hexLenShort: // #RGB | |||
| 121 | + | ri, _ = parseHex(hex[0:1]) | |||
| 122 | + | gi, _ = parseHex(hex[1:2]) | |||
| 123 | + | bi, _ = parseHex(hex[2:3]) | |||
| 124 | + | ri *= 17 // 0xF -> 0xFF | |||
| 125 | + | gi *= 17 | |||
| 126 | + | bi *= 17 | |||
| 127 | + | ai = maxColorValue | |||
| 128 | + | case hexLenStandard: // #RRGGBB | |||
| 129 | + | ri, _ = parseHex(hex[0:2]) | |||
| 130 | + | gi, _ = parseHex(hex[2:4]) | |||
| 131 | + | bi, _ = parseHex(hex[4:6]) | |||
| 132 | + | ai = maxColorValue | |||
| 133 | + | case hexLenWithAlha: // #RRGGBBAA | |||
| 134 | + | ri, _ = parseHex(hex[0:2]) | |||
| 135 | + | gi, _ = parseHex(hex[2:4]) | |||
| 136 | + | bi, _ = parseHex(hex[4:6]) | |||
| 137 | + | ai, _ = parseHex(hex[6:8]) | |||
| 138 | + | default: | |||
| 139 | + | return 0, 0, 0, 1 | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | return float32(ri) / maxColorValue, float32(gi) / maxColorValue, float32(bi) / maxColorValue, float32(ai) / maxColorValue | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // parseHex parses a hex string to uint64. | |||
| 146 | + | func parseHex(s string) (uint64, error) { | |||
| 147 | + | var result uint64 | |||
| 148 | + | for _, c := range s { | |||
| 149 | + | result *= 16 | |||
| 150 | + | switch { | |||
| 151 | + | case c >= '0' && c <= '9': | |||
| 152 | + | result += uint64(c - '0') | |||
| 153 | + | case c >= 'a' && c <= 'f': | |||
| 154 | + | result += uint64(c - 'a' + 10) | |||
| 155 | + | case c >= 'A' && c <= 'F': | |||
| 156 | + | result += uint64(c - 'A' + 10) | |||
| 157 | + | default: | |||
| 158 | + | return 0, fmt.Errorf("invalid hex char: %c", c) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return result, nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 100 | // ValidateHexColor checks if a string is a valid hex color. | 164 | // ValidateHexColor checks if a string is a valid hex color. | ||
| 101 | func ValidateHexColor(color string) error { | 165 | func ValidateHexColor(color string) error { | ||
| 102 | if color == "" { | 166 | if color == "" { | ||