detached e736791

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.
55 ## [Unreleased]
66
77 ### 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.
810 - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
911 - **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.).
1012
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Fixed7 ### 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
33 import (
44 "context"
55 "fmt"
6+ "sync"
67
78 "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/logging"
@@ -18,6 +19,10 @@ type WebViewFactory struct {
1819 injector *ContentInjector
1920 router *MessageRouter
2021 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
2126 }
2227
2328 // NewWebViewFactory creates a new WebViewFactory.
@@ -48,6 +53,18 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
4853 }
4954 }
5055
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+
5168 // Create creates a new WebView instance.
5269 // If a pool is configured, it will try to acquire from the pool first.
5370 func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
@@ -88,8 +105,16 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
88105 return nil, fmt.Errorf("create related webview: %w", err)
89106 }
90107
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)
93118
94119 // Attach frontend (scripts, message handler)
95120 if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
@@ -118,8 +143,16 @@ func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
118143 return nil, err
119144 }
120145
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)
123156
124157 // Attach frontend
125158 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 *ContentInjector19 injector *ContentInjector
19 router *MessageRouter20 router *MessageRouter
20 filterApplier FilterApplier // Optional content filter applier21 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 visible108+ // 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, err143 return nil, err
119 }144 }
120 145
121- // Ensure visible146+ // 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 frontend157 // 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() {
2727
2828 // darkModeScript patches window.matchMedia and sets theme class on <html>.
2929 // 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.
3040 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);
3343
3444 // Apply dark/light class to document element for CSS theming
3545 if (prefersDark) {
@@ -41,44 +51,74 @@ const darkModeScript = `(function() {
4151 }
4252
4353 // Inject color-scheme meta tag
44- const meta = document.createElement('meta');
54+ var meta = document.createElement('meta');
4555 meta.name = 'color-scheme';
4656 meta.content = prefersDark ? 'dark light' : 'light dark';
4757 document.documentElement.appendChild(meta);
4858
4959 // Inject root color-scheme style
50- const style = document.createElement('style');
60+ var style = document.createElement('style');
5161 style.setAttribute('data-dumber-color-scheme', '');
5262 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
5363 document.documentElement.appendChild(style);
5464
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+
55115 // Patch matchMedia for prefers-color-scheme queries
56116 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);
82122 }
83123 return originalMatchMedia(query);
84124 };
@@ -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 theming44 // 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 tag53 // 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 style59 // 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 queries115 // 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 {
4949 router *MessageRouter
5050 filterApplier FilterApplier // Optional content filter applier
5151
52+ // Background color for WebViews (eliminates white flash)
53+ bgR, bgG, bgB, bgA float32
54+ bgMu sync.RWMutex
55+
5256 closed atomic.Bool
5357 wg sync.WaitGroup
5458 }
@@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
96100 p.filterApplier = applier
97101 }
98102
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+
99111 // Acquire gets a WebView from the pool or creates a new one.
100112 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
101113 log := logging.FromContext(ctx)
@@ -135,8 +147,16 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
135147 return nil, err
136148 }
137149
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)
140160
141161 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
142162 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
@@ -49,6 +49,10 @@ type WebViewPool struct {
49 router *MessageRouter49 router *MessageRouter
50 filterApplier FilterApplier // Optional content filter applier50 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.Bool56 closed atomic.Bool
53 wg sync.WaitGroup57 wg sync.WaitGroup
54 }58 }
@@ -96,6 +100,14 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
96 p.filterApplier = applier100 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, err147 return nil, err
136 }148 }
137 149
138- // Ensure WebView is visible when used150+ // 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 {
729729 return wv.inner.GetZoomLevel()
730730 }
731731
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+
732748 // State returns the current WebView state as a snapshot.
733749 func (wv *WebView) State() port.WebViewState {
734750 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) {
210210 }
211211
212212 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+
213220 // Prewarm WebView pool now that GTK is initialized.
214221 if a.pool == nil {
215222 return
@@ -504,6 +511,11 @@ func (a *App) initCoordinators(ctx context.Context) {
504511 a.injector,
505512 a.router,
506513 )
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+ }
507519 a.contentCoord.SetPopupConfig(
508520 a.webViewFactory,
509521 &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 return222 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
4242 overlay.SetHexpand(true)
4343 overlay.SetVexpand(true)
4444 overlay.SetVisible(true)
45+ overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
4546
4647 // Set the WebView as the main child
48+ // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
4749 if webViewWidget != nil {
48- webViewWidget.SetVisible(true)
4950 overlay.SetChild(webViewWidget)
5051 }
5152
@@ -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 child47 // 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) {
2525 mockOverlay.EXPECT().SetHexpand(true).Once()
2626 mockOverlay.EXPECT().SetVexpand(true).Once()
2727 mockOverlay.EXPECT().SetVisible(true).Once()
28- mockWebView.EXPECT().SetVisible(true).Once()
28+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
2929 mockOverlay.EXPECT().SetChild(mockWebView).Once()
3030
3131 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -59,6 +59,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
5959 mockOverlay.EXPECT().SetHexpand(true).Once()
6060 mockOverlay.EXPECT().SetVexpand(true).Once()
6161 mockOverlay.EXPECT().SetVisible(true).Once()
62+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
6263 // SetChild should NOT be called when webview is nil
6364
6465 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -504,7 +505,7 @@ func setupPaneViewMocks(
504505 mockOverlay.EXPECT().SetHexpand(true).Once()
505506 mockOverlay.EXPECT().SetVexpand(true).Once()
506507 mockOverlay.EXPECT().SetVisible(true).Once()
507- mockWebView.EXPECT().SetVisible(true).Once()
508+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
508509 mockOverlay.EXPECT().SetChild(mockWebView).Once()
509510
510511 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
@@ -527,6 +528,7 @@ func setupPaneViewMocksNoWebView(
527528 mockOverlay.EXPECT().SetHexpand(true).Once()
528529 mockOverlay.EXPECT().SetVexpand(true).Once()
529530 mockOverlay.EXPECT().SetVisible(true).Once()
531+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
530532
531533 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
532534 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 nil63 // 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
4141 mockOverlay.EXPECT().SetHexpand(true).Once()
4242 mockOverlay.EXPECT().SetVexpand(true).Once()
4343 mockOverlay.EXPECT().SetVisible(true).Once()
44+ mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
4445
4546 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
4647 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
639639 // onLoadCommitted re-applies zoom when page content starts loading and records history.
640640 // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
641641 // 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).
642643 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+
643653 url := wv.URI()
644654 if url == "" {
645655 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 return655 return
M internal/ui/theme/css.go +5 -0
@@ -434,6 +434,11 @@ entry.find-bar-entry:focus-visible {
434434 func generatePaneCSS(p Palette) string {
435435 return `/* ===== Pane Styling ===== */
436436
437+/* Pane overlay container - theme background prevents white flash */
438+.pane-overlay {
439+ background-color: var(--bg);
440+}
441+
437442 /* Pane border - default transparent */
438443 .pane-border {
439444 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 {
8787 return m.lightPalette
8888 }
8989
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+
9097 // GetLightPalette returns the light theme palette.
9198 func (m *Manager) GetLightPalette() Palette {
9299 return m.lightPalette
@@ -87,6 +87,13 @@ func (m *Manager) GetCurrentPalette() Palette {
87 return m.lightPalette87 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.lightPalette99 return m.lightPalette
M internal/ui/theme/palette.go +64 -0
@@ -97,6 +97,70 @@ func Coalesce(values ...string) string {
9797 // hexColorRegex matches valid hex colors (#RGB, #RRGGBB, #RRGGBBAA).
9898 var hexColorRegex = regexp.MustCompile(`^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$`)
9999
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+
100164 // ValidateHexColor checks if a string is a valid hex color.
101165 func ValidateHexColor(color string) error {
102166 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 == "" {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close