detached 68ec9b2

Merge pull request #258 from bnema/fix/cef-osr-stability-hardening

fix(cef): harden osr stability, browser creation, and shortcuts
commit
68ec9b2e52539bc1f1f9e119e2f46a1bcfc45533
author
bnema · · 2026-05-21 07:04 UTC
committer
GitHub · · 2026-05-21 07:04 UTC
merge of
602ad0d + 4128564 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
32 files · +1491 -126
M README.md +2 -0
@@ -103,6 +103,8 @@ Then set `engine.type = "cef"` in your config.
103103
104104 CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`.
105105
106+CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both `engine.cef.adaptive_windowless_frame_rate = true` and `engine.cef.windowless_frame_rate = 0` (the defaults). Adaptive mode is capped by `engine.cef.windowless_frame_rate_max = 240`. Setting `engine.cef.windowless_frame_rate` to a positive value forces a fixed rate instead. This adaptive polling path is Wayland-specific; other platforms fall back to the configured fixed/default CEF behavior.
107+
106108 ## Keyboard-Driven
107109
108110 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.
@@ -103,6 +103,8 @@ Then set `engine.type = "cef"` in your config.
103 103
104 CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`.104 CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`.
105 105
106+CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both `engine.cef.adaptive_windowless_frame_rate = true` and `engine.cef.windowless_frame_rate = 0` (the defaults). Adaptive mode is capped by `engine.cef.windowless_frame_rate_max = 240`. Setting `engine.cef.windowless_frame_rate` to a positive value forces a fixed rate instead. This adaptive polling path is Wayland-specific; other platforms fall back to the configured fixed/default CEF behavior.
107+
106 ## Keyboard-Driven108 ## Keyboard-Driven
107 109
108 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.110 Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern.
M docs/config/index.md +3 -0
@@ -190,6 +190,9 @@ border = "#363636"
190190 | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |
191191 | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |
192192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |
193+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) |
194+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default |
195+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF |
193196 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |
194197 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |
195198
@@ -190,6 +190,9 @@ border = "#363636"
190 | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |190 | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |
191 | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |191 | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |
192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |
193+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) |
194+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default |
195+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF |
193 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |196 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |
194 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |197 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |
195 198
M docs/reference/configuration.md +3 -0
@@ -60,6 +60,9 @@
6060 | `rendering.sample_memory` | bool | `false` | |
6161 | `rendering.debug_frames` | bool | `false` | |
6262 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |
63+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
64+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
65+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 |
6366 | `default_ui_scale` | float | `1.0` | > 0 |
6467 | `default_webpage_zoom` | float | `1.2` | > 0 |
6568 | `workspace.new_pane_url` | string | `about:blank` | |
@@ -60,6 +60,9 @@
60 | `rendering.sample_memory` | bool | `false` | |60 | `rendering.sample_memory` | bool | `false` | |
61 | `rendering.debug_frames` | bool | `false` | |61 | `rendering.debug_frames` | bool | `false` | |
62 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |62 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |
63+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
64+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
65+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 |
63 | `default_ui_scale` | float | `1.0` | > 0 |66 | `default_ui_scale` | float | `1.0` | > 0 |
64 | `default_webpage_zoom` | float | `1.2` | > 0 |67 | `default_webpage_zoom` | float | `1.2` | > 0 |
65 | `workspace.new_pane_url` | string | `about:blank` | |68 | `workspace.new_pane_url` | string | `about:blank` | |
M go.mod +1 -0
@@ -83,3 +83,4 @@ require (
8383 )
8484
8585 tool github.com/a-h/templ/cmd/templ
86+
@@ -83,3 +83,4 @@ require (
83 )83 )
84 84
85 tool github.com/a-h/templ/cmd/templ85 tool github.com/a-h/templ/cmd/templ
86+
M internal/bootstrap/engine.go +9 -7
@@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
7777 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
7878 }
7979 cefCfg := cef.RuntimeConfig{
80- CEFDir: cfg.Engine.CEF.CEFDir,
81- LogFile: cfg.Engine.CEF.LogFile,
82- LogSeverity: cfg.Engine.CEF.LogSeverity,
83- RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),
84- WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
85- EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
86- TraceHandlers: cfg.Engine.CEF.TraceHandlers,
80+ CEFDir: cfg.Engine.CEF.CEFDir,
81+ LogFile: cfg.Engine.CEF.LogFile,
82+ LogSeverity: cfg.Engine.CEF.LogSeverity,
83+ RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),
84+ AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(),
85+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
86+ WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),
87+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
88+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
8789 }
8890 deps := cef.EngineDependencies{
8991 RegisterHandlers: handlers.RegisterAll,
@@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
77 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),77 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
78 }78 }
79 cefCfg := cef.RuntimeConfig{79 cefCfg := cef.RuntimeConfig{
80- CEFDir: cfg.Engine.CEF.CEFDir,80+ CEFDir: cfg.Engine.CEF.CEFDir,
81- LogFile: cfg.Engine.CEF.LogFile,81+ LogFile: cfg.Engine.CEF.LogFile,
82- LogSeverity: cfg.Engine.CEF.LogSeverity,82+ LogSeverity: cfg.Engine.CEF.LogSeverity,
83- RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),83+ RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),
84- WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,84+ AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(),
85- EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,85+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
86- TraceHandlers: cfg.Engine.CEF.TraceHandlers,86+ WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),
87+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
88+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
87 }89 }
88 deps := cef.EngineDependencies{90 deps := cef.EngineDependencies{
89 RegisterHandlers: handlers.RegisterAll,91 RegisterHandlers: handlers.RegisterAll,
M internal/infrastructure/audio/factory/output_test.go +14 -25
@@ -3,6 +3,7 @@ package factory
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "testing"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -133,40 +134,28 @@ func TestSelector_BothFail_ReturnsError(t *testing.T) {
133134 }
134135
135136 // TestNewAudioOutputFactory_ReturnsWorkingFactory verifies that
136-// NewAudioOutputFactory returns a working Selector with PipeWire primary
137-// and null fallback.
137+// NewAudioOutputFactory wires PipeWire as primary and null as fallback without
138+// requiring a real PipeWire session.
138139 func TestNewAudioOutputFactory_ReturnsWorkingFactory(t *testing.T) {
139140 factory := NewAudioOutputFactory()
140141 if factory == nil {
141142 t.Fatal("Expected non-nil factory")
142143 }
143144
144- // Verify the factory produces a working stream
145- format := port.AudioStreamFormat{
146- SampleRate: 48000,
147- ChannelCount: 2,
148- FramesPerBuffer: 512,
145+ selector, ok := factory.(*Selector)
146+ if !ok {
147+ t.Fatalf("Expected *Selector, got %T", factory)
149148 }
150- ctx := context.Background()
151-
152- stream, err := factory.NewStream(ctx, format)
153- if err != nil {
154- t.Fatalf("Expected no error creating stream, got: %v", err)
149+ if selector.Primary == nil {
150+ t.Fatal("Expected non-nil primary backend")
155151 }
156- if stream == nil {
157- t.Fatal("Expected non-nil stream")
152+ if selector.Fallback == nil {
153+ t.Fatal("Expected non-nil fallback backend")
158154 }
159-
160- // Verify the stream works
161- samples := make([][]float32, format.ChannelCount)
162- for i := range samples {
163- samples[i] = make([]float32, format.FramesPerBuffer)
155+ if got := fmt.Sprintf("%T", selector.Primary); got != "*pipewire.Factory" {
156+ t.Fatalf("Expected PipeWire primary backend, got %s", got)
164157 }
165-
166- if err := stream.Write(samples); err != nil {
167- t.Errorf("Write failed: %v", err)
168- }
169- if err := stream.Close(); err != nil {
170- t.Errorf("Close failed: %v", err)
158+ if got := fmt.Sprintf("%T", selector.Fallback); got != "*null.Factory" {
159+ t.Fatalf("Expected null fallback backend, got %s", got)
171160 }
172161 }
@@ -3,6 +3,7 @@ package factory
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -133,40 +134,28 @@ func TestSelector_BothFail_ReturnsError(t *testing.T) {
133 }134 }
134 135
135 // TestNewAudioOutputFactory_ReturnsWorkingFactory verifies that136 // TestNewAudioOutputFactory_ReturnsWorkingFactory verifies that
136-// NewAudioOutputFactory returns a working Selector with PipeWire primary137+// NewAudioOutputFactory wires PipeWire as primary and null as fallback without
137-// and null fallback.138+// requiring a real PipeWire session.
138 func TestNewAudioOutputFactory_ReturnsWorkingFactory(t *testing.T) {139 func TestNewAudioOutputFactory_ReturnsWorkingFactory(t *testing.T) {
139 factory := NewAudioOutputFactory()140 factory := NewAudioOutputFactory()
140 if factory == nil {141 if factory == nil {
141 t.Fatal("Expected non-nil factory")142 t.Fatal("Expected non-nil factory")
142 }143 }
143 144
144- // Verify the factory produces a working stream145+ selector, ok := factory.(*Selector)
145- format := port.AudioStreamFormat{146+ if !ok {
146- SampleRate: 48000,147+ t.Fatalf("Expected *Selector, got %T", factory)
147- ChannelCount: 2,
148- FramesPerBuffer: 512,
149 }148 }
150- ctx := context.Background()149+ if selector.Primary == nil {
151-150+ t.Fatal("Expected non-nil primary backend")
152- stream, err := factory.NewStream(ctx, format)
153- if err != nil {
154- t.Fatalf("Expected no error creating stream, got: %v", err)
155 }151 }
156- if stream == nil {152+ if selector.Fallback == nil {
157- t.Fatal("Expected non-nil stream")153+ t.Fatal("Expected non-nil fallback backend")
158 }154 }
159-155+ if got := fmt.Sprintf("%T", selector.Primary); got != "*pipewire.Factory" {
160- // Verify the stream works156+ t.Fatalf("Expected PipeWire primary backend, got %s", got)
161- samples := make([][]float32, format.ChannelCount)
162- for i := range samples {
163- samples[i] = make([]float32, format.FramesPerBuffer)
164 }157 }
165-158+ if got := fmt.Sprintf("%T", selector.Fallback); got != "*null.Factory" {
166- if err := stream.Write(samples); err != nil {159+ t.Fatalf("Expected null fallback backend, got %s", got)
167- t.Errorf("Write failed: %v", err)
168- }
169- if err := stream.Close(); err != nil {
170- t.Errorf("Close failed: %v", err)
171 }160 }
172 }161 }
A internal/infrastructure/cef/adaptive_frame_rate.go +136 -0
@@ -0,0 +1,136 @@
1+package cef
2+
3+import (
4+ "strings"
5+
6+ cef2gtk "github.com/bnema/purego-cef2gtk"
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const (
14+ adaptiveFrameRatePollIntervalMS = 1000
15+ adaptiveFrameRateMinFPS = 30
16+)
17+
18+func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 {
19+ return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{
20+ DefaultFPS: fallbackFPS,
21+ MinFPS: adaptiveFrameRateMinFPS,
22+ MaxFPS: maxFPS,
23+ })
24+}
25+
26+func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() {
27+ if wv == nil || !wv.adaptiveWindowlessFrameRate {
28+ return
29+ }
30+ wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() })
31+}
32+
33+func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() {
34+ if wv == nil {
35+ return
36+ }
37+ wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() })
38+}
39+
40+func (wv *WebView) startAdaptiveFrameRatePolling() {
41+ if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
42+ return
43+ }
44+
45+ wv.mu.Lock()
46+ if wv.adaptiveFrameRatePollID != 0 || wv.host == nil {
47+ wv.mu.Unlock()
48+ return
49+ }
50+ cb := new(glib.SourceFunc)
51+ *cb = func(uintptr) bool {
52+ if wv.destroyed.Load() {
53+ return false
54+ }
55+ wv.applyAdaptiveWindowlessFrameRate()
56+ return true
57+ }
58+ wv.adaptiveFrameRatePoll = cb
59+ wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0)
60+ wv.mu.Unlock()
61+
62+ wv.applyAdaptiveWindowlessFrameRate()
63+}
64+
65+func (wv *WebView) stopAdaptiveFrameRatePolling() {
66+ if wv == nil {
67+ return
68+ }
69+ wv.mu.Lock()
70+ pollID := wv.adaptiveFrameRatePollID
71+ wv.adaptiveFrameRatePollID = 0
72+ wv.adaptiveFrameRatePoll = nil
73+ wv.mu.Unlock()
74+ if pollID != 0 {
75+ glib.SourceRemove(pollID)
76+ }
77+}
78+
79+func (wv *WebView) applyAdaptiveWindowlessFrameRate() {
80+ if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil {
81+ return
82+ }
83+ widget := wv.viewBridge.Widget()
84+ refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget)
85+ if !ok || !isWaylandDisplayName(displayName) {
86+ return
87+ }
88+ fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax)
89+
90+ wv.mu.Lock()
91+ host := wv.host
92+ if host == nil || fps == wv.lastAdaptiveFrameRate {
93+ wv.mu.Unlock()
94+ return
95+ }
96+ old := wv.lastAdaptiveFrameRate
97+ wv.lastAdaptiveFrameRate = fps
98+ wv.mu.Unlock()
99+
100+ host.SetWindowlessFrameRate(fps)
101+ if wv.ctx != nil {
102+ logging.FromContext(wv.ctx).Info().
103+ Int32("old_windowless_frame_rate", old).
104+ Int32("windowless_frame_rate", fps).
105+ Int("monitor_refresh_millihz", refreshRateMilliHz).
106+ Str("display", displayName).
107+ Msg("cef: adaptive windowless frame rate updated")
108+ }
109+}
110+
111+func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) {
112+ if widget == nil {
113+ return 0, "", false
114+ }
115+ native := widget.GetNative()
116+ if native == nil {
117+ return 0, "", false
118+ }
119+ surface := native.GetSurface()
120+ if surface == nil {
121+ return 0, "", false
122+ }
123+ display := surface.GetDisplay()
124+ if display == nil {
125+ return 0, "", false
126+ }
127+ monitor := display.GetMonitorAtSurface(surface)
128+ if monitor == nil {
129+ return 0, display.GetName(), false
130+ }
131+ return monitor.GetRefreshRate(), display.GetName(), true
132+}
133+
134+func isWaylandDisplayName(name string) bool {
135+ return strings.Contains(strings.ToLower(name), "wayland")
136+}
@@ -0,0 +1,136 @@
1+package cef
2+
3+import (
4+ "strings"
5+
6+ cef2gtk "github.com/bnema/purego-cef2gtk"
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const (
14+ adaptiveFrameRatePollIntervalMS = 1000
15+ adaptiveFrameRateMinFPS = 30
16+)
17+
18+func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 {
19+ return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{
20+ DefaultFPS: fallbackFPS,
21+ MinFPS: adaptiveFrameRateMinFPS,
22+ MaxFPS: maxFPS,
23+ })
24+}
25+
26+func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() {
27+ if wv == nil || !wv.adaptiveWindowlessFrameRate {
28+ return
29+ }
30+ wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() })
31+}
32+
33+func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() {
34+ if wv == nil {
35+ return
36+ }
37+ wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() })
38+}
39+
40+func (wv *WebView) startAdaptiveFrameRatePolling() {
41+ if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
42+ return
43+ }
44+
45+ wv.mu.Lock()
46+ if wv.adaptiveFrameRatePollID != 0 || wv.host == nil {
47+ wv.mu.Unlock()
48+ return
49+ }
50+ cb := new(glib.SourceFunc)
51+ *cb = func(uintptr) bool {
52+ if wv.destroyed.Load() {
53+ return false
54+ }
55+ wv.applyAdaptiveWindowlessFrameRate()
56+ return true
57+ }
58+ wv.adaptiveFrameRatePoll = cb
59+ wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0)
60+ wv.mu.Unlock()
61+
62+ wv.applyAdaptiveWindowlessFrameRate()
63+}
64+
65+func (wv *WebView) stopAdaptiveFrameRatePolling() {
66+ if wv == nil {
67+ return
68+ }
69+ wv.mu.Lock()
70+ pollID := wv.adaptiveFrameRatePollID
71+ wv.adaptiveFrameRatePollID = 0
72+ wv.adaptiveFrameRatePoll = nil
73+ wv.mu.Unlock()
74+ if pollID != 0 {
75+ glib.SourceRemove(pollID)
76+ }
77+}
78+
79+func (wv *WebView) applyAdaptiveWindowlessFrameRate() {
80+ if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil {
81+ return
82+ }
83+ widget := wv.viewBridge.Widget()
84+ refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget)
85+ if !ok || !isWaylandDisplayName(displayName) {
86+ return
87+ }
88+ fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax)
89+
90+ wv.mu.Lock()
91+ host := wv.host
92+ if host == nil || fps == wv.lastAdaptiveFrameRate {
93+ wv.mu.Unlock()
94+ return
95+ }
96+ old := wv.lastAdaptiveFrameRate
97+ wv.lastAdaptiveFrameRate = fps
98+ wv.mu.Unlock()
99+
100+ host.SetWindowlessFrameRate(fps)
101+ if wv.ctx != nil {
102+ logging.FromContext(wv.ctx).Info().
103+ Int32("old_windowless_frame_rate", old).
104+ Int32("windowless_frame_rate", fps).
105+ Int("monitor_refresh_millihz", refreshRateMilliHz).
106+ Str("display", displayName).
107+ Msg("cef: adaptive windowless frame rate updated")
108+ }
109+}
110+
111+func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) {
112+ if widget == nil {
113+ return 0, "", false
114+ }
115+ native := widget.GetNative()
116+ if native == nil {
117+ return 0, "", false
118+ }
119+ surface := native.GetSurface()
120+ if surface == nil {
121+ return 0, "", false
122+ }
123+ display := surface.GetDisplay()
124+ if display == nil {
125+ return 0, "", false
126+ }
127+ monitor := display.GetMonitorAtSurface(surface)
128+ if monitor == nil {
129+ return 0, display.GetName(), false
130+ }
131+ return monitor.GetRefreshRate(), display.GetName(), true
132+}
133+
134+func isWaylandDisplayName(name string) bool {
135+ return strings.Contains(strings.ToLower(name), "wayland")
136+}
A internal/infrastructure/cef/adaptive_frame_rate_test.go +54 -0
@@ -0,0 +1,54 @@
1+package cef
2+
3+import "testing"
4+
5+func TestAdaptiveFrameRateForRefresh(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ refreshMilliHz int
9+ fallbackFPS int32
10+ maxFPS int32
11+ want int32
12+ }{
13+ {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60},
14+ {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144},
15+ {name: "uses lower refresh monitor", refreshMilliHz: 50000, fallbackFPS: 60, maxFPS: 240, want: 50},
16+ {name: "floors very low refresh monitor", refreshMilliHz: 24000, fallbackFPS: 60, maxFPS: 240, want: 30},
17+ {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240},
18+ }
19+
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS)
23+ if got != tt.want {
24+ t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want)
25+ }
26+ })
27+ }
28+}
29+
30+func TestIsWaylandDisplayName(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ input string
34+ want bool
35+ }{
36+ {name: "exact lowercase", input: "wayland", want: true},
37+ {name: "exact uppercase", input: "WAYLAND", want: true},
38+ {name: "mixed case", input: "WayLand", want: true},
39+ {name: "numeric suffix", input: "wayland-1", want: true},
40+ {name: "punctuation suffix", input: "wayland:0", want: true},
41+ {name: "leading whitespace", input: " wayland ", want: true},
42+ {name: "substring", input: "mywayland-display", want: true},
43+ {name: "x11", input: "x11", want: false},
44+ {name: "empty", input: "", want: false},
45+ {name: "whitespace", input: " ", want: false},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ if got := isWaylandDisplayName(tt.input); got != tt.want {
50+ t.Fatalf("isWaylandDisplayName(%q) = %v, want %v", tt.input, got, tt.want)
51+ }
52+ })
53+ }
54+}
@@ -0,0 +1,54 @@
1+package cef
2+
3+import "testing"
4+
5+func TestAdaptiveFrameRateForRefresh(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ refreshMilliHz int
9+ fallbackFPS int32
10+ maxFPS int32
11+ want int32
12+ }{
13+ {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60},
14+ {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144},
15+ {name: "uses lower refresh monitor", refreshMilliHz: 50000, fallbackFPS: 60, maxFPS: 240, want: 50},
16+ {name: "floors very low refresh monitor", refreshMilliHz: 24000, fallbackFPS: 60, maxFPS: 240, want: 30},
17+ {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240},
18+ }
19+
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS)
23+ if got != tt.want {
24+ t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want)
25+ }
26+ })
27+ }
28+}
29+
30+func TestIsWaylandDisplayName(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ input string
34+ want bool
35+ }{
36+ {name: "exact lowercase", input: "wayland", want: true},
37+ {name: "exact uppercase", input: "WAYLAND", want: true},
38+ {name: "mixed case", input: "WayLand", want: true},
39+ {name: "numeric suffix", input: "wayland-1", want: true},
40+ {name: "punctuation suffix", input: "wayland:0", want: true},
41+ {name: "leading whitespace", input: " wayland ", want: true},
42+ {name: "substring", input: "mywayland-display", want: true},
43+ {name: "x11", input: "x11", want: false},
44+ {name: "empty", input: "", want: false},
45+ {name: "whitespace", input: " ", want: false},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ if got := isWaylandDisplayName(tt.input); got != tt.want {
50+ t.Fatalf("isWaylandDisplayName(%q) = %v, want %v", tt.input, got, tt.want)
51+ }
52+ })
53+ }
54+}
M internal/infrastructure/cef/app.go +2 -0
@@ -219,6 +219,7 @@ func appendUniqueCommaSeparatedSwitchValues(commandLine purecef.CommandLine, nam
219219 if len(combined) == 0 {
220220 return
221221 }
222+ commandLine.RemoveSwitch(name)
222223 commandLine.AppendSwitchWithValue(name, strings.Join(combined, ","))
223224 }
224225
@@ -261,6 +262,7 @@ func removeCommaSeparatedSwitchValues(commandLine purecef.CommandLine, name stri
261262 return
262263 }
263264
265+ commandLine.RemoveSwitch(name)
264266 commandLine.AppendSwitchWithValue(name, strings.Join(cleaned, ","))
265267 }
266268
@@ -219,6 +219,7 @@ func appendUniqueCommaSeparatedSwitchValues(commandLine purecef.CommandLine, nam
219 if len(combined) == 0 {219 if len(combined) == 0 {
220 return220 return
221 }221 }
222+ commandLine.RemoveSwitch(name)
222 commandLine.AppendSwitchWithValue(name, strings.Join(combined, ","))223 commandLine.AppendSwitchWithValue(name, strings.Join(combined, ","))
223 }224 }
224 225
@@ -261,6 +262,7 @@ func removeCommaSeparatedSwitchValues(commandLine purecef.CommandLine, name stri
261 return262 return
262 }263 }
263 264
265+ commandLine.RemoveSwitch(name)
264 commandLine.AppendSwitchWithValue(name, strings.Join(cleaned, ","))266 commandLine.AppendSwitchWithValue(name, strings.Join(cleaned, ","))
265 }267 }
266 268
M internal/infrastructure/cef/cef2gtk_adapter.go +29 -0
@@ -165,6 +165,21 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
165165 a.view.SetCursorFromName(name)
166166 }
167167
168+// RefreshObservedSizeOnGTKThread synchronously refreshes the bridge's cached
169+// observed size from the current GTK allocation. Must be called on the GTK
170+// main thread.
171+func (a *Cef2gtkAdapter) RefreshObservedSizeOnGTKThread() (int32, int32) {
172+ if a == nil || a.destroyed.Load() {
173+ return 1, 1
174+ }
175+ a.viewMu.RLock()
176+ defer a.viewMu.RUnlock()
177+ if a.view == nil {
178+ return 1, 1
179+ }
180+ return a.view.RefreshObservedSizeOnGTKThread()
181+}
182+
168183 // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.
169184 // Must be called on the GTK main thread.
170185 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
@@ -209,6 +224,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
209224 return a.view.ConfigureProfiling(opts)
210225 }
211226
227+// RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in
228+// the bridge profiler when profiling is enabled. It is a no-op otherwise.
229+func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() {
230+ if a == nil || a.destroyed.Load() {
231+ return
232+ }
233+ a.viewMu.RLock()
234+ defer a.viewMu.RUnlock()
235+ if a.view == nil {
236+ return
237+ }
238+ a.view.RecordExternalBeginFrameSent()
239+}
240+
212241 // AttachInput attaches GTK event controllers to the view and forwards input
213242 // to the given CEF browser host. Must be called on the GTK main thread.
214243 //
@@ -165,6 +165,21 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
165 a.view.SetCursorFromName(name)165 a.view.SetCursorFromName(name)
166 }166 }
167 167
168+// RefreshObservedSizeOnGTKThread synchronously refreshes the bridge's cached
169+// observed size from the current GTK allocation. Must be called on the GTK
170+// main thread.
171+func (a *Cef2gtkAdapter) RefreshObservedSizeOnGTKThread() (int32, int32) {
172+ if a == nil || a.destroyed.Load() {
173+ return 1, 1
174+ }
175+ a.viewMu.RLock()
176+ defer a.viewMu.RUnlock()
177+ if a.view == nil {
178+ return 1, 1
179+ }
180+ return a.view.RefreshObservedSizeOnGTKThread()
181+}
182+
168 // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.183 // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.
169 // Must be called on the GTK main thread.184 // Must be called on the GTK main thread.
170 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {185 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
@@ -209,6 +224,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
209 return a.view.ConfigureProfiling(opts)224 return a.view.ConfigureProfiling(opts)
210 }225 }
211 226
227+// RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in
228+// the bridge profiler when profiling is enabled. It is a no-op otherwise.
229+func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() {
230+ if a == nil || a.destroyed.Load() {
231+ return
232+ }
233+ a.viewMu.RLock()
234+ defer a.viewMu.RUnlock()
235+ if a.view == nil {
236+ return
237+ }
238+ a.view.RecordExternalBeginFrameSent()
239+}
240+
212 // AttachInput attaches GTK event controllers to the view and forwards input241 // AttachInput attaches GTK event controllers to the view and forwards input
213 // to the given CEF browser host. Must be called on the GTK main thread.242 // to the given CEF browser host. Must be called on the GTK main thread.
214 //243 //
M internal/infrastructure/cef/engine_init.go +8 -7
@@ -97,8 +97,12 @@ func NewEngine(
9797 return wireEngine(
9898 ctx,
9999 eng,
100- windowlessFrameRate,
101- audioFactory,
100+ webViewFactoryOptions{
101+ adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate,
102+ windowlessFrameRate: windowlessFrameRate,
103+ windowlessFrameRateMax: cfg.WindowlessFrameRateMax,
104+ audioOutputFactory: audioFactory,
105+ },
102106 logger,
103107 deps.CurrentConfigPayload,
104108 deps.DefaultConfigPayload,
@@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string {
215219 // wireEngine creates factory, pool, and scheme handler after CEF init.
216220 func wireEngine(
217221 ctx context.Context, eng *Engine,
218- windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
222+ factoryOptions webViewFactoryOptions, logger *zerolog.Logger,
219223 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
220224 ) (*Engine, error) {
221- eng.factory = newWebViewFactory(eng, webViewFactoryOptions{
222- windowlessFrameRate: windowlessFrameRate,
223- audioOutputFactory: audioFactory,
224- })
225+ eng.factory = newWebViewFactory(eng, factoryOptions)
225226 eng.pool = newWebViewPool(eng.factory)
226227 eng.contentInj = newContentInjector(eng, nil)
227228
@@ -97,8 +97,12 @@ func NewEngine(
97 return wireEngine(97 return wireEngine(
98 ctx,98 ctx,
99 eng,99 eng,
100- windowlessFrameRate,100+ webViewFactoryOptions{
101- audioFactory,101+ adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate,
102+ windowlessFrameRate: windowlessFrameRate,
103+ windowlessFrameRateMax: cfg.WindowlessFrameRateMax,
104+ audioOutputFactory: audioFactory,
105+ },
102 logger,106 logger,
103 deps.CurrentConfigPayload,107 deps.CurrentConfigPayload,
104 deps.DefaultConfigPayload,108 deps.DefaultConfigPayload,
@@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string {
215 // wireEngine creates factory, pool, and scheme handler after CEF init.219 // wireEngine creates factory, pool, and scheme handler after CEF init.
216 func wireEngine(220 func wireEngine(
217 ctx context.Context, eng *Engine,221 ctx context.Context, eng *Engine,
218- windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,222+ factoryOptions webViewFactoryOptions, logger *zerolog.Logger,
219 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),223 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
220 ) (*Engine, error) {224 ) (*Engine, error) {
221- eng.factory = newWebViewFactory(eng, webViewFactoryOptions{225+ eng.factory = newWebViewFactory(eng, factoryOptions)
222- windowlessFrameRate: windowlessFrameRate,
223- audioOutputFactory: audioFactory,
224- })
225 eng.pool = newWebViewPool(eng.factory)226 eng.pool = newWebViewPool(eng.factory)
226 eng.contentInj = newContentInjector(eng, nil)227 eng.contentInj = newContentInjector(eng, nil)
227 228
M internal/infrastructure/cef/factory.go +35 -24
@@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
2020 // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created
2121 // CEF browser (via BrowserHostCreateBrowser).
2222 type WebViewFactory struct {
23- engine *Engine
24- nextID atomic.Uint64
25- windowlessFrameRate int32
26- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27- audioOutputFactory port.AudioOutputFactory
23+ engine *Engine
24+ nextID atomic.Uint64
25+ adaptiveWindowlessFrameRate bool
26+ windowlessFrameRate int32
27+ windowlessFrameRateMax int32
28+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
29+ audioOutputFactory port.AudioOutputFactory
2830 }
2931
3032 type webViewFactoryOptions struct {
31- windowlessFrameRate int32
32- audioOutputFactory port.AudioOutputFactory
33+ adaptiveWindowlessFrameRate bool
34+ windowlessFrameRate int32
35+ windowlessFrameRateMax int32
36+ audioOutputFactory port.AudioOutputFactory
3337 }
3438
3539 type resizeNotifiableBrowserHost interface {
@@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto
4953 if opts.windowlessFrameRate < 1 {
5054 opts.windowlessFrameRate = 60
5155 }
56+ if opts.windowlessFrameRateMax < 1 {
57+ opts.windowlessFrameRateMax = 240
58+ }
5259 return &WebViewFactory{
53- engine: engine,
54- windowlessFrameRate: opts.windowlessFrameRate,
55- audioOutputFactory: opts.audioOutputFactory,
60+ engine: engine,
61+ adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate,
62+ windowlessFrameRate: opts.windowlessFrameRate,
63+ windowlessFrameRateMax: opts.windowlessFrameRateMax,
64+ audioOutputFactory: opts.audioOutputFactory,
5665 }
5766 }
5867
@@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
103112 }
104113
105114 wv := &WebView{
106- id: id,
107- ctx: ctx,
108- engine: f.engine,
109- factory: f,
110- viewBridge: viewBridge,
111- audioOutputFactory: f.audioOutputFactory,
112- windowlessFrameRate: f.windowlessFrameRate,
113- backgroundColor: f.bgColor.Load(),
115+ id: id,
116+ ctx: ctx,
117+ engine: f.engine,
118+ factory: f,
119+ viewBridge: viewBridge,
120+ audioOutputFactory: f.audioOutputFactory,
121+ adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
122+ windowlessFrameRate: f.windowlessFrameRate,
123+ windowlessFrameRateMax: f.windowlessFrameRateMax,
124+ backgroundColor: f.bgColor.Load(),
114125 }
115126
116127 handlers := &handlerSet{wv: wv}
@@ -149,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
149160 if wv.viewBridge == nil {
150161 return
151162 }
152- browserCreateScheduled := false
153163 wv.runOnGTKSync(func() {
154164 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
155- // Size observers run on the GTK thread. Prepare the GtkGLArea only after it
156- // has been packed and allocated; preparing at WebView construction time can
157- // run before GTK has created a GL context.
158- if !browserCreateScheduled {
165+ // Size observers run on the GTK thread. Prepare the GtkGLArea only for
166+ // the one-shot initial resize path; once that path has been consumed,
167+ // later size events must go through normal viewport sync even if popup
168+ // attachment keeps pendingCreate alive a little longer.
169+ if wv.shouldStartBrowserCreateFromSizeObserver() {
159170 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
160171 logging.FromContext(ctx).Warn().
161172 Err(err).
@@ -165,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
165176 Msg("cef: cef2gtk view not ready after resize, will retry")
166177 return
167178 }
168- browserCreateScheduled = true
179+ wv.markInitialBrowserCreateResizeHandled()
169180 onFirstResize(w, h)
170181 return
171182 }
@@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
20 // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created20 // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created
21 // CEF browser (via BrowserHostCreateBrowser).21 // CEF browser (via BrowserHostCreateBrowser).
22 type WebViewFactory struct {22 type WebViewFactory struct {
23- engine *Engine23+ engine *Engine
24- nextID atomic.Uint6424+ nextID atomic.Uint64
25- windowlessFrameRate int3225+ adaptiveWindowlessFrameRate bool
26- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor26+ windowlessFrameRate int32
27- audioOutputFactory port.AudioOutputFactory27+ windowlessFrameRateMax int32
28+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
29+ audioOutputFactory port.AudioOutputFactory
28 }30 }
29 31
30 type webViewFactoryOptions struct {32 type webViewFactoryOptions struct {
31- windowlessFrameRate int3233+ adaptiveWindowlessFrameRate bool
32- audioOutputFactory port.AudioOutputFactory34+ windowlessFrameRate int32
35+ windowlessFrameRateMax int32
36+ audioOutputFactory port.AudioOutputFactory
33 }37 }
34 38
35 type resizeNotifiableBrowserHost interface {39 type resizeNotifiableBrowserHost interface {
@@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto
49 if opts.windowlessFrameRate < 1 {53 if opts.windowlessFrameRate < 1 {
50 opts.windowlessFrameRate = 6054 opts.windowlessFrameRate = 60
51 }55 }
56+ if opts.windowlessFrameRateMax < 1 {
57+ opts.windowlessFrameRateMax = 240
58+ }
52 return &WebViewFactory{59 return &WebViewFactory{
53- engine: engine,60+ engine: engine,
54- windowlessFrameRate: opts.windowlessFrameRate,61+ adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate,
55- audioOutputFactory: opts.audioOutputFactory,62+ windowlessFrameRate: opts.windowlessFrameRate,
63+ windowlessFrameRateMax: opts.windowlessFrameRateMax,
64+ audioOutputFactory: opts.audioOutputFactory,
56 }65 }
57 }66 }
58 67
@@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
103 }112 }
104 113
105 wv := &WebView{114 wv := &WebView{
106- id: id,115+ id: id,
107- ctx: ctx,116+ ctx: ctx,
108- engine: f.engine,117+ engine: f.engine,
109- factory: f,118+ factory: f,
110- viewBridge: viewBridge,119+ viewBridge: viewBridge,
111- audioOutputFactory: f.audioOutputFactory,120+ audioOutputFactory: f.audioOutputFactory,
112- windowlessFrameRate: f.windowlessFrameRate,121+ adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
113- backgroundColor: f.bgColor.Load(),122+ windowlessFrameRate: f.windowlessFrameRate,
123+ windowlessFrameRateMax: f.windowlessFrameRateMax,
124+ backgroundColor: f.bgColor.Load(),
114 }125 }
115 126
116 handlers := &handlerSet{wv: wv}127 handlers := &handlerSet{wv: wv}
@@ -149,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
149 if wv.viewBridge == nil {160 if wv.viewBridge == nil {
150 return161 return
151 }162 }
152- browserCreateScheduled := false
153 wv.runOnGTKSync(func() {163 wv.runOnGTKSync(func() {
154 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {164 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
155- // Size observers run on the GTK thread. Prepare the GtkGLArea only after it165+ // Size observers run on the GTK thread. Prepare the GtkGLArea only for
156- // has been packed and allocated; preparing at WebView construction time can166+ // the one-shot initial resize path; once that path has been consumed,
157- // run before GTK has created a GL context.167+ // later size events must go through normal viewport sync even if popup
158- if !browserCreateScheduled {168+ // attachment keeps pendingCreate alive a little longer.
169+ if wv.shouldStartBrowserCreateFromSizeObserver() {
159 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {170 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
160 logging.FromContext(ctx).Warn().171 logging.FromContext(ctx).Warn().
161 Err(err).172 Err(err).
@@ -165,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
165 Msg("cef: cef2gtk view not ready after resize, will retry")176 Msg("cef: cef2gtk view not ready after resize, will retry")
166 return177 return
167 }178 }
168- browserCreateScheduled = true179+ wv.markInitialBrowserCreateResizeHandled()
169 onFirstResize(w, h)180 onFirstResize(w, h)
170 return181 return
171 }182 }
M internal/infrastructure/cef/factory_resize_test.go +16 -0
@@ -39,6 +39,22 @@ func TestConfigureWindowInfo_SetsAcceleratedWindowlessAndSharedTexture(t *testin
3939 require.Equal(t, int32(1), info.SharedTextureEnabled)
4040 }
4141
42+func TestShouldStartBrowserCreateFromSizeObserver_FalseAfterInitialResizeHandled(t *testing.T) {
43+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
44+
45+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
46+ wv.markInitialBrowserCreateResizeHandled()
47+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
48+}
49+
50+func TestShouldStartBrowserCreateFromSizeObserver_FalseAfterPendingCreateConsumed(t *testing.T) {
51+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
52+
53+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
54+ require.NotNil(t, wv.takePendingCreate())
55+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
56+}
57+
4258 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
4359 oldNewTask := cefNewTask
4460 oldPostTask := cefPostTask
@@ -39,6 +39,22 @@ func TestConfigureWindowInfo_SetsAcceleratedWindowlessAndSharedTexture(t *testin
39 require.Equal(t, int32(1), info.SharedTextureEnabled)39 require.Equal(t, int32(1), info.SharedTextureEnabled)
40 }40 }
41 41
42+func TestShouldStartBrowserCreateFromSizeObserver_FalseAfterInitialResizeHandled(t *testing.T) {
43+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
44+
45+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
46+ wv.markInitialBrowserCreateResizeHandled()
47+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
48+}
49+
50+func TestShouldStartBrowserCreateFromSizeObserver_FalseAfterPendingCreateConsumed(t *testing.T) {
51+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
52+
53+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
54+ require.NotNil(t, wv.takePendingCreate())
55+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
56+}
57+
42 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {58 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
43 oldNewTask := cefNewTask59 oldNewTask := cefNewTask
44 oldPostTask := cefPostTask60 oldPostTask := cefPostTask
M internal/infrastructure/cef/runtime_config.go +9 -7
@@ -15,13 +15,15 @@ var (
1515 )
1616
1717 type RuntimeConfig struct {
18- CEFDir string
19- LogFile string
20- LogSeverity int32
21- RenderStack string
22- WindowlessFrameRate int32
23- EnableAudioHandler bool
24- TraceHandlers bool
18+ CEFDir string
19+ LogFile string
20+ LogSeverity int32
21+ RenderStack string
22+ AdaptiveWindowlessFrameRate bool
23+ WindowlessFrameRate int32
24+ WindowlessFrameRateMax int32
25+ EnableAudioHandler bool
26+ TraceHandlers bool
2527 }
2628
2729 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
@@ -15,13 +15,15 @@ var (
15 )15 )
16 16
17 type RuntimeConfig struct {17 type RuntimeConfig struct {
18- CEFDir string18+ CEFDir string
19- LogFile string19+ LogFile string
20- LogSeverity int3220+ LogSeverity int32
21- RenderStack string21+ RenderStack string
22- WindowlessFrameRate int3222+ AdaptiveWindowlessFrameRate bool
23- EnableAudioHandler bool23+ WindowlessFrameRate int32
24- TraceHandlers bool24+ WindowlessFrameRateMax int32
25+ EnableAudioHandler bool
26+ TraceHandlers bool
25 }27 }
26 28
27 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error29 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
M internal/infrastructure/cef/webview.go +71 -13
@@ -103,6 +103,12 @@ type WebView struct {
103103 viewportRealizeSignalID uint
104104 viewportResizePulseSeq atomic.Uint64
105105
106+ adaptiveWindowlessFrameRate bool
107+ windowlessFrameRateMax int32
108+ adaptiveFrameRatePoll *glib.SourceFunc
109+ adaptiveFrameRatePollID uint
110+ lastAdaptiveFrameRate int32
111+
106112 // beginFrameTick drives CEF external BeginFrame requests while the GTK
107113 // widget is visible. Access is guarded by mu.
108114 beginFrameTick *gtk.TickCallback
@@ -110,6 +116,10 @@ type WebView struct {
110116
111117 // pendingCreate holds browser creation params until the GL area is realized.
112118 pendingCreate *pendingBrowserCreate
119+ // initialBrowserCreateResizeHandled gates the one-shot onFirstResize path so
120+ // later size observer events fall through to normal viewport sync even when
121+ // browser creation started from another GTK lifecycle path.
122+ initialBrowserCreateResizeHandled bool
113123
114124 // pendingURI is set when LoadURI is called before the browser exists.
115125 pendingURI string
@@ -181,11 +191,12 @@ type WebView struct {
181191 loadDiagLastLoadStateAt time.Time
182192
183193 // Atomic state.
184- destroyed atomic.Bool
185- fullscreen atomic.Bool
186- generation atomic.Uint64
187- audioPlaying atomic.Bool
188- zoomFactor atomic.Value // float64, initialized to 1.0
194+ destroyed atomic.Bool
195+ fullscreen atomic.Bool
196+ generation atomic.Uint64
197+ audioPlaying atomic.Bool
198+ zoomFactor atomic.Value // float64, initialized to 1.0
199+ lastAppliedOSRBackingScaleBits atomic.Uint64
189200
190201 // Browser creation defaults copied from the factory so native popup shells
191202 // can apply the same settings in OnBeforePopup.
@@ -205,11 +216,13 @@ type WebView struct {
205216 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
206217 // deferred until the GL area has a non-zero size.
207218 type pendingBrowserCreate struct {
208- windowInfo *purecef.WindowInfo
209- client purecef.RawClient
210- settings *purecef.BrowserSettings
211- extraInfo purecef.DictionaryValue
212- postTaskRetries int
219+ windowInfo *purecef.WindowInfo
220+ client purecef.RawClient
221+ settings *purecef.BrowserSettings
222+ extraInfo purecef.DictionaryValue
223+ postTaskRetries int
224+ observedSizeRetries int
225+ observedSizeRetryScheduled bool
213226 }
214227
215228 type cefTaskFunc func()
@@ -451,7 +464,7 @@ func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
451464 return factorFromCEFZoom(level) * normalizeScale(backingScale)
452465 }
453466
454-func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel float64) {
467+func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) {
455468 host.SetZoomLevel(cefLevel)
456469 // Force CEF to produce a new frame at the new zoom level. In OSR mode,
457470 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
@@ -460,6 +473,7 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel
460473 // SynchronizeVisualProperties cycle, which makes the renderer produce
461474 // a new compositor frame at the new zoom level.
462475 host.NotifyScreenInfoChanged()
476+ wv.recordAppliedOSRBackingScale(backingScale)
463477 // Zoom is applied asynchronously in the renderer process. Request a couple
464478 // of follow-up refreshes on the CEF UI thread so OSR captures the updated
465479 // compositor frame after the zoom IPC has been processed.
@@ -499,7 +513,7 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
499513 Float64("cef_level", cefLevel).
500514 Float64("osr_backing_scale", backingScale).
501515 Msg("cef: SetZoomLevel")
502- wv.applyCEFZoomLevel(host, factor, cefLevel)
516+ wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)
503517 wv.zoomFactor.Store(factor)
504518 return nil
505519 }
@@ -1093,6 +1107,7 @@ func (wv *WebView) Destroy() {
10931107 wv.stopRenderStallWatchdog()
10941108 wv.cancelSelectionDebounce()
10951109 wv.closeAudioStream()
1110+ wv.scheduleStopAdaptiveFrameRatePolling()
10961111 wv.scheduleStopBeginFrameLoop()
10971112 wv.mu.RLock()
10981113 host := wv.host
@@ -1402,6 +1417,25 @@ func (wv *WebView) osrBackingScaleFactor() float64 {
14021417 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
14031418 }
14041419
1420+func (wv *WebView) recordAppliedOSRBackingScale(scale float64) {
1421+ if wv == nil {
1422+ return
1423+ }
1424+ wv.lastAppliedOSRBackingScaleBits.Store(math.Float64bits(normalizeScale(scale)))
1425+}
1426+
1427+func (wv *WebView) shouldReapplyZoomForBackingScale(scale float64) bool {
1428+ if wv == nil {
1429+ return false
1430+ }
1431+ normalized := normalizeScale(scale)
1432+ bits := wv.lastAppliedOSRBackingScaleBits.Load()
1433+ if bits == 0 {
1434+ return true
1435+ }
1436+ return math.Float64frombits(bits) != normalized
1437+}
1438+
14051439 func (wv *WebView) handleMiddleClickFromBridge() bool {
14061440 if wv == nil {
14071441 return false
@@ -1688,7 +1722,7 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
16881722 factor := wv.GetZoomLevel()
16891723 backingScale := wv.osrBackingScaleFactor()
16901724 cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
1691- wv.applyCEFZoomLevel(host, factor, cefLevel)
1725+ wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)
16921726 logging.FromContext(wv.ctx).Debug().
16931727 Str("reason", reason).
16941728 Float64("factor", factor).
@@ -1736,6 +1770,9 @@ func (wv *WebView) startBeginFrameLoop() {
17361770 return false
17371771 }
17381772 host.SendExternalBeginFrame()
1773+ if wv.viewBridge != nil {
1774+ wv.viewBridge.RecordExternalBeginFrameSent()
1775+ }
17391776 return true
17401777 }
17411778 wv.beginFrameTick = cb
@@ -1745,6 +1782,9 @@ func (wv *WebView) startBeginFrameLoop() {
17451782
17461783 if host != nil {
17471784 host.SendExternalBeginFrame()
1785+ if wv.viewBridge != nil {
1786+ wv.viewBridge.RecordExternalBeginFrameSent()
1787+ }
17481788 }
17491789 }
17501790
@@ -1847,6 +1887,24 @@ func (wv *WebView) runNavigationCallbacks(uri string) {
18471887 })
18481888 }
18491889
1890+func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool {
1891+ if wv == nil {
1892+ return false
1893+ }
1894+ wv.mu.RLock()
1895+ defer wv.mu.RUnlock()
1896+ return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled
1897+}
1898+
1899+func (wv *WebView) markInitialBrowserCreateResizeHandled() {
1900+ if wv == nil {
1901+ return
1902+ }
1903+ wv.mu.Lock()
1904+ defer wv.mu.Unlock()
1905+ wv.initialBrowserCreateResizeHandled = true
1906+}
1907+
18501908 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
18511909 wv.mu.Lock()
18521910 defer wv.mu.Unlock()
@@ -103,6 +103,12 @@ type WebView struct {
103 viewportRealizeSignalID uint103 viewportRealizeSignalID uint
104 viewportResizePulseSeq atomic.Uint64104 viewportResizePulseSeq atomic.Uint64
105 105
106+ adaptiveWindowlessFrameRate bool
107+ windowlessFrameRateMax int32
108+ adaptiveFrameRatePoll *glib.SourceFunc
109+ adaptiveFrameRatePollID uint
110+ lastAdaptiveFrameRate int32
111+
106 // beginFrameTick drives CEF external BeginFrame requests while the GTK112 // beginFrameTick drives CEF external BeginFrame requests while the GTK
107 // widget is visible. Access is guarded by mu.113 // widget is visible. Access is guarded by mu.
108 beginFrameTick *gtk.TickCallback114 beginFrameTick *gtk.TickCallback
@@ -110,6 +116,10 @@ type WebView struct {
110 116
111 // pendingCreate holds browser creation params until the GL area is realized.117 // pendingCreate holds browser creation params until the GL area is realized.
112 pendingCreate *pendingBrowserCreate118 pendingCreate *pendingBrowserCreate
119+ // initialBrowserCreateResizeHandled gates the one-shot onFirstResize path so
120+ // later size observer events fall through to normal viewport sync even when
121+ // browser creation started from another GTK lifecycle path.
122+ initialBrowserCreateResizeHandled bool
113 123
114 // pendingURI is set when LoadURI is called before the browser exists.124 // pendingURI is set when LoadURI is called before the browser exists.
115 pendingURI string125 pendingURI string
@@ -181,11 +191,12 @@ type WebView struct {
181 loadDiagLastLoadStateAt time.Time191 loadDiagLastLoadStateAt time.Time
182 192
183 // Atomic state.193 // Atomic state.
184- destroyed atomic.Bool194+ destroyed atomic.Bool
185- fullscreen atomic.Bool195+ fullscreen atomic.Bool
186- generation atomic.Uint64196+ generation atomic.Uint64
187- audioPlaying atomic.Bool197+ audioPlaying atomic.Bool
188- zoomFactor atomic.Value // float64, initialized to 1.0198+ zoomFactor atomic.Value // float64, initialized to 1.0
199+ lastAppliedOSRBackingScaleBits atomic.Uint64
189 200
190 // Browser creation defaults copied from the factory so native popup shells201 // Browser creation defaults copied from the factory so native popup shells
191 // can apply the same settings in OnBeforePopup.202 // can apply the same settings in OnBeforePopup.
@@ -205,11 +216,13 @@ type WebView struct {
205 // pendingBrowserCreate holds the parameters needed to create a CEF browser,216 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
206 // deferred until the GL area has a non-zero size.217 // deferred until the GL area has a non-zero size.
207 type pendingBrowserCreate struct {218 type pendingBrowserCreate struct {
208- windowInfo *purecef.WindowInfo219+ windowInfo *purecef.WindowInfo
209- client purecef.RawClient220+ client purecef.RawClient
210- settings *purecef.BrowserSettings221+ settings *purecef.BrowserSettings
211- extraInfo purecef.DictionaryValue222+ extraInfo purecef.DictionaryValue
212- postTaskRetries int223+ postTaskRetries int
224+ observedSizeRetries int
225+ observedSizeRetryScheduled bool
213 }226 }
214 227
215 type cefTaskFunc func()228 type cefTaskFunc func()
@@ -451,7 +464,7 @@ func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
451 return factorFromCEFZoom(level) * normalizeScale(backingScale)464 return factorFromCEFZoom(level) * normalizeScale(backingScale)
452 }465 }
453 466
454-func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel float64) {467+func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) {
455 host.SetZoomLevel(cefLevel)468 host.SetZoomLevel(cefLevel)
456 // Force CEF to produce a new frame at the new zoom level. In OSR mode,469 // Force CEF to produce a new frame at the new zoom level. In OSR mode,
457 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a470 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
@@ -460,6 +473,7 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel
460 // SynchronizeVisualProperties cycle, which makes the renderer produce473 // SynchronizeVisualProperties cycle, which makes the renderer produce
461 // a new compositor frame at the new zoom level.474 // a new compositor frame at the new zoom level.
462 host.NotifyScreenInfoChanged()475 host.NotifyScreenInfoChanged()
476+ wv.recordAppliedOSRBackingScale(backingScale)
463 // Zoom is applied asynchronously in the renderer process. Request a couple477 // Zoom is applied asynchronously in the renderer process. Request a couple
464 // of follow-up refreshes on the CEF UI thread so OSR captures the updated478 // of follow-up refreshes on the CEF UI thread so OSR captures the updated
465 // compositor frame after the zoom IPC has been processed.479 // compositor frame after the zoom IPC has been processed.
@@ -499,7 +513,7 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
499 Float64("cef_level", cefLevel).513 Float64("cef_level", cefLevel).
500 Float64("osr_backing_scale", backingScale).514 Float64("osr_backing_scale", backingScale).
501 Msg("cef: SetZoomLevel")515 Msg("cef: SetZoomLevel")
502- wv.applyCEFZoomLevel(host, factor, cefLevel)516+ wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)
503 wv.zoomFactor.Store(factor)517 wv.zoomFactor.Store(factor)
504 return nil518 return nil
505 }519 }
@@ -1093,6 +1107,7 @@ func (wv *WebView) Destroy() {
1093 wv.stopRenderStallWatchdog()1107 wv.stopRenderStallWatchdog()
1094 wv.cancelSelectionDebounce()1108 wv.cancelSelectionDebounce()
1095 wv.closeAudioStream()1109 wv.closeAudioStream()
1110+ wv.scheduleStopAdaptiveFrameRatePolling()
1096 wv.scheduleStopBeginFrameLoop()1111 wv.scheduleStopBeginFrameLoop()
1097 wv.mu.RLock()1112 wv.mu.RLock()
1098 host := wv.host1113 host := wv.host
@@ -1402,6 +1417,25 @@ func (wv *WebView) osrBackingScaleFactor() float64 {
1402 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())1417 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
1403 }1418 }
1404 1419
1420+func (wv *WebView) recordAppliedOSRBackingScale(scale float64) {
1421+ if wv == nil {
1422+ return
1423+ }
1424+ wv.lastAppliedOSRBackingScaleBits.Store(math.Float64bits(normalizeScale(scale)))
1425+}
1426+
1427+func (wv *WebView) shouldReapplyZoomForBackingScale(scale float64) bool {
1428+ if wv == nil {
1429+ return false
1430+ }
1431+ normalized := normalizeScale(scale)
1432+ bits := wv.lastAppliedOSRBackingScaleBits.Load()
1433+ if bits == 0 {
1434+ return true
1435+ }
1436+ return math.Float64frombits(bits) != normalized
1437+}
1438+
1405 func (wv *WebView) handleMiddleClickFromBridge() bool {1439 func (wv *WebView) handleMiddleClickFromBridge() bool {
1406 if wv == nil {1440 if wv == nil {
1407 return false1441 return false
@@ -1688,7 +1722,7 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
1688 factor := wv.GetZoomLevel()1722 factor := wv.GetZoomLevel()
1689 backingScale := wv.osrBackingScaleFactor()1723 backingScale := wv.osrBackingScaleFactor()
1690 cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)1724 cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
1691- wv.applyCEFZoomLevel(host, factor, cefLevel)1725+ wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale)
1692 logging.FromContext(wv.ctx).Debug().1726 logging.FromContext(wv.ctx).Debug().
1693 Str("reason", reason).1727 Str("reason", reason).
1694 Float64("factor", factor).1728 Float64("factor", factor).
@@ -1736,6 +1770,9 @@ func (wv *WebView) startBeginFrameLoop() {
1736 return false1770 return false
1737 }1771 }
1738 host.SendExternalBeginFrame()1772 host.SendExternalBeginFrame()
1773+ if wv.viewBridge != nil {
1774+ wv.viewBridge.RecordExternalBeginFrameSent()
1775+ }
1739 return true1776 return true
1740 }1777 }
1741 wv.beginFrameTick = cb1778 wv.beginFrameTick = cb
@@ -1745,6 +1782,9 @@ func (wv *WebView) startBeginFrameLoop() {
1745 1782
1746 if host != nil {1783 if host != nil {
1747 host.SendExternalBeginFrame()1784 host.SendExternalBeginFrame()
1785+ if wv.viewBridge != nil {
1786+ wv.viewBridge.RecordExternalBeginFrameSent()
1787+ }
1748 }1788 }
1749 }1789 }
1750 1790
@@ -1847,6 +1887,24 @@ func (wv *WebView) runNavigationCallbacks(uri string) {
1847 })1887 })
1848 }1888 }
1849 1889
1890+func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool {
1891+ if wv == nil {
1892+ return false
1893+ }
1894+ wv.mu.RLock()
1895+ defer wv.mu.RUnlock()
1896+ return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled
1897+}
1898+
1899+func (wv *WebView) markInitialBrowserCreateResizeHandled() {
1900+ if wv == nil {
1901+ return
1902+ }
1903+ wv.mu.Lock()
1904+ defer wv.mu.Unlock()
1905+ wv.initialBrowserCreateResizeHandled = true
1906+}
1907+
1850 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {1908 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
1851 wv.mu.Lock()1909 wv.mu.Lock()
1852 defer wv.mu.Unlock()1910 defer wv.mu.Unlock()
M internal/infrastructure/cef/webview_handlers.go +9 -0
@@ -617,6 +617,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
617617
618618 h.wv.browser = browser
619619 h.wv.host = host
620+ h.wv.lastAppliedOSRBackingScaleBits.Store(0)
620621 h.wv.pendingCreate = nil
621622 bridge := h.wv.viewBridge
622623 h.wv.inputAttached = bridge == nil
@@ -667,6 +668,11 @@ func (h *handlerSet) attachAfterCreatedBrowser(
667668 // painting/caret updates until explicitly told the browser is shown.
668669 host.WasHidden(0)
669670 if h.wv != nil {
671+ if h.wv.ctx != nil {
672+ logging.FromContext(h.wv.ctx).Info().
673+ Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
674+ Msg("cef: browser initial/configured windowless frame rate")
675+ }
670676 h.wv.SyncViewport(h.wv.ctx, "after-created")
671677 }
672678 return state
@@ -709,6 +715,7 @@ func (h *handlerSet) finishAfterCreated(
709715 if state.nativePopupParent != nil && state.nativePopupID != 0 {
710716 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
711717 }
718+ h.wv.scheduleStartAdaptiveFrameRatePolling()
712719 h.wv.scheduleStartBeginFrameLoop()
713720 h.wv.fireReadyToShow()
714721 }
@@ -739,6 +746,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
739746 h.wv.mu.Lock()
740747 h.wv.browser = nil
741748 h.wv.host = nil
749+ h.wv.lastAppliedOSRBackingScaleBits.Store(0)
742750 h.wv.inputAttached = false
743751 if h.wv.findCtrl != nil {
744752 h.wv.findCtrl.setHost(nil)
@@ -757,6 +765,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
757765 }
758766 })
759767 }
768+ h.wv.scheduleStopAdaptiveFrameRatePolling()
760769 h.wv.scheduleStopBeginFrameLoop()
761770
762771 h.wv.mu.RLock()
@@ -617,6 +617,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
617 617
618 h.wv.browser = browser618 h.wv.browser = browser
619 h.wv.host = host619 h.wv.host = host
620+ h.wv.lastAppliedOSRBackingScaleBits.Store(0)
620 h.wv.pendingCreate = nil621 h.wv.pendingCreate = nil
621 bridge := h.wv.viewBridge622 bridge := h.wv.viewBridge
622 h.wv.inputAttached = bridge == nil623 h.wv.inputAttached = bridge == nil
@@ -667,6 +668,11 @@ func (h *handlerSet) attachAfterCreatedBrowser(
667 // painting/caret updates until explicitly told the browser is shown.668 // painting/caret updates until explicitly told the browser is shown.
668 host.WasHidden(0)669 host.WasHidden(0)
669 if h.wv != nil {670 if h.wv != nil {
671+ if h.wv.ctx != nil {
672+ logging.FromContext(h.wv.ctx).Info().
673+ Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
674+ Msg("cef: browser initial/configured windowless frame rate")
675+ }
670 h.wv.SyncViewport(h.wv.ctx, "after-created")676 h.wv.SyncViewport(h.wv.ctx, "after-created")
671 }677 }
672 return state678 return state
@@ -709,6 +715,7 @@ func (h *handlerSet) finishAfterCreated(
709 if state.nativePopupParent != nil && state.nativePopupID != 0 {715 if state.nativePopupParent != nil && state.nativePopupID != 0 {
710 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)716 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
711 }717 }
718+ h.wv.scheduleStartAdaptiveFrameRatePolling()
712 h.wv.scheduleStartBeginFrameLoop()719 h.wv.scheduleStartBeginFrameLoop()
713 h.wv.fireReadyToShow()720 h.wv.fireReadyToShow()
714 }721 }
@@ -739,6 +746,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
739 h.wv.mu.Lock()746 h.wv.mu.Lock()
740 h.wv.browser = nil747 h.wv.browser = nil
741 h.wv.host = nil748 h.wv.host = nil
749+ h.wv.lastAppliedOSRBackingScaleBits.Store(0)
742 h.wv.inputAttached = false750 h.wv.inputAttached = false
743 if h.wv.findCtrl != nil {751 if h.wv.findCtrl != nil {
744 h.wv.findCtrl.setHost(nil)752 h.wv.findCtrl.setHost(nil)
@@ -757,6 +765,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
757 }765 }
758 })766 })
759 }767 }
768+ h.wv.scheduleStopAdaptiveFrameRatePolling()
760 h.wv.scheduleStopBeginFrameLoop()769 h.wv.scheduleStopBeginFrameLoop()
761 770
762 h.wv.mu.RLock()771 h.wv.mu.RLock()
M internal/infrastructure/cef/webview_viewport.go +228 -1
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "strings"
6+ "time"
67
78 purecef "github.com/bnema/purego-cef/cef"
89 "github.com/bnema/puregotk/v4/gobject"
@@ -17,6 +18,29 @@ type viewportSyncBrowserHost interface {
1718 WasHidden(int32)
1819 }
1920
21+type pendingBrowserCreateObservedSizeBridge interface {
22+ Size() (int32, int32)
23+ RefreshObservedSizeOnGTKThread() (int32, int32)
24+}
25+
26+const (
27+ // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting
28+ // for the GTK bridge to observe the widget's allocated size.
29+ pendingBrowserCreateObservedSizeRetryDelay = 16 * time.Millisecond
30+ // pendingBrowserCreateObservedSizeMaxRetries caps observed-size retries
31+ // before browser creation proceeds despite a remaining size mismatch.
32+ pendingBrowserCreateObservedSizeMaxRetries = 8
33+)
34+
35+type pendingBrowserCreateObservedSizeRetryAction uint8
36+
37+const (
38+ pendingBrowserCreateObservedSizeRetryUnavailable pendingBrowserCreateObservedSizeRetryAction = iota
39+ pendingBrowserCreateObservedSizeRetryAlreadyScheduled
40+ pendingBrowserCreateObservedSizeRetryScheduled
41+ pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
42+)
43+
2044 // SyncViewport requests an explicit viewport resync for CEF OSR browsers.
2145 //
2246 // This is a hardening path for UI lifecycle transitions that may not emit a
@@ -102,6 +126,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool
102126 widget := wv.viewBridge.Widget()
103127 visible := widget != nil && widget.IsVisible()
104128 wv.notifyViewportSyncOnCEFUIThread(host, visible)
129+ wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)
105130 logging.FromContext(ctx).Debug().
106131 Uint64("webview_id", uint64(wv.id)).
107132 Bool("visible", visible).
@@ -182,6 +207,64 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
182207 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
183208 return false
184209 }
210+
211+ observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height)
212+ if !observedReady {
213+ widget.QueueAllocate()
214+ widget.QueueResize()
215+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason)
216+ switch action {
217+ case pendingBrowserCreateObservedSizeRetryUnavailable:
218+ return false
219+ case pendingBrowserCreateObservedSizeRetryAlreadyScheduled:
220+ logging.FromContext(ctx).Debug().
221+ Uint64("webview_id", uint64(wv.id)).
222+ Int32("observed_width", observedWidth).
223+ Int32("observed_height", observedHeight).
224+ Int32("width", width).
225+ Int32("height", height).
226+ Int("attempt", attempt).
227+ Str("reason", reason).
228+ Msg("cef: pending browser creation retry already scheduled while awaiting bridge size")
229+ return false
230+ case pendingBrowserCreateObservedSizeRetryScheduled:
231+ logging.FromContext(ctx).Debug().
232+ Uint64("webview_id", uint64(wv.id)).
233+ Int32("observed_width", observedWidth).
234+ Int32("observed_height", observedHeight).
235+ Int32("width", width).
236+ Int32("height", height).
237+ Int("attempt", attempt).
238+ Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries).
239+ Str("reason", reason).
240+ Msg("cef: delaying pending browser creation until bridge observes widget size")
241+ return false
242+ case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay:
243+ // Creating the browser here is intentional: after bounded retries we
244+ // fall back to the current GTK allocation rather than stalling popup
245+ // creation.
246+ logging.FromContext(ctx).Debug().
247+ Uint64("webview_id", uint64(wv.id)).
248+ Int32("observed_width", observedWidth).
249+ Int32("observed_height", observedHeight).
250+ Int32("width", width).
251+ Int32("height", height).
252+ Int("attempt", attempt).
253+ Str("reason", reason).
254+ Msg("cef: bridge size observation did not settle before browser creation; continuing")
255+ }
256+ }
257+
258+ if wv.shouldDeferPendingBrowserCreateFromViewportSync() {
259+ logging.FromContext(ctx).Debug().
260+ Uint64("webview_id", uint64(wv.id)).
261+ Int32("width", width).
262+ Int32("height", height).
263+ Str("reason", reason).
264+ Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach")
265+ return false
266+ }
267+
185268 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
186269 logging.FromContext(ctx).Debug().
187270 Err(err).
@@ -193,6 +276,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
193276 return false
194277 }
195278
279+ wv.markInitialBrowserCreateResizeHandled()
196280 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
197281 logging.FromContext(ctx).Debug().
198282 Uint64("webview_id", uint64(wv.id)).
@@ -203,6 +287,121 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
203287 return true
204288 }
205289
290+func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool {
291+ return wv != nil && wv.awaitsNativePopupAttachment()
292+}
293+
294+func pendingBrowserCreateObservedSize(
295+ bridge pendingBrowserCreateObservedSizeBridge,
296+ allocatedWidth, allocatedHeight int32,
297+) (observedWidth, observedHeight int32, ready bool) {
298+ if bridge == nil {
299+ return 0, 0, false
300+ }
301+ observedWidth, observedHeight = bridge.Size()
302+ if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) {
303+ return observedWidth, observedHeight, true
304+ }
305+ observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread()
306+ return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight)
307+}
308+
309+// observedViewportSizeReady reports whether observedWidth/observedHeight match
310+// allocatedWidth/allocatedHeight within a ±1px tolerance.
311+//
312+// Fallback observed sizes (<=1) and non-positive allocated dimensions are
313+// treated as not ready.
314+func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool {
315+ if allocatedWidth <= 0 || allocatedHeight <= 0 {
316+ return false
317+ }
318+ if observedWidth <= 1 || observedHeight <= 1 {
319+ return false
320+ }
321+ return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1
322+}
323+
324+// abs32 returns the absolute value of v.
325+func abs32(v int32) int32 {
326+ if v < 0 {
327+ return -v
328+ }
329+ return v
330+}
331+
332+// preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry
333+// state machine for a pending browser create.
334+//
335+// It returns the current attempt count and the resulting action. Scheduling is
336+// coalesced so only one delayed retry is pending at a time, while the max-retry
337+// path proceeds without leaving the scheduled flag set.
338+func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry(
339+ ctx context.Context,
340+ reason string,
341+) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) {
342+ if wv == nil {
343+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
344+ }
345+
346+ shouldSchedule := false
347+ wv.mu.Lock()
348+ if wv.pendingCreate == nil {
349+ wv.mu.Unlock()
350+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
351+ }
352+ if wv.pendingCreate.observedSizeRetryScheduled {
353+ attempt = wv.pendingCreate.observedSizeRetries
354+ wv.mu.Unlock()
355+ return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled
356+ }
357+ wv.pendingCreate.observedSizeRetries++
358+ attempt = wv.pendingCreate.observedSizeRetries
359+ if attempt <= pendingBrowserCreateObservedSizeMaxRetries {
360+ wv.pendingCreate.observedSizeRetryScheduled = true
361+ shouldSchedule = true
362+ }
363+ wv.mu.Unlock()
364+
365+ if !shouldSchedule {
366+ return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
367+ }
368+ wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason)
369+ return attempt, pendingBrowserCreateObservedSizeRetryScheduled
370+}
371+
372+// clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag
373+// so a future observed-size reservation can schedule another attempt.
374+func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() {
375+ if wv == nil {
376+ return
377+ }
378+ wv.mu.Lock()
379+ defer wv.mu.Unlock()
380+ if wv.pendingCreate != nil {
381+ wv.pendingCreate.observedSizeRetryScheduled = false
382+ }
383+}
384+
385+// schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport
386+// sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay.
387+//
388+// It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a
389+// retry-specific suffix appended to reason.
390+func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) {
391+ if wv == nil || wv.destroyed.Load() {
392+ return
393+ }
394+ cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() {
395+ if wv == nil || wv.destroyed.Load() {
396+ return
397+ }
398+ wv.clearPendingBrowserCreateObservedSizeRetry()
399+ wv.runOnGTK(func() {
400+ wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size")
401+ })
402+ })
403+}
404+
206405 func (wv *WebView) installViewportSyncHooks() {
207406 if wv == nil || wv.viewBridge == nil {
208407 return
@@ -279,7 +478,35 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost,
279478 return
280479 }
281480 notifyBrowserViewportSync(host, visible)
282- wv.reapplyCurrentZoomForBackingScale("viewport-sync")
481+ }))
482+ if task == nil {
483+ return
484+ }
485+ cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
486+}
487+
488+func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost, reason string) {
489+ if host == nil || wv == nil {
490+ return
491+ }
492+ if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) {
493+ return
494+ }
495+ task := cefNewTask(cefTaskFunc(func() {
496+ if wv == nil || wv.destroyed.Load() {
497+ return
498+ }
499+ wv.mu.RLock()
500+ currentHost := wv.host
501+ wv.mu.RUnlock()
502+ if currentHost != host {
503+ return
504+ }
505+ backingScale := wv.osrBackingScaleFactor()
506+ if !wv.shouldReapplyZoomForBackingScale(backingScale) {
507+ return
508+ }
509+ wv.reapplyCurrentZoomForBackingScale(reason)
283510 }))
284511 if task == nil {
285512 return
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "strings"5 "strings"
6+ "time"
6 7
7 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
8 "github.com/bnema/puregotk/v4/gobject"9 "github.com/bnema/puregotk/v4/gobject"
@@ -17,6 +18,29 @@ type viewportSyncBrowserHost interface {
17 WasHidden(int32)18 WasHidden(int32)
18 }19 }
19 20
21+type pendingBrowserCreateObservedSizeBridge interface {
22+ Size() (int32, int32)
23+ RefreshObservedSizeOnGTKThread() (int32, int32)
24+}
25+
26+const (
27+ // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting
28+ // for the GTK bridge to observe the widget's allocated size.
29+ pendingBrowserCreateObservedSizeRetryDelay = 16 * time.Millisecond
30+ // pendingBrowserCreateObservedSizeMaxRetries caps observed-size retries
31+ // before browser creation proceeds despite a remaining size mismatch.
32+ pendingBrowserCreateObservedSizeMaxRetries = 8
33+)
34+
35+type pendingBrowserCreateObservedSizeRetryAction uint8
36+
37+const (
38+ pendingBrowserCreateObservedSizeRetryUnavailable pendingBrowserCreateObservedSizeRetryAction = iota
39+ pendingBrowserCreateObservedSizeRetryAlreadyScheduled
40+ pendingBrowserCreateObservedSizeRetryScheduled
41+ pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
42+)
43+
20 // SyncViewport requests an explicit viewport resync for CEF OSR browsers.44 // SyncViewport requests an explicit viewport resync for CEF OSR browsers.
21 //45 //
22 // This is a hardening path for UI lifecycle transitions that may not emit a46 // This is a hardening path for UI lifecycle transitions that may not emit a
@@ -102,6 +126,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool
102 widget := wv.viewBridge.Widget()126 widget := wv.viewBridge.Widget()
103 visible := widget != nil && widget.IsVisible()127 visible := widget != nil && widget.IsVisible()
104 wv.notifyViewportSyncOnCEFUIThread(host, visible)128 wv.notifyViewportSyncOnCEFUIThread(host, visible)
129+ wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)
105 logging.FromContext(ctx).Debug().130 logging.FromContext(ctx).Debug().
106 Uint64("webview_id", uint64(wv.id)).131 Uint64("webview_id", uint64(wv.id)).
107 Bool("visible", visible).132 Bool("visible", visible).
@@ -182,6 +207,64 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
182 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")207 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
183 return false208 return false
184 }209 }
210+
211+ observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height)
212+ if !observedReady {
213+ widget.QueueAllocate()
214+ widget.QueueResize()
215+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason)
216+ switch action {
217+ case pendingBrowserCreateObservedSizeRetryUnavailable:
218+ return false
219+ case pendingBrowserCreateObservedSizeRetryAlreadyScheduled:
220+ logging.FromContext(ctx).Debug().
221+ Uint64("webview_id", uint64(wv.id)).
222+ Int32("observed_width", observedWidth).
223+ Int32("observed_height", observedHeight).
224+ Int32("width", width).
225+ Int32("height", height).
226+ Int("attempt", attempt).
227+ Str("reason", reason).
228+ Msg("cef: pending browser creation retry already scheduled while awaiting bridge size")
229+ return false
230+ case pendingBrowserCreateObservedSizeRetryScheduled:
231+ logging.FromContext(ctx).Debug().
232+ Uint64("webview_id", uint64(wv.id)).
233+ Int32("observed_width", observedWidth).
234+ Int32("observed_height", observedHeight).
235+ Int32("width", width).
236+ Int32("height", height).
237+ Int("attempt", attempt).
238+ Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries).
239+ Str("reason", reason).
240+ Msg("cef: delaying pending browser creation until bridge observes widget size")
241+ return false
242+ case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay:
243+ // Creating the browser here is intentional: after bounded retries we
244+ // fall back to the current GTK allocation rather than stalling popup
245+ // creation.
246+ logging.FromContext(ctx).Debug().
247+ Uint64("webview_id", uint64(wv.id)).
248+ Int32("observed_width", observedWidth).
249+ Int32("observed_height", observedHeight).
250+ Int32("width", width).
251+ Int32("height", height).
252+ Int("attempt", attempt).
253+ Str("reason", reason).
254+ Msg("cef: bridge size observation did not settle before browser creation; continuing")
255+ }
256+ }
257+
258+ if wv.shouldDeferPendingBrowserCreateFromViewportSync() {
259+ logging.FromContext(ctx).Debug().
260+ Uint64("webview_id", uint64(wv.id)).
261+ Int32("width", width).
262+ Int32("height", height).
263+ Str("reason", reason).
264+ Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach")
265+ return false
266+ }
267+
185 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {268 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
186 logging.FromContext(ctx).Debug().269 logging.FromContext(ctx).Debug().
187 Err(err).270 Err(err).
@@ -193,6 +276,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
193 return false276 return false
194 }277 }
195 278
279+ wv.markInitialBrowserCreateResizeHandled()
196 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)280 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
197 logging.FromContext(ctx).Debug().281 logging.FromContext(ctx).Debug().
198 Uint64("webview_id", uint64(wv.id)).282 Uint64("webview_id", uint64(wv.id)).
@@ -203,6 +287,121 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
203 return true287 return true
204 }288 }
205 289
290+func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool {
291+ return wv != nil && wv.awaitsNativePopupAttachment()
292+}
293+
294+func pendingBrowserCreateObservedSize(
295+ bridge pendingBrowserCreateObservedSizeBridge,
296+ allocatedWidth, allocatedHeight int32,
297+) (observedWidth, observedHeight int32, ready bool) {
298+ if bridge == nil {
299+ return 0, 0, false
300+ }
301+ observedWidth, observedHeight = bridge.Size()
302+ if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) {
303+ return observedWidth, observedHeight, true
304+ }
305+ observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread()
306+ return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight)
307+}
308+
309+// observedViewportSizeReady reports whether observedWidth/observedHeight match
310+// allocatedWidth/allocatedHeight within a ±1px tolerance.
311+//
312+// Fallback observed sizes (<=1) and non-positive allocated dimensions are
313+// treated as not ready.
314+func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool {
315+ if allocatedWidth <= 0 || allocatedHeight <= 0 {
316+ return false
317+ }
318+ if observedWidth <= 1 || observedHeight <= 1 {
319+ return false
320+ }
321+ return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1
322+}
323+
324+// abs32 returns the absolute value of v.
325+func abs32(v int32) int32 {
326+ if v < 0 {
327+ return -v
328+ }
329+ return v
330+}
331+
332+// preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry
333+// state machine for a pending browser create.
334+//
335+// It returns the current attempt count and the resulting action. Scheduling is
336+// coalesced so only one delayed retry is pending at a time, while the max-retry
337+// path proceeds without leaving the scheduled flag set.
338+func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry(
339+ ctx context.Context,
340+ reason string,
341+) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) {
342+ if wv == nil {
343+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
344+ }
345+
346+ shouldSchedule := false
347+ wv.mu.Lock()
348+ if wv.pendingCreate == nil {
349+ wv.mu.Unlock()
350+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
351+ }
352+ if wv.pendingCreate.observedSizeRetryScheduled {
353+ attempt = wv.pendingCreate.observedSizeRetries
354+ wv.mu.Unlock()
355+ return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled
356+ }
357+ wv.pendingCreate.observedSizeRetries++
358+ attempt = wv.pendingCreate.observedSizeRetries
359+ if attempt <= pendingBrowserCreateObservedSizeMaxRetries {
360+ wv.pendingCreate.observedSizeRetryScheduled = true
361+ shouldSchedule = true
362+ }
363+ wv.mu.Unlock()
364+
365+ if !shouldSchedule {
366+ return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
367+ }
368+ wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason)
369+ return attempt, pendingBrowserCreateObservedSizeRetryScheduled
370+}
371+
372+// clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag
373+// so a future observed-size reservation can schedule another attempt.
374+func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() {
375+ if wv == nil {
376+ return
377+ }
378+ wv.mu.Lock()
379+ defer wv.mu.Unlock()
380+ if wv.pendingCreate != nil {
381+ wv.pendingCreate.observedSizeRetryScheduled = false
382+ }
383+}
384+
385+// schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport
386+// sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay.
387+//
388+// It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a
389+// retry-specific suffix appended to reason.
390+func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) {
391+ if wv == nil || wv.destroyed.Load() {
392+ return
393+ }
394+ cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() {
395+ if wv == nil || wv.destroyed.Load() {
396+ return
397+ }
398+ wv.clearPendingBrowserCreateObservedSizeRetry()
399+ wv.runOnGTK(func() {
400+ wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size")
401+ })
402+ })
403+}
404+
206 func (wv *WebView) installViewportSyncHooks() {405 func (wv *WebView) installViewportSyncHooks() {
207 if wv == nil || wv.viewBridge == nil {406 if wv == nil || wv.viewBridge == nil {
208 return407 return
@@ -279,7 +478,35 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost,
279 return478 return
280 }479 }
281 notifyBrowserViewportSync(host, visible)480 notifyBrowserViewportSync(host, visible)
282- wv.reapplyCurrentZoomForBackingScale("viewport-sync")481+ }))
482+ if task == nil {
483+ return
484+ }
485+ cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
486+}
487+
488+func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost, reason string) {
489+ if host == nil || wv == nil {
490+ return
491+ }
492+ if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) {
493+ return
494+ }
495+ task := cefNewTask(cefTaskFunc(func() {
496+ if wv == nil || wv.destroyed.Load() {
497+ return
498+ }
499+ wv.mu.RLock()
500+ currentHost := wv.host
501+ wv.mu.RUnlock()
502+ if currentHost != host {
503+ return
504+ }
505+ backingScale := wv.osrBackingScaleFactor()
506+ if !wv.shouldReapplyZoomForBackingScale(backingScale) {
507+ return
508+ }
509+ wv.reapplyCurrentZoomForBackingScale(reason)
283 }))510 }))
284 if task == nil {511 if task == nil {
285 return512 return
M internal/infrastructure/cef/webview_viewport_test.go +267 -1
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "testing"
6+ "time"
67
78 purecef "github.com/bnema/purego-cef/cef"
89 "github.com/stretchr/testify/require"
@@ -14,6 +15,27 @@ type viewportSyncOrderHost struct {
1415 zoomLevel float64
1516 }
1617
18+type pendingBrowserCreateObservedSizeBridgeStub struct {
19+ sizeWidth int32
20+ sizeHeight int32
21+ refreshWidth int32
22+ refreshHeight int32
23+ refreshCalls int
24+ refreshProvided bool
25+}
26+
27+func (s *pendingBrowserCreateObservedSizeBridgeStub) Size() (int32, int32) {
28+ return s.sizeWidth, s.sizeHeight
29+}
30+
31+func (s *pendingBrowserCreateObservedSizeBridgeStub) RefreshObservedSizeOnGTKThread() (int32, int32) {
32+ s.refreshCalls++
33+ if s.refreshProvided {
34+ return s.refreshWidth, s.refreshHeight
35+ }
36+ return s.sizeWidth, s.sizeHeight
37+}
38+
1739 func (h *viewportSyncOrderHost) WasHidden(state int32) {
1840 h.calls = append(h.calls, "WasHidden")
1941 if state != 0 {
@@ -58,7 +80,7 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) {
5880 require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
5981 }
6082
61-func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) {
83+func TestNotifyViewportSyncOnCEFUIThread_PostsViewportWorkOnly(t *testing.T) {
6284 oldNewTask := cefNewTask
6385 oldPostDelayedTask := cefPostDelayedTask
6486 defer func() {
@@ -91,11 +113,116 @@ func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) {
91113 "NotifyScreenInfoChanged",
92114 "WasResized",
93115 "Invalidate",
116+ }, host.calls)
117+}
118+
119+func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResizes(t *testing.T) {
120+ oldNewTask := cefNewTask
121+ oldPostDelayedTask := cefPostDelayedTask
122+ defer func() {
123+ cefNewTask = oldNewTask
124+ cefPostDelayedTask = oldPostDelayedTask
125+ }()
126+
127+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
128+
129+ var scheduled purecef.Task
130+ cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
131+ if delayMs == 0 && scheduled == nil {
132+ scheduled = task
133+ }
134+ return 1
135+ }
136+
137+ host := &viewportSyncOrderHost{}
138+ wv := &WebView{ctx: context.Background(), host: host}
139+
140+ wv.notifyViewportSyncOnCEFUIThread(host, true)
141+ require.NotNil(t, scheduled)
142+ scheduled.Execute()
143+
144+ host.calls = nil
145+ scheduled = nil
146+
147+ wv.notifyViewportSyncOnCEFUIThread(host, true)
148+ require.NotNil(t, scheduled)
149+ scheduled.Execute()
150+
151+ require.Equal(t, []string{
152+ "WasHidden",
153+ "NotifyScreenInfoChanged",
154+ "WasResized",
155+ "Invalidate",
156+ }, host.calls)
157+}
158+
159+func TestSyncZoomForBackingScaleOnCEFUIThread_PostsZoomWork(t *testing.T) {
160+ oldNewTask := cefNewTask
161+ oldPostDelayedTask := cefPostDelayedTask
162+ defer func() {
163+ cefNewTask = oldNewTask
164+ cefPostDelayedTask = oldPostDelayedTask
165+ }()
166+
167+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
168+
169+ var scheduled purecef.Task
170+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
171+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
172+ require.NotNil(t, task)
173+ if delayMs == 0 && scheduled == nil {
174+ scheduled = task
175+ }
176+ return 1
177+ }
178+
179+ host := &viewportSyncOrderHost{}
180+ wv := &WebView{ctx: context.Background(), host: host}
181+ wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
182+
183+ require.Empty(t, host.calls)
184+ require.NotNil(t, scheduled)
185+
186+ scheduled.Execute()
187+ require.Equal(t, []string{
94188 "SetZoomLevel",
95189 "NotifyScreenInfoChanged",
96190 }, host.calls)
97191 }
98192
193+func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingScaleUnchanged(t *testing.T) {
194+ oldNewTask := cefNewTask
195+ oldPostDelayedTask := cefPostDelayedTask
196+ defer func() {
197+ cefNewTask = oldNewTask
198+ cefPostDelayedTask = oldPostDelayedTask
199+ }()
200+
201+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
202+
203+ var scheduled purecef.Task
204+ cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
205+ if delayMs == 0 && scheduled == nil {
206+ scheduled = task
207+ }
208+ return 1
209+ }
210+
211+ host := &viewportSyncOrderHost{}
212+ wv := &WebView{ctx: context.Background(), host: host}
213+
214+ wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
215+ require.NotNil(t, scheduled)
216+ scheduled.Execute()
217+
218+ host.calls = nil
219+ scheduled = nil
220+
221+ wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
222+ require.Nil(t, scheduled)
223+ require.Empty(t, host.calls)
224+}
225+
99226 func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) {
100227 oldNewTask := cefNewTask
101228 oldPostDelayedTask := cefPostDelayedTask
@@ -149,6 +276,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) {
149276 require.Empty(t, host.calls)
150277 }
151278
279+func TestObservedViewportSizeReady(t *testing.T) {
280+ tests := []struct {
281+ name string
282+ observedWidth int32
283+ observedHeight int32
284+ allocatedWidth int32
285+ allocatedHeight int32
286+ want bool
287+ }{
288+ {
289+ name: "exact match",
290+ observedWidth: 793,
291+ observedHeight: 1753,
292+ allocatedWidth: 793,
293+ allocatedHeight: 1753,
294+ want: true,
295+ },
296+ {
297+ name: "one pixel drift is accepted",
298+ observedWidth: 792,
299+ observedHeight: 1754,
300+ allocatedWidth: 793,
301+ allocatedHeight: 1753,
302+ want: true,
303+ },
304+ {
305+ name: "fallback observed size is rejected",
306+ observedWidth: 1,
307+ observedHeight: 1,
308+ allocatedWidth: 793,
309+ allocatedHeight: 1753,
310+ want: false,
311+ },
312+ {
313+ name: "missing allocation is rejected",
314+ observedWidth: 793,
315+ observedHeight: 1753,
316+ allocatedWidth: 0,
317+ allocatedHeight: 1753,
318+ want: false,
319+ },
320+ {
321+ name: "large mismatch is rejected",
322+ observedWidth: 1200,
323+ observedHeight: 1400,
324+ allocatedWidth: 1587,
325+ allocatedHeight: 1679,
326+ want: false,
327+ },
328+ }
329+
330+ for _, tt := range tests {
331+ t.Run(tt.name, func(t *testing.T) {
332+ require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight))
333+ })
334+ }
335+}
336+
337+func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) {
338+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{
339+ sizeWidth: 1,
340+ sizeHeight: 1,
341+ refreshWidth: 1055,
342+ refreshHeight: 1719,
343+ refreshProvided: true,
344+ }
345+
346+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
347+
348+ require.True(t, ready)
349+ require.Equal(t, int32(1055), observedWidth)
350+ require.Equal(t, int32(1719), observedHeight)
351+ require.Equal(t, 1, bridge.refreshCalls)
352+}
353+
354+func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) {
355+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719}
356+
357+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
358+
359+ require.True(t, ready)
360+ require.Equal(t, int32(1055), observedWidth)
361+ require.Equal(t, int32(1719), observedHeight)
362+ require.Equal(t, 0, bridge.refreshCalls)
363+}
364+
365+func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) {
366+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
367+
368+ require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
369+
370+ wv.nativePopupFallbackStarted = true
371+ require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
372+}
373+
374+func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) {
375+ oldScheduleAfter := cefScheduleAfter
376+ defer func() { cefScheduleAfter = oldScheduleAfter }()
377+
378+ scheduleCalls := 0
379+ cefScheduleAfter = func(delay time.Duration, fn func()) {
380+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
381+ require.NotNil(t, fn)
382+ scheduleCalls++
383+ }
384+
385+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
386+
387+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
388+ require.Equal(t, 1, attempt)
389+ require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action)
390+ require.Equal(t, 1, scheduleCalls)
391+
392+ attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
393+ require.Equal(t, 1, attempt)
394+ require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action)
395+ require.Equal(t, 1, scheduleCalls)
396+}
397+
398+func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) {
399+ oldScheduleAfter := cefScheduleAfter
400+ defer func() { cefScheduleAfter = oldScheduleAfter }()
401+
402+ scheduleCalls := 0
403+ cefScheduleAfter = func(delay time.Duration, fn func()) {
404+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
405+ require.NotNil(t, fn)
406+ scheduleCalls++
407+ }
408+
409+ wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}}
410+
411+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
412+ require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt)
413+ require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action)
414+ require.Equal(t, 0, scheduleCalls)
415+ require.False(t, wv.pendingCreate.observedSizeRetryScheduled)
416+}
417+
152418 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
153419 oldNewTask := cefNewTask
154420 oldPostDelayedTask := cefPostDelayedTask
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "testing"5 "testing"
6+ "time"
6 7
7 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
@@ -14,6 +15,27 @@ type viewportSyncOrderHost struct {
14 zoomLevel float6415 zoomLevel float64
15 }16 }
16 17
18+type pendingBrowserCreateObservedSizeBridgeStub struct {
19+ sizeWidth int32
20+ sizeHeight int32
21+ refreshWidth int32
22+ refreshHeight int32
23+ refreshCalls int
24+ refreshProvided bool
25+}
26+
27+func (s *pendingBrowserCreateObservedSizeBridgeStub) Size() (int32, int32) {
28+ return s.sizeWidth, s.sizeHeight
29+}
30+
31+func (s *pendingBrowserCreateObservedSizeBridgeStub) RefreshObservedSizeOnGTKThread() (int32, int32) {
32+ s.refreshCalls++
33+ if s.refreshProvided {
34+ return s.refreshWidth, s.refreshHeight
35+ }
36+ return s.sizeWidth, s.sizeHeight
37+}
38+
17 func (h *viewportSyncOrderHost) WasHidden(state int32) {39 func (h *viewportSyncOrderHost) WasHidden(state int32) {
18 h.calls = append(h.calls, "WasHidden")40 h.calls = append(h.calls, "WasHidden")
19 if state != 0 {41 if state != 0 {
@@ -58,7 +80,7 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) {
58 require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)80 require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
59 }81 }
60 82
61-func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) {83+func TestNotifyViewportSyncOnCEFUIThread_PostsViewportWorkOnly(t *testing.T) {
62 oldNewTask := cefNewTask84 oldNewTask := cefNewTask
63 oldPostDelayedTask := cefPostDelayedTask85 oldPostDelayedTask := cefPostDelayedTask
64 defer func() {86 defer func() {
@@ -91,11 +113,116 @@ func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) {
91 "NotifyScreenInfoChanged",113 "NotifyScreenInfoChanged",
92 "WasResized",114 "WasResized",
93 "Invalidate",115 "Invalidate",
116+ }, host.calls)
117+}
118+
119+func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResizes(t *testing.T) {
120+ oldNewTask := cefNewTask
121+ oldPostDelayedTask := cefPostDelayedTask
122+ defer func() {
123+ cefNewTask = oldNewTask
124+ cefPostDelayedTask = oldPostDelayedTask
125+ }()
126+
127+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
128+
129+ var scheduled purecef.Task
130+ cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
131+ if delayMs == 0 && scheduled == nil {
132+ scheduled = task
133+ }
134+ return 1
135+ }
136+
137+ host := &viewportSyncOrderHost{}
138+ wv := &WebView{ctx: context.Background(), host: host}
139+
140+ wv.notifyViewportSyncOnCEFUIThread(host, true)
141+ require.NotNil(t, scheduled)
142+ scheduled.Execute()
143+
144+ host.calls = nil
145+ scheduled = nil
146+
147+ wv.notifyViewportSyncOnCEFUIThread(host, true)
148+ require.NotNil(t, scheduled)
149+ scheduled.Execute()
150+
151+ require.Equal(t, []string{
152+ "WasHidden",
153+ "NotifyScreenInfoChanged",
154+ "WasResized",
155+ "Invalidate",
156+ }, host.calls)
157+}
158+
159+func TestSyncZoomForBackingScaleOnCEFUIThread_PostsZoomWork(t *testing.T) {
160+ oldNewTask := cefNewTask
161+ oldPostDelayedTask := cefPostDelayedTask
162+ defer func() {
163+ cefNewTask = oldNewTask
164+ cefPostDelayedTask = oldPostDelayedTask
165+ }()
166+
167+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
168+
169+ var scheduled purecef.Task
170+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
171+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
172+ require.NotNil(t, task)
173+ if delayMs == 0 && scheduled == nil {
174+ scheduled = task
175+ }
176+ return 1
177+ }
178+
179+ host := &viewportSyncOrderHost{}
180+ wv := &WebView{ctx: context.Background(), host: host}
181+ wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
182+
183+ require.Empty(t, host.calls)
184+ require.NotNil(t, scheduled)
185+
186+ scheduled.Execute()
187+ require.Equal(t, []string{
94 "SetZoomLevel",188 "SetZoomLevel",
95 "NotifyScreenInfoChanged",189 "NotifyScreenInfoChanged",
96 }, host.calls)190 }, host.calls)
97 }191 }
98 192
193+func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingScaleUnchanged(t *testing.T) {
194+ oldNewTask := cefNewTask
195+ oldPostDelayedTask := cefPostDelayedTask
196+ defer func() {
197+ cefNewTask = oldNewTask
198+ cefPostDelayedTask = oldPostDelayedTask
199+ }()
200+
201+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
202+
203+ var scheduled purecef.Task
204+ cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
205+ if delayMs == 0 && scheduled == nil {
206+ scheduled = task
207+ }
208+ return 1
209+ }
210+
211+ host := &viewportSyncOrderHost{}
212+ wv := &WebView{ctx: context.Background(), host: host}
213+
214+ wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
215+ require.NotNil(t, scheduled)
216+ scheduled.Execute()
217+
218+ host.calls = nil
219+ scheduled = nil
220+
221+ wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
222+ require.Nil(t, scheduled)
223+ require.Empty(t, host.calls)
224+}
225+
99 func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) {226 func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) {
100 oldNewTask := cefNewTask227 oldNewTask := cefNewTask
101 oldPostDelayedTask := cefPostDelayedTask228 oldPostDelayedTask := cefPostDelayedTask
@@ -149,6 +276,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) {
149 require.Empty(t, host.calls)276 require.Empty(t, host.calls)
150 }277 }
151 278
279+func TestObservedViewportSizeReady(t *testing.T) {
280+ tests := []struct {
281+ name string
282+ observedWidth int32
283+ observedHeight int32
284+ allocatedWidth int32
285+ allocatedHeight int32
286+ want bool
287+ }{
288+ {
289+ name: "exact match",
290+ observedWidth: 793,
291+ observedHeight: 1753,
292+ allocatedWidth: 793,
293+ allocatedHeight: 1753,
294+ want: true,
295+ },
296+ {
297+ name: "one pixel drift is accepted",
298+ observedWidth: 792,
299+ observedHeight: 1754,
300+ allocatedWidth: 793,
301+ allocatedHeight: 1753,
302+ want: true,
303+ },
304+ {
305+ name: "fallback observed size is rejected",
306+ observedWidth: 1,
307+ observedHeight: 1,
308+ allocatedWidth: 793,
309+ allocatedHeight: 1753,
310+ want: false,
311+ },
312+ {
313+ name: "missing allocation is rejected",
314+ observedWidth: 793,
315+ observedHeight: 1753,
316+ allocatedWidth: 0,
317+ allocatedHeight: 1753,
318+ want: false,
319+ },
320+ {
321+ name: "large mismatch is rejected",
322+ observedWidth: 1200,
323+ observedHeight: 1400,
324+ allocatedWidth: 1587,
325+ allocatedHeight: 1679,
326+ want: false,
327+ },
328+ }
329+
330+ for _, tt := range tests {
331+ t.Run(tt.name, func(t *testing.T) {
332+ require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight))
333+ })
334+ }
335+}
336+
337+func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) {
338+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{
339+ sizeWidth: 1,
340+ sizeHeight: 1,
341+ refreshWidth: 1055,
342+ refreshHeight: 1719,
343+ refreshProvided: true,
344+ }
345+
346+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
347+
348+ require.True(t, ready)
349+ require.Equal(t, int32(1055), observedWidth)
350+ require.Equal(t, int32(1719), observedHeight)
351+ require.Equal(t, 1, bridge.refreshCalls)
352+}
353+
354+func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) {
355+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719}
356+
357+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
358+
359+ require.True(t, ready)
360+ require.Equal(t, int32(1055), observedWidth)
361+ require.Equal(t, int32(1719), observedHeight)
362+ require.Equal(t, 0, bridge.refreshCalls)
363+}
364+
365+func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) {
366+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
367+
368+ require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
369+
370+ wv.nativePopupFallbackStarted = true
371+ require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
372+}
373+
374+func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) {
375+ oldScheduleAfter := cefScheduleAfter
376+ defer func() { cefScheduleAfter = oldScheduleAfter }()
377+
378+ scheduleCalls := 0
379+ cefScheduleAfter = func(delay time.Duration, fn func()) {
380+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
381+ require.NotNil(t, fn)
382+ scheduleCalls++
383+ }
384+
385+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
386+
387+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
388+ require.Equal(t, 1, attempt)
389+ require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action)
390+ require.Equal(t, 1, scheduleCalls)
391+
392+ attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
393+ require.Equal(t, 1, attempt)
394+ require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action)
395+ require.Equal(t, 1, scheduleCalls)
396+}
397+
398+func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) {
399+ oldScheduleAfter := cefScheduleAfter
400+ defer func() { cefScheduleAfter = oldScheduleAfter }()
401+
402+ scheduleCalls := 0
403+ cefScheduleAfter = func(delay time.Duration, fn func()) {
404+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
405+ require.NotNil(t, fn)
406+ scheduleCalls++
407+ }
408+
409+ wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}}
410+
411+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
412+ require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt)
413+ require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action)
414+ require.Equal(t, 0, scheduleCalls)
415+ require.False(t, wv.pendingCreate.observedSizeRetryScheduled)
416+}
417+
152 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {418 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
153 oldNewTask := cefNewTask419 oldNewTask := cefNewTask
154 oldPostDelayedTask := cefPostDelayedTask420 oldPostDelayedTask := cefPostDelayedTask
A internal/infrastructure/config/cef_frame_rate_test.go +74 -0
@@ -0,0 +1,74 @@
1+package config
2+
3+import "testing"
4+
5+func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) {
6+ cfg := DefaultConfig().Engine.CEF
7+
8+ if !cfg.AdaptiveWindowlessFrameRate {
9+ t.Fatal("adaptive windowless frame-rate should be enabled by default")
10+ }
11+ if cfg.WindowlessFrameRate != 0 {
12+ t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate)
13+ }
14+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
15+ t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate)
16+ }
17+ if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) {
18+ t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax)
19+ }
20+ if !cfg.CEFAdaptiveWindowlessFrameRate() {
21+ t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set")
22+ }
23+}
24+
25+func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) {
26+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144}
27+
28+ if cfg.CEFAdaptiveWindowlessFrameRate() {
29+ t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate")
30+ }
31+ if got := cfg.CEFWindowlessFrameRate(); got != 144 {
32+ t.Fatalf("explicit frame-rate = %d, want 144", got)
33+ }
34+}
35+
36+func TestCEFWindowlessFrameRateFallbackWhenAdaptiveDisabled(t *testing.T) {
37+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 0}
38+
39+ if cfg.CEFAdaptiveWindowlessFrameRate() {
40+ t.Fatal("adaptive should be disabled when adaptive flag is false")
41+ }
42+ if got := cfg.CEFWindowlessFrameRate(); got != defaultCEFWindowlessFrameRate {
43+ t.Fatalf("frame-rate = %d, want default %d", got, defaultCEFWindowlessFrameRate)
44+ }
45+}
46+
47+func TestCEFWindowlessFrameRateUsesExplicitValueWhenAdaptiveDisabled(t *testing.T) {
48+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 75}
49+
50+ if cfg.CEFAdaptiveWindowlessFrameRate() {
51+ t.Fatal("adaptive should remain disabled with explicit frame-rate")
52+ }
53+ if got := cfg.CEFWindowlessFrameRate(); got != 75 {
54+ t.Fatalf("frame-rate = %d, want explicit 75", got)
55+ }
56+}
57+
58+func TestCEFWindowlessFrameRateBoundaries(t *testing.T) {
59+ tests := []struct {
60+ name string
61+ cfg CEFEngineConfig
62+ want int32
63+ }{
64+ {name: "negative falls back", cfg: CEFEngineConfig{WindowlessFrameRate: -1}, want: defaultCEFWindowlessFrameRate},
65+ {name: "above max remains explicit", cfg: CEFEngineConfig{WindowlessFrameRate: 300, WindowlessFrameRateMax: 240}, want: 300},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ if got := tt.cfg.CEFWindowlessFrameRate(); got != tt.want {
70+ t.Fatalf("CEFWindowlessFrameRate() = %d, want %d", got, tt.want)
71+ }
72+ })
73+ }
74+}
@@ -0,0 +1,74 @@
1+package config
2+
3+import "testing"
4+
5+func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) {
6+ cfg := DefaultConfig().Engine.CEF
7+
8+ if !cfg.AdaptiveWindowlessFrameRate {
9+ t.Fatal("adaptive windowless frame-rate should be enabled by default")
10+ }
11+ if cfg.WindowlessFrameRate != 0 {
12+ t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate)
13+ }
14+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
15+ t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate)
16+ }
17+ if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) {
18+ t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax)
19+ }
20+ if !cfg.CEFAdaptiveWindowlessFrameRate() {
21+ t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set")
22+ }
23+}
24+
25+func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) {
26+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144}
27+
28+ if cfg.CEFAdaptiveWindowlessFrameRate() {
29+ t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate")
30+ }
31+ if got := cfg.CEFWindowlessFrameRate(); got != 144 {
32+ t.Fatalf("explicit frame-rate = %d, want 144", got)
33+ }
34+}
35+
36+func TestCEFWindowlessFrameRateFallbackWhenAdaptiveDisabled(t *testing.T) {
37+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 0}
38+
39+ if cfg.CEFAdaptiveWindowlessFrameRate() {
40+ t.Fatal("adaptive should be disabled when adaptive flag is false")
41+ }
42+ if got := cfg.CEFWindowlessFrameRate(); got != defaultCEFWindowlessFrameRate {
43+ t.Fatalf("frame-rate = %d, want default %d", got, defaultCEFWindowlessFrameRate)
44+ }
45+}
46+
47+func TestCEFWindowlessFrameRateUsesExplicitValueWhenAdaptiveDisabled(t *testing.T) {
48+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 75}
49+
50+ if cfg.CEFAdaptiveWindowlessFrameRate() {
51+ t.Fatal("adaptive should remain disabled with explicit frame-rate")
52+ }
53+ if got := cfg.CEFWindowlessFrameRate(); got != 75 {
54+ t.Fatalf("frame-rate = %d, want explicit 75", got)
55+ }
56+}
57+
58+func TestCEFWindowlessFrameRateBoundaries(t *testing.T) {
59+ tests := []struct {
60+ name string
61+ cfg CEFEngineConfig
62+ want int32
63+ }{
64+ {name: "negative falls back", cfg: CEFEngineConfig{WindowlessFrameRate: -1}, want: defaultCEFWindowlessFrameRate},
65+ {name: "above max remains explicit", cfg: CEFEngineConfig{WindowlessFrameRate: 300, WindowlessFrameRateMax: 240}, want: 300},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ if got := tt.cfg.CEFWindowlessFrameRate(); got != tt.want {
70+ t.Fatalf("CEFWindowlessFrameRate() = %d, want %d", got, tt.want)
71+ }
72+ })
73+ }
74+}
M internal/infrastructure/config/defaults.go +10 -7
@@ -67,9 +67,10 @@ const (
6767 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
6868
6969 // Performance defaults
70- defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
71- defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72- defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
70+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
71+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72+ defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled
73+ defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap
7374
7475 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
7576 defaultSkiaCPUPaintingThreads = 0
@@ -171,10 +172,12 @@ func DefaultConfig() *Config {
171172 // matches Epiphany's model and avoids a misleading setting.
172173 CookiePolicy: CookiePolicyAlways,
173174 CEF: CEFEngineConfig{
174- RenderStack: CEFRenderStackVulkan,
175- WindowlessFrameRate: defaultCEFWindowlessFrameRate,
176- LogFile: "",
177- EnableAudioHandler: true,
175+ RenderStack: CEFRenderStackVulkan,
176+ AdaptiveWindowlessFrameRate: true,
177+ WindowlessFrameRate: 0,
178+ WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax,
179+ LogFile: "",
180+ EnableAudioHandler: true,
178181 },
179182 WebKit: WebKitEngineConfig{
180183 ITPEnabled: true,
@@ -67,9 +67,10 @@ const (
67 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)67 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
68 68
69 // Performance defaults69 // Performance defaults
70- defaultZoomCacheSize = 256 // domains to cache (~20KB memory)70+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
71- defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup71+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72- defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF72+ defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled
73+ defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap
73 74
74 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)75 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
75 defaultSkiaCPUPaintingThreads = 076 defaultSkiaCPUPaintingThreads = 0
@@ -171,10 +172,12 @@ func DefaultConfig() *Config {
171 // matches Epiphany's model and avoids a misleading setting.172 // matches Epiphany's model and avoids a misleading setting.
172 CookiePolicy: CookiePolicyAlways,173 CookiePolicy: CookiePolicyAlways,
173 CEF: CEFEngineConfig{174 CEF: CEFEngineConfig{
174- RenderStack: CEFRenderStackVulkan,175+ RenderStack: CEFRenderStackVulkan,
175- WindowlessFrameRate: defaultCEFWindowlessFrameRate,176+ AdaptiveWindowlessFrameRate: true,
176- LogFile: "",177+ WindowlessFrameRate: 0,
177- EnableAudioHandler: true,178+ WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax,
179+ LogFile: "",
180+ EnableAudioHandler: true,
178 },181 },
179 WebKit: WebKitEngineConfig{182 WebKit: WebKitEngineConfig{
180 ITPEnabled: true,183 ITPEnabled: true,
M internal/infrastructure/config/defaults_test.go +2 -0
@@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1919 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
2020 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
2121 assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack())
22+ assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate())
2223 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24+ assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax())
2325 assert.Empty(t, cfg.Engine.CEF.LogFile)
2426 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
2527 assert.False(t, cfg.Engine.CEF.TraceHandlers)
@@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
19 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)19 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
20 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)20 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
21 assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack())21 assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack())
22+ assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate())
22 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())23 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24+ assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax())
23 assert.Empty(t, cfg.Engine.CEF.LogFile)25 assert.Empty(t, cfg.Engine.CEF.LogFile)
24 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)26 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
25 assert.False(t, cfg.Engine.CEF.TraceHandlers)27 assert.False(t, cfg.Engine.CEF.TraceHandlers)
M internal/infrastructure/config/engine.go +20 -2
@@ -100,9 +100,16 @@ type CEFEngineConfig struct {
100100 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
101101 // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan.
102102 RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"`
103+ // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate
104+ // and updates CEF's OSR frame-rate while the browser moves across monitors.
105+ // It is ignored when WindowlessFrameRate is explicitly set.
106+ AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit
103107 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
104- // Default: 60. Higher values increase CPU usage.
105- WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
108+ // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the
109+ // active Wayland monitor refresh rate.
110+ WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit
111+ // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates.
112+ WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit
106113 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
107114 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
108115 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
@@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
127134 return defaultCEFWindowlessFrameRate
128135 }
129136
137+func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 {
138+ if c.WindowlessFrameRateMax > 0 {
139+ return c.WindowlessFrameRateMax
140+ }
141+ return defaultCEFWindowlessFrameRateMax
142+}
143+
144+func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool {
145+ return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0
146+}
147+
130148 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
131149 // new [engine] config section, for use with ResolvePerformanceProfile.
132150 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {
@@ -100,9 +100,16 @@ type CEFEngineConfig struct {
100 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`100 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
101 // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan.101 // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan.
102 RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"`102 RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"`
103+ // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate
104+ // and updates CEF's OSR frame-rate while the browser moves across monitors.
105+ // It is ignored when WindowlessFrameRate is explicitly set.
106+ AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit
103 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.107 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
104- // Default: 60. Higher values increase CPU usage.108+ // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the
105- WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`109+ // active Wayland monitor refresh rate.
110+ WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit
111+ // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates.
112+ WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit
106 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.113 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
107 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`114 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
108 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.115 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
@@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
127 return defaultCEFWindowlessFrameRate134 return defaultCEFWindowlessFrameRate
128 }135 }
129 136
137+func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 {
138+ if c.WindowlessFrameRateMax > 0 {
139+ return c.WindowlessFrameRateMax
140+ }
141+ return defaultCEFWindowlessFrameRateMax
142+}
143+
144+func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool {
145+ return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0
146+}
147+
130 // PerformanceConfigFromEngine constructs a PerformanceConfig from the148 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
131 // new [engine] config section, for use with ResolvePerformanceProfile.149 // new [engine] config section, for use with ResolvePerformanceProfile.
132 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {150 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {
M internal/infrastructure/config/loader.go +58 -9
@@ -19,15 +19,26 @@ import (
1919 type Manager struct {
2020 config *Config
2121 viper *viper.Viper
22+ automaticEnv automaticEnvConfig
2223 mu sync.RWMutex
2324 callbacks []func(*Config)
2425 watching bool
2526 skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
2627 }
2728
29+type automaticEnvConfig struct {
30+ enabled bool
31+ prefix string
32+ keyReplacer *strings.Replacer
33+}
34+
2835 // NewManager creates a new configuration manager.
2936 func NewManager() (*Manager, error) {
3037 v := viper.New()
38+ m := &Manager{
39+ viper: v,
40+ callbacks: make([]func(*Config), 0),
41+ }
3142
3243 // Configure Viper for TOML as default format
3344 v.SetConfigName("config") // Name without extension
@@ -42,9 +53,7 @@ func NewManager() (*Manager, error) {
4253 v.AddConfigPath(".") // Current directory for development
4354
4455 // Set up environment variable support
45- v.SetEnvPrefix("DUMBER")
46- v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
47- v.AutomaticEnv()
56+ m.configureAutomaticEnv()
4857
4958 // Note: Most environment variables are handled automatically via AutomaticEnv()
5059 // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
@@ -69,10 +78,7 @@ func NewManager() (*Manager, error) {
6978 return nil, err
7079 }
7180
72- return &Manager{
73- viper: v,
74- callbacks: make([]func(*Config), 0),
75- }, nil
81+ return m, nil
7682 }
7783
7884 // Load loads the configuration from file and environment variables.
@@ -153,7 +159,11 @@ func (m *Manager) transformLegacyConfig() {
153159 rawConfig := m.viper.AllSettings()
154160 transformer := NewLegacyConfigTransformer()
155161 transformer.TransformLegacyActions(rawConfig)
156- transformer.TransformLegacyEngineConfig(rawConfig)
162+ transformer.TransformLegacyEngineConfigWithExplicitAdaptive(
163+ rawConfig,
164+ m.hasExplicitAdaptiveWindowlessFrameRateSetting(),
165+ m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"),
166+ )
157167
158168 // AllSettings includes defaults, so workspace.browsing_contexts may already be
159169 // present even when the user only configured legacy workspace.popups. Always
@@ -171,6 +181,43 @@ func (m *Manager) transformLegacyConfig() {
171181 }
172182 }
173183
184+func (m *Manager) configureAutomaticEnv() {
185+ if m == nil || m.viper == nil {
186+ return
187+ }
188+ replacer := strings.NewReplacer(".", "_")
189+ m.viper.SetEnvPrefix("DUMBER")
190+ m.viper.SetEnvKeyReplacer(replacer)
191+ m.viper.AutomaticEnv()
192+ m.automaticEnv = automaticEnvConfig{
193+ enabled: true,
194+ prefix: "DUMBER",
195+ keyReplacer: replacer,
196+ }
197+}
198+
199+func (m *Manager) hasExplicitAdaptiveWindowlessFrameRateSetting() bool {
200+ if m == nil || m.viper == nil {
201+ return false
202+ }
203+ if m.viper.InConfig("engine.cef.adaptive_windowless_frame_rate") {
204+ return true
205+ }
206+ if !m.automaticEnv.enabled {
207+ return false
208+ }
209+ envKey := "engine.cef.adaptive_windowless_frame_rate"
210+ if m.automaticEnv.keyReplacer != nil {
211+ envKey = m.automaticEnv.keyReplacer.Replace(envKey)
212+ }
213+ envKey = strings.ToUpper(envKey)
214+ if prefix := strings.TrimSpace(m.automaticEnv.prefix); prefix != "" {
215+ envKey = prefix + "_" + envKey
216+ }
217+ value, ok := os.LookupEnv(envKey)
218+ return ok && value != ""
219+}
220+
174221 func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) {
175222 workspace, ok := rawConfig["workspace"].(map[string]any)
176223 if !ok {
@@ -694,7 +741,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
694741 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
695742 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
696743 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
697- m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
744+ m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
745+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
746+ m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
698747 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
699748 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
700749
@@ -19,15 +19,26 @@ import (
19 type Manager struct {19 type Manager struct {
20 config *Config20 config *Config
21 viper *viper.Viper21 viper *viper.Viper
22+ automaticEnv automaticEnvConfig
22 mu sync.RWMutex23 mu sync.RWMutex
23 callbacks []func(*Config)24 callbacks []func(*Config)
24 watching bool25 watching bool
25 skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config26 skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config
26 }27 }
27 28
29+type automaticEnvConfig struct {
30+ enabled bool
31+ prefix string
32+ keyReplacer *strings.Replacer
33+}
34+
28 // NewManager creates a new configuration manager.35 // NewManager creates a new configuration manager.
29 func NewManager() (*Manager, error) {36 func NewManager() (*Manager, error) {
30 v := viper.New()37 v := viper.New()
38+ m := &Manager{
39+ viper: v,
40+ callbacks: make([]func(*Config), 0),
41+ }
31 42
32 // Configure Viper for TOML as default format43 // Configure Viper for TOML as default format
33 v.SetConfigName("config") // Name without extension44 v.SetConfigName("config") // Name without extension
@@ -42,9 +53,7 @@ func NewManager() (*Manager, error) {
42 v.AddConfigPath(".") // Current directory for development53 v.AddConfigPath(".") // Current directory for development
43 54
44 // Set up environment variable support55 // Set up environment variable support
45- v.SetEnvPrefix("DUMBER")56+ m.configureAutomaticEnv()
46- v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
47- v.AutomaticEnv()
48 57
49 // Note: Most environment variables are handled automatically via AutomaticEnv()58 // Note: Most environment variables are handled automatically via AutomaticEnv()
50 // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).59 // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL).
@@ -69,10 +78,7 @@ func NewManager() (*Manager, error) {
69 return nil, err78 return nil, err
70 }79 }
71 80
72- return &Manager{81+ return m, nil
73- viper: v,
74- callbacks: make([]func(*Config), 0),
75- }, nil
76 }82 }
77 83
78 // Load loads the configuration from file and environment variables.84 // Load loads the configuration from file and environment variables.
@@ -153,7 +159,11 @@ func (m *Manager) transformLegacyConfig() {
153 rawConfig := m.viper.AllSettings()159 rawConfig := m.viper.AllSettings()
154 transformer := NewLegacyConfigTransformer()160 transformer := NewLegacyConfigTransformer()
155 transformer.TransformLegacyActions(rawConfig)161 transformer.TransformLegacyActions(rawConfig)
156- transformer.TransformLegacyEngineConfig(rawConfig)162+ transformer.TransformLegacyEngineConfigWithExplicitAdaptive(
163+ rawConfig,
164+ m.hasExplicitAdaptiveWindowlessFrameRateSetting(),
165+ m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"),
166+ )
157 167
158 // AllSettings includes defaults, so workspace.browsing_contexts may already be168 // AllSettings includes defaults, so workspace.browsing_contexts may already be
159 // present even when the user only configured legacy workspace.popups. Always169 // present even when the user only configured legacy workspace.popups. Always
@@ -171,6 +181,43 @@ func (m *Manager) transformLegacyConfig() {
171 }181 }
172 }182 }
173 183
184+func (m *Manager) configureAutomaticEnv() {
185+ if m == nil || m.viper == nil {
186+ return
187+ }
188+ replacer := strings.NewReplacer(".", "_")
189+ m.viper.SetEnvPrefix("DUMBER")
190+ m.viper.SetEnvKeyReplacer(replacer)
191+ m.viper.AutomaticEnv()
192+ m.automaticEnv = automaticEnvConfig{
193+ enabled: true,
194+ prefix: "DUMBER",
195+ keyReplacer: replacer,
196+ }
197+}
198+
199+func (m *Manager) hasExplicitAdaptiveWindowlessFrameRateSetting() bool {
200+ if m == nil || m.viper == nil {
201+ return false
202+ }
203+ if m.viper.InConfig("engine.cef.adaptive_windowless_frame_rate") {
204+ return true
205+ }
206+ if !m.automaticEnv.enabled {
207+ return false
208+ }
209+ envKey := "engine.cef.adaptive_windowless_frame_rate"
210+ if m.automaticEnv.keyReplacer != nil {
211+ envKey = m.automaticEnv.keyReplacer.Replace(envKey)
212+ }
213+ envKey = strings.ToUpper(envKey)
214+ if prefix := strings.TrimSpace(m.automaticEnv.prefix); prefix != "" {
215+ envKey = prefix + "_" + envKey
216+ }
217+ value, ok := os.LookupEnv(envKey)
218+ return ok && value != ""
219+}
220+
174 func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) {221 func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) {
175 workspace, ok := rawConfig["workspace"].(map[string]any)222 workspace, ok := rawConfig["workspace"].(map[string]any)
176 if !ok {223 if !ok {
@@ -694,7 +741,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
694 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))741 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
695 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)742 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
696 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)743 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
697- m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())744+ m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
745+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
746+ m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
698 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)747 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
699 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)748 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
700 749
M internal/infrastructure/config/loader_legacy_test.go +77 -0
@@ -1,6 +1,8 @@
11 package config
22
33 import (
4+ "os"
5+ "path/filepath"
46 "testing"
57
68 "github.com/spf13/viper"
@@ -75,3 +77,78 @@ func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) {
7577 require.Error(t, err)
7678 require.Contains(t, err.Error(), "dumber config migrate")
7779 }
80+
81+func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) {
82+ dir := t.TempDir()
83+ configPath := filepath.Join(dir, "config.toml")
84+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
85+
86+ m := &Manager{viper: viper.New()}
87+ m.viper.SetConfigFile(configPath)
88+ m.viper.SetConfigType("toml")
89+ m.setDefaults()
90+ require.NoError(t, m.viper.ReadInConfig())
91+
92+ m.transformLegacyConfig()
93+
94+ require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
95+ require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
96+ require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
97+}
98+
99+func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {
100+ dir := t.TempDir()
101+ configPath := filepath.Join(dir, "config.toml")
102+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
103+ t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false")
104+
105+ m := &Manager{viper: viper.New()}
106+ m.viper.SetConfigFile(configPath)
107+ m.viper.SetConfigType("toml")
108+ m.configureAutomaticEnv()
109+ m.setDefaults()
110+ require.NoError(t, m.viper.ReadInConfig())
111+
112+ m.transformLegacyConfig()
113+
114+ require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
115+ require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate"))
116+}
117+
118+func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {
119+ dir := t.TempDir()
120+ configPath := filepath.Join(dir, "config.toml")
121+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
122+ t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false")
123+
124+ m := &Manager{viper: viper.New()}
125+ m.viper.SetConfigFile(configPath)
126+ m.viper.SetConfigType("toml")
127+ m.setDefaults()
128+ require.NoError(t, m.viper.ReadInConfig())
129+
130+ m.transformLegacyConfig()
131+
132+ require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
133+ require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
134+}
135+
136+func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {
137+ dir := t.TempDir()
138+ configPath := filepath.Join(dir, "config.toml")
139+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
140+ t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "true")
141+
142+ m := &Manager{viper: viper.New()}
143+ m.viper.SetConfigFile(configPath)
144+ m.viper.SetConfigType("toml")
145+ m.configureAutomaticEnv()
146+ m.setDefaults()
147+ require.NoError(t, m.viper.ReadInConfig())
148+
149+ m.transformLegacyConfig()
150+
151+ require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
152+ require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
153+ require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
154+}
@@ -1,6 +1,8 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
4 "testing"6 "testing"
5 7
6 "github.com/spf13/viper"8 "github.com/spf13/viper"
@@ -75,3 +77,78 @@ func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) {
75 require.Error(t, err)77 require.Error(t, err)
76 require.Contains(t, err.Error(), "dumber config migrate")78 require.Contains(t, err.Error(), "dumber config migrate")
77 }79 }
80+
81+func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) {
82+ dir := t.TempDir()
83+ configPath := filepath.Join(dir, "config.toml")
84+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
85+
86+ m := &Manager{viper: viper.New()}
87+ m.viper.SetConfigFile(configPath)
88+ m.viper.SetConfigType("toml")
89+ m.setDefaults()
90+ require.NoError(t, m.viper.ReadInConfig())
91+
92+ m.transformLegacyConfig()
93+
94+ require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
95+ require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
96+ require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
97+}
98+
99+func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {
100+ dir := t.TempDir()
101+ configPath := filepath.Join(dir, "config.toml")
102+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
103+ t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false")
104+
105+ m := &Manager{viper: viper.New()}
106+ m.viper.SetConfigFile(configPath)
107+ m.viper.SetConfigType("toml")
108+ m.configureAutomaticEnv()
109+ m.setDefaults()
110+ require.NoError(t, m.viper.ReadInConfig())
111+
112+ m.transformLegacyConfig()
113+
114+ require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
115+ require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate"))
116+}
117+
118+func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {
119+ dir := t.TempDir()
120+ configPath := filepath.Join(dir, "config.toml")
121+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
122+ t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false")
123+
124+ m := &Manager{viper: viper.New()}
125+ m.viper.SetConfigFile(configPath)
126+ m.viper.SetConfigType("toml")
127+ m.setDefaults()
128+ require.NoError(t, m.viper.ReadInConfig())
129+
130+ m.transformLegacyConfig()
131+
132+ require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
133+ require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
134+}
135+
136+func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {
137+ dir := t.TempDir()
138+ configPath := filepath.Join(dir, "config.toml")
139+ require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600))
140+ t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "true")
141+
142+ m := &Manager{viper: viper.New()}
143+ m.viper.SetConfigFile(configPath)
144+ m.viper.SetConfigType("toml")
145+ m.configureAutomaticEnv()
146+ m.setDefaults()
147+ require.NoError(t, m.viper.ReadInConfig())
148+
149+ m.transformLegacyConfig()
150+
151+ require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
152+ require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
153+ require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
154+}
M internal/infrastructure/config/schema_provider.go +17 -2
@@ -939,11 +939,26 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
939939 Values: []string{"vulkan", "egl"},
940940 Section: SectionPerformance,
941941 },
942+ {
943+ Key: "engine.cef.adaptive_windowless_frame_rate",
944+ Type: "bool",
945+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate),
946+ Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set",
947+ Section: SectionPerformance,
948+ },
942949 {
943950 Key: "engine.cef.windowless_frame_rate",
944951 Type: "int32",
945- Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
946- Description: "Maximum off-screen frame rate for the CEF engine",
952+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate),
953+ Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured",
954+ Range: ">=0",
955+ Section: SectionPerformance,
956+ },
957+ {
958+ Key: "engine.cef.windowless_frame_rate_max",
959+ Type: "int32",
960+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()),
961+ Description: "Hard cap for adaptive CEF OSR frame rate; 0 uses the built-in default cap",
947962 Range: ">=0",
948963 Section: SectionPerformance,
949964 },
@@ -939,11 +939,26 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
939 Values: []string{"vulkan", "egl"},939 Values: []string{"vulkan", "egl"},
940 Section: SectionPerformance,940 Section: SectionPerformance,
941 },941 },
942+ {
943+ Key: "engine.cef.adaptive_windowless_frame_rate",
944+ Type: "bool",
945+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate),
946+ Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set",
947+ Section: SectionPerformance,
948+ },
942 {949 {
943 Key: "engine.cef.windowless_frame_rate",950 Key: "engine.cef.windowless_frame_rate",
944 Type: "int32",951 Type: "int32",
945- Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),952+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate),
946- Description: "Maximum off-screen frame rate for the CEF engine",953+ Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured",
954+ Range: ">=0",
955+ Section: SectionPerformance,
956+ },
957+ {
958+ Key: "engine.cef.windowless_frame_rate_max",
959+ Type: "int32",
960+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()),
961+ Description: "Hard cap for adaptive CEF OSR frame rate; 0 uses the built-in default cap",
947 Range: ">=0",962 Range: ">=0",
948 Section: SectionPerformance,963 Section: SectionPerformance,
949 },964 },
M internal/infrastructure/config/transform_legacy.go +81 -1
@@ -1,5 +1,7 @@
11 package config
22
3+import "math"
4+
35 const (
46 sectionWorkspace = "workspace"
57 sectionSession = "session"
@@ -42,7 +44,19 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an
4244 }
4345
4446 // TransformLegacyEngineConfig removes deprecated engine config keys.
45-func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
47+func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
48+ adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
49+ t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
50+}
51+
52+func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive(
53+ rawConfig map[string]any,
54+ hasAdaptive, adaptiveEnabled bool,
55+) {
56+ t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
57+}
58+
59+func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) {
4660 engine, ok := rawConfig["engine"].(map[string]any)
4761 if !ok {
4862 return
@@ -54,6 +68,72 @@ func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string
5468 }
5569
5670 delete(cef, "enable_context_menu_handler")
71+ migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled)
72+}
73+
74+func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) {
75+ if hasAdaptive && !adaptiveEnabled {
76+ return
77+ }
78+ frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"])
79+ if !ok || frameRate != defaultCEFWindowlessFrameRate {
80+ return
81+ }
82+ cef["adaptive_windowless_frame_rate"] = true
83+ cef["windowless_frame_rate"] = 0
84+ if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax {
85+ cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax
86+ }
87+}
88+
89+func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) {
90+ current := rawConfig
91+ for i, key := range path {
92+ value, ok := current[key]
93+ if !ok {
94+ return false, false
95+ }
96+ if i == len(path)-1 {
97+ parsed, parsedOK := value.(bool)
98+ return parsed, parsedOK
99+ }
100+ next, ok := value.(map[string]any)
101+ if !ok {
102+ return false, false
103+ }
104+ current = next
105+ }
106+ return false, false
107+}
108+
109+func int64ConfigValue(value any) (int64, bool) {
110+ switch v := value.(type) {
111+ case int:
112+ return int64(v), true
113+ case int8:
114+ return int64(v), true
115+ case int16:
116+ return int64(v), true
117+ case int32:
118+ return int64(v), true
119+ case int64:
120+ return v, true
121+ case uint:
122+ return int64(v), true
123+ case uint8:
124+ return int64(v), true
125+ case uint16:
126+ return int64(v), true
127+ case uint32:
128+ return int64(v), true
129+ case uint64:
130+ if v > math.MaxInt64 {
131+ return 0, false
132+ }
133+ return int64(v), true
134+ default:
135+ return 0, false
136+ }
57137 }
58138
59139 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
@@ -1,5 +1,7 @@
1 package config1 package config
2 2
3+import "math"
4+
3 const (5 const (
4 sectionWorkspace = "workspace"6 sectionWorkspace = "workspace"
5 sectionSession = "session"7 sectionSession = "session"
@@ -42,7 +44,19 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an
42 }44 }
43 45
44 // TransformLegacyEngineConfig removes deprecated engine config keys.46 // TransformLegacyEngineConfig removes deprecated engine config keys.
45-func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {47+func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
48+ adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
49+ t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
50+}
51+
52+func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive(
53+ rawConfig map[string]any,
54+ hasAdaptive, adaptiveEnabled bool,
55+) {
56+ t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled)
57+}
58+
59+func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) {
46 engine, ok := rawConfig["engine"].(map[string]any)60 engine, ok := rawConfig["engine"].(map[string]any)
47 if !ok {61 if !ok {
48 return62 return
@@ -54,6 +68,72 @@ func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string
54 }68 }
55 69
56 delete(cef, "enable_context_menu_handler")70 delete(cef, "enable_context_menu_handler")
71+ migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled)
72+}
73+
74+func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) {
75+ if hasAdaptive && !adaptiveEnabled {
76+ return
77+ }
78+ frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"])
79+ if !ok || frameRate != defaultCEFWindowlessFrameRate {
80+ return
81+ }
82+ cef["adaptive_windowless_frame_rate"] = true
83+ cef["windowless_frame_rate"] = 0
84+ if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax {
85+ cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax
86+ }
87+}
88+
89+func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) {
90+ current := rawConfig
91+ for i, key := range path {
92+ value, ok := current[key]
93+ if !ok {
94+ return false, false
95+ }
96+ if i == len(path)-1 {
97+ parsed, parsedOK := value.(bool)
98+ return parsed, parsedOK
99+ }
100+ next, ok := value.(map[string]any)
101+ if !ok {
102+ return false, false
103+ }
104+ current = next
105+ }
106+ return false, false
107+}
108+
109+func int64ConfigValue(value any) (int64, bool) {
110+ switch v := value.(type) {
111+ case int:
112+ return int64(v), true
113+ case int8:
114+ return int64(v), true
115+ case int16:
116+ return int64(v), true
117+ case int32:
118+ return int64(v), true
119+ case int64:
120+ return v, true
121+ case uint:
122+ return int64(v), true
123+ case uint8:
124+ return int64(v), true
125+ case uint16:
126+ return int64(v), true
127+ case uint32:
128+ return int64(v), true
129+ case uint64:
130+ if v > math.MaxInt64 {
131+ return 0, false
132+ }
133+ return int64(v), true
134+ default:
135+ return 0, false
136+ }
57 }137 }
58 138
59 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {139 func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) {
M internal/infrastructure/config/transform_legacy_test.go +56 -0
@@ -188,6 +188,62 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test
188188 assert.False(t, exists)
189189 }
190190
191+func TestTransformLegacyEngineConfig_MigratesLegacyCEFWindowlessFrameRateDefault(t *testing.T) {
192+ transformer := NewLegacyConfigTransformer()
193+ rawConfig := map[string]any{
194+ "engine": map[string]any{
195+ "cef": map[string]any{
196+ "windowless_frame_rate": 60,
197+ },
198+ },
199+ }
200+
201+ transformer.TransformLegacyEngineConfig(rawConfig)
202+
203+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
204+ assert.Equal(t, true, cef["adaptive_windowless_frame_rate"])
205+ assert.Equal(t, 0, cef["windowless_frame_rate"])
206+ assert.Equal(t, defaultCEFWindowlessFrameRateMax, cef["windowless_frame_rate_max"])
207+}
208+
209+func TestTransformLegacyEngineConfig_PreservesExplicitCEFWindowlessFrameRate(t *testing.T) {
210+ transformer := NewLegacyConfigTransformer()
211+ rawConfig := map[string]any{
212+ "engine": map[string]any{
213+ "cef": map[string]any{
214+ "adaptive_windowless_frame_rate": false,
215+ "windowless_frame_rate": 60,
216+ },
217+ },
218+ }
219+
220+ transformer.TransformLegacyEngineConfig(rawConfig)
221+
222+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
223+ assert.Equal(t, false, cef["adaptive_windowless_frame_rate"])
224+ assert.Equal(t, 60, cef["windowless_frame_rate"])
225+ _, hasMax := cef["windowless_frame_rate_max"]
226+ assert.False(t, hasMax)
227+}
228+
229+func TestTransformLegacyEngineConfig_PreservesNonDefaultCEFWindowlessFrameRate(t *testing.T) {
230+ transformer := NewLegacyConfigTransformer()
231+ rawConfig := map[string]any{
232+ "engine": map[string]any{
233+ "cef": map[string]any{
234+ "windowless_frame_rate": 144,
235+ },
236+ },
237+ }
238+
239+ transformer.TransformLegacyEngineConfig(rawConfig)
240+
241+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
242+ _, hasAdaptive := cef["adaptive_windowless_frame_rate"]
243+ assert.False(t, hasAdaptive)
244+ assert.Equal(t, 144, cef["windowless_frame_rate"])
245+}
246+
191247 func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) {
192248 transformer := NewLegacyConfigTransformer()
193249 rawConfig := map[string]any{
@@ -188,6 +188,62 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test
188 assert.False(t, exists)188 assert.False(t, exists)
189 }189 }
190 190
191+func TestTransformLegacyEngineConfig_MigratesLegacyCEFWindowlessFrameRateDefault(t *testing.T) {
192+ transformer := NewLegacyConfigTransformer()
193+ rawConfig := map[string]any{
194+ "engine": map[string]any{
195+ "cef": map[string]any{
196+ "windowless_frame_rate": 60,
197+ },
198+ },
199+ }
200+
201+ transformer.TransformLegacyEngineConfig(rawConfig)
202+
203+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
204+ assert.Equal(t, true, cef["adaptive_windowless_frame_rate"])
205+ assert.Equal(t, 0, cef["windowless_frame_rate"])
206+ assert.Equal(t, defaultCEFWindowlessFrameRateMax, cef["windowless_frame_rate_max"])
207+}
208+
209+func TestTransformLegacyEngineConfig_PreservesExplicitCEFWindowlessFrameRate(t *testing.T) {
210+ transformer := NewLegacyConfigTransformer()
211+ rawConfig := map[string]any{
212+ "engine": map[string]any{
213+ "cef": map[string]any{
214+ "adaptive_windowless_frame_rate": false,
215+ "windowless_frame_rate": 60,
216+ },
217+ },
218+ }
219+
220+ transformer.TransformLegacyEngineConfig(rawConfig)
221+
222+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
223+ assert.Equal(t, false, cef["adaptive_windowless_frame_rate"])
224+ assert.Equal(t, 60, cef["windowless_frame_rate"])
225+ _, hasMax := cef["windowless_frame_rate_max"]
226+ assert.False(t, hasMax)
227+}
228+
229+func TestTransformLegacyEngineConfig_PreservesNonDefaultCEFWindowlessFrameRate(t *testing.T) {
230+ transformer := NewLegacyConfigTransformer()
231+ rawConfig := map[string]any{
232+ "engine": map[string]any{
233+ "cef": map[string]any{
234+ "windowless_frame_rate": 144,
235+ },
236+ },
237+ }
238+
239+ transformer.TransformLegacyEngineConfig(rawConfig)
240+
241+ cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any)
242+ _, hasAdaptive := cef["adaptive_windowless_frame_rate"]
243+ assert.False(t, hasAdaptive)
244+ assert.Equal(t, 144, cef["windowless_frame_rate"])
245+}
246+
191 func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) {247 func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) {
192 transformer := NewLegacyConfigTransformer()248 transformer := NewLegacyConfigTransformer()
193 rawConfig := map[string]any{249 rawConfig := map[string]any{
M internal/infrastructure/config/validation.go +3 -0
@@ -358,6 +358,9 @@ func validateCEF(config *Config) []string {
358358 if config.Engine.CEF.WindowlessFrameRate < 0 {
359359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
360360 }
361+ if config.Engine.CEF.WindowlessFrameRateMax < 0 {
362+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
363+ }
361364
362365 return validationErrors
363366 }
@@ -358,6 +358,9 @@ func validateCEF(config *Config) []string {
358 if config.Engine.CEF.WindowlessFrameRate < 0 {358 if config.Engine.CEF.WindowlessFrameRate < 0 {
359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
360 }360 }
361+ if config.Engine.CEF.WindowlessFrameRateMax < 0 {
362+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
363+ }
361 364
362 return validationErrors365 return validationErrors
363 }366 }
M internal/ui/app.go +25 -7
@@ -2347,7 +2347,11 @@ func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow
23472347 }
23482348
23492349 // switchBrowserWindowTabIndex activates the given browser window and switches to
2350-// the tab at the given 0-based index in that window, creating tabs as needed.
2350+// the tab at the given 0-based index in that window.
2351+//
2352+// If the requested tab already exists, it switches to it. If the index is out
2353+// of range, it creates exactly one new tab instead of backfilling all missing
2354+// intermediate tab slots.
23512355 func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {
23522356 if a.tabCoord == nil {
23532357 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")
@@ -2357,13 +2361,27 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
23572361 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")
23582362 return nil
23592363 }
2360- a.activateBrowserWindow(bw)
2361- target := a.ensureTabTargetForBrowserWindow(bw)
2362- initialURL := ""
2363- if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" {
2364- initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)
2364+ if index < 0 {
2365+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index")
2366+ return nil
2367+ }
2368+ target := a.tabTargetForBrowserWindow(bw)
2369+ tabCount := 0
2370+ if target.Tabs != nil {
2371+ tabCount = target.Tabs.Count()
2372+ }
2373+ if index < tabCount {
2374+ a.activateBrowserWindow(bw)
2375+ return a.tabCoord.SwitchByIndex(ctx, target, index)
23652376 }
2366- return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)
2377+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
2378+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured")
2379+ return fmt.Errorf("newPaneURL is not configured")
2380+ }
2381+ a.activateBrowserWindow(bw)
2382+ ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2383+ _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
2384+ return err
23672385 }
23682386
23692387 // windowOrder returns window IDs in registration order.
@@ -2347,7 +2347,11 @@ func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow
2347 }2347 }
2348 2348
2349 // switchBrowserWindowTabIndex activates the given browser window and switches to2349 // switchBrowserWindowTabIndex activates the given browser window and switches to
2350-// the tab at the given 0-based index in that window, creating tabs as needed.2350+// the tab at the given 0-based index in that window.
2351+//
2352+// If the requested tab already exists, it switches to it. If the index is out
2353+// of range, it creates exactly one new tab instead of backfilling all missing
2354+// intermediate tab slots.
2351 func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {2355 func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {
2352 if a.tabCoord == nil {2356 if a.tabCoord == nil {
2353 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")2357 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")
@@ -2357,13 +2361,27 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
2357 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")2361 logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")
2358 return nil2362 return nil
2359 }2363 }
2360- a.activateBrowserWindow(bw)2364+ if index < 0 {
2361- target := a.ensureTabTargetForBrowserWindow(bw)2365+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index")
2362- initialURL := ""2366+ return nil
2363- if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" {2367+ }
2364- initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)2368+ target := a.tabTargetForBrowserWindow(bw)
2369+ tabCount := 0
2370+ if target.Tabs != nil {
2371+ tabCount = target.Tabs.Count()
2372+ }
2373+ if index < tabCount {
2374+ a.activateBrowserWindow(bw)
2375+ return a.tabCoord.SwitchByIndex(ctx, target, index)
2365 }2376 }
2366- return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)2377+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
2378+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured")
2379+ return fmt.Errorf("newPaneURL is not configured")
2380+ }
2381+ a.activateBrowserWindow(bw)
2382+ ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2383+ _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
2384+ return err
2367 }2385 }
2368 2386
2369 // windowOrder returns window IDs in registration order.2387 // windowOrder returns window IDs in registration order.
M internal/ui/app_browser_launch_test.go +168 -13
@@ -2243,10 +2243,54 @@ func counterIDGen() func() string {
22432243 }
22442244 }
22452245
2246-func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) {
2246+func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testing.T) {
2247+ ctx := context.Background()
2248+
2249+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2250+ firstTabs := entity.NewTabList()
2251+ firstTabs.Add(firstTab)
2252+ firstTabs.SetActive(firstTab.ID)
2253+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2254+
2255+ secondTab1 := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2256+ secondTab2 := entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2")))
2257+ secondTabs := entity.NewTabList()
2258+ secondTabs.Add(secondTab1)
2259+ secondTabs.Add(secondTab2)
2260+ secondTabs.SetActive(secondTab1.ID)
2261+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2262+
2263+ app := &App{
2264+ deps: &Dependencies{
2265+ Config: &config.Config{
2266+ Workspace: config.WorkspaceConfig{
2267+ NewPaneURL: "about:blank",
2268+ },
2269+ },
2270+ },
2271+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2272+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2273+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
2274+ lastFocusedWindowID: first.id, // stale — should NOT be used
2275+ }
2276+ app.initTabCoordinator(ctx)
2277+
2278+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex2)
2279+ if err != nil {
2280+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2281+ }
2282+
2283+ if got := secondTabs.ActiveTabID; got != secondTab2.ID {
2284+ t.Fatalf("second active tab = %q, want %q", got, secondTab2.ID)
2285+ }
2286+ if got := firstTabs.ActiveTabID; got != firstTab.ID {
2287+ t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2288+ }
2289+}
2290+
2291+func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t *testing.T) {
22472292 ctx := context.Background()
22482293
2249- // Two windows, each with one tab.
22502294 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
22512295 firstTabs := entity.NewTabList()
22522296 firstTabs.Add(firstTab)
@@ -2280,24 +2324,135 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *t
22802324 t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
22812325 }
22822326
2283- // secondTabs should have 4 tabs (1 existing + 3 new).
2284- if got := secondTabs.Count(); got != 4 {
2285- t.Fatalf("second target tab count = %d, want 4", got)
2327+ // Out-of-range switches should create a single new tab in the source window.
2328+ if got := secondTabs.Count(); got != 2 {
2329+ t.Fatalf("second target tab count = %d, want 2", got)
22862330 }
2287- if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID {
2288- t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID)
2331+ if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[1].ID {
2332+ t.Fatalf("active tab = %q, want %q", got, secondTabs.Tabs[1].ID)
22892333 }
22902334
2291- // firstTabs must be completely unchanged.
22922335 if got := firstTabs.Count(); got != 1 {
22932336 t.Fatalf("first target tab count = %d, want 1", got)
22942337 }
2338+ if got := firstTabs.ActiveTabID; got != firstTab.ID {
2339+ t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2340+ }
22952341
2296- // windowForTab should map all second window tabs to second.
2297- for _, tab := range secondTabs.Tabs {
2298- if got := app.windowForTab[tab.ID]; got != second {
2299- t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second)
2300- }
2342+ if got := app.windowForTab[secondTabs.Tabs[1].ID]; got != second {
2343+ t.Fatalf("windowForTab[%s] = %p, want second window %p", secondTabs.Tabs[1].ID, got, second)
2344+ }
2345+}
2346+
2347+func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
2348+ ctx := context.Background()
2349+
2350+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2351+ secondTabs := entity.NewTabList()
2352+ secondTabs.Add(secondTab)
2353+ secondTabs.SetActive(secondTab.ID)
2354+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2355+
2356+ app := &App{
2357+ deps: &Dependencies{
2358+ Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}},
2359+ },
2360+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2361+ browserWindows: map[string]*browserWindow{second.id: second},
2362+ windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
2363+ }
2364+ app.initTabCoordinator(ctx)
2365+
2366+ err := app.switchBrowserWindowTabIndex(ctx, second, -1)
2367+ if err != nil {
2368+ t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
2369+ }
2370+ if got := secondTabs.Count(); got != 1 {
2371+ t.Fatalf("second target tab count = %d, want 1", got)
2372+ }
2373+ if got := secondTabs.ActiveTabID; got != secondTab.ID {
2374+ t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2375+ }
2376+}
2377+
2378+func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) {
2379+ ctx := context.Background()
2380+
2381+ second := &browserWindow{id: "window-2"}
2382+
2383+ app := &App{
2384+ deps: &Dependencies{
2385+ Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}},
2386+ },
2387+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2388+ browserWindows: map[string]*browserWindow{second.id: second},
2389+ lastFocusedWindowID: "window-1",
2390+ }
2391+ app.initTabCoordinator(ctx)
2392+
2393+ err := app.switchBrowserWindowTabIndex(ctx, second, -1)
2394+ if err != nil {
2395+ t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
2396+ }
2397+ if got := app.lastFocusedWindowID; got != "window-1" {
2398+ t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
2399+ }
2400+ if second.tabs != nil {
2401+ t.Fatalf("second tabs should stay nil for invalid index")
2402+ }
2403+}
2404+
2405+func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
2406+ ctx := context.Background()
2407+
2408+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2409+ secondTabs := entity.NewTabList()
2410+ secondTabs.Add(secondTab)
2411+ secondTabs.SetActive(secondTab.ID)
2412+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2413+
2414+ app := &App{
2415+ deps: &Dependencies{Config: &config.Config{}},
2416+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2417+ browserWindows: map[string]*browserWindow{second.id: second},
2418+ windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
2419+ }
2420+ app.initTabCoordinator(ctx)
2421+
2422+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2423+ if err == nil {
2424+ t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
2425+ }
2426+ if got := secondTabs.Count(); got != 1 {
2427+ t.Fatalf("second target tab count = %d, want 1", got)
2428+ }
2429+ if got := secondTabs.ActiveTabID; got != secondTab.ID {
2430+ t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2431+ }
2432+}
2433+
2434+func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) {
2435+ ctx := context.Background()
2436+
2437+ second := &browserWindow{id: "window-2"}
2438+
2439+ app := &App{
2440+ deps: &Dependencies{Config: &config.Config{}},
2441+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2442+ browserWindows: map[string]*browserWindow{second.id: second},
2443+ lastFocusedWindowID: "window-1",
2444+ }
2445+ app.initTabCoordinator(ctx)
2446+
2447+ err := app.switchBrowserWindowTabIndex(ctx, second, 3)
2448+ if err == nil {
2449+ t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error")
2450+ }
2451+ if got := app.lastFocusedWindowID; got != "window-1" {
2452+ t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
2453+ }
2454+ if second.tabs != nil {
2455+ t.Fatalf("second tabs should stay nil when new pane URL is missing")
23012456 }
23022457 }
23032458
@@ -2243,10 +2243,54 @@ func counterIDGen() func() string {
2243 }2243 }
2244 }2244 }
2245 2245
2246-func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) {2246+func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testing.T) {
2247+ ctx := context.Background()
2248+
2249+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2250+ firstTabs := entity.NewTabList()
2251+ firstTabs.Add(firstTab)
2252+ firstTabs.SetActive(firstTab.ID)
2253+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2254+
2255+ secondTab1 := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2256+ secondTab2 := entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2")))
2257+ secondTabs := entity.NewTabList()
2258+ secondTabs.Add(secondTab1)
2259+ secondTabs.Add(secondTab2)
2260+ secondTabs.SetActive(secondTab1.ID)
2261+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2262+
2263+ app := &App{
2264+ deps: &Dependencies{
2265+ Config: &config.Config{
2266+ Workspace: config.WorkspaceConfig{
2267+ NewPaneURL: "about:blank",
2268+ },
2269+ },
2270+ },
2271+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2272+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2273+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
2274+ lastFocusedWindowID: first.id, // stale — should NOT be used
2275+ }
2276+ app.initTabCoordinator(ctx)
2277+
2278+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex2)
2279+ if err != nil {
2280+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2281+ }
2282+
2283+ if got := secondTabs.ActiveTabID; got != secondTab2.ID {
2284+ t.Fatalf("second active tab = %q, want %q", got, secondTab2.ID)
2285+ }
2286+ if got := firstTabs.ActiveTabID; got != firstTab.ID {
2287+ t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2288+ }
2289+}
2290+
2291+func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t *testing.T) {
2247 ctx := context.Background()2292 ctx := context.Background()
2248 2293
2249- // Two windows, each with one tab.
2250 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))2294 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2251 firstTabs := entity.NewTabList()2295 firstTabs := entity.NewTabList()
2252 firstTabs.Add(firstTab)2296 firstTabs.Add(firstTab)
@@ -2280,24 +2324,135 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *t
2280 t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)2324 t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2281 }2325 }
2282 2326
2283- // secondTabs should have 4 tabs (1 existing + 3 new).2327+ // Out-of-range switches should create a single new tab in the source window.
2284- if got := secondTabs.Count(); got != 4 {2328+ if got := secondTabs.Count(); got != 2 {
2285- t.Fatalf("second target tab count = %d, want 4", got)2329+ t.Fatalf("second target tab count = %d, want 2", got)
2286 }2330 }
2287- if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID {2331+ if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[1].ID {
2288- t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID)2332+ t.Fatalf("active tab = %q, want %q", got, secondTabs.Tabs[1].ID)
2289 }2333 }
2290 2334
2291- // firstTabs must be completely unchanged.
2292 if got := firstTabs.Count(); got != 1 {2335 if got := firstTabs.Count(); got != 1 {
2293 t.Fatalf("first target tab count = %d, want 1", got)2336 t.Fatalf("first target tab count = %d, want 1", got)
2294 }2337 }
2338+ if got := firstTabs.ActiveTabID; got != firstTab.ID {
2339+ t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2340+ }
2295 2341
2296- // windowForTab should map all second window tabs to second.2342+ if got := app.windowForTab[secondTabs.Tabs[1].ID]; got != second {
2297- for _, tab := range secondTabs.Tabs {2343+ t.Fatalf("windowForTab[%s] = %p, want second window %p", secondTabs.Tabs[1].ID, got, second)
2298- if got := app.windowForTab[tab.ID]; got != second {2344+ }
2299- t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second)2345+}
2300- }2346+
2347+func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
2348+ ctx := context.Background()
2349+
2350+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2351+ secondTabs := entity.NewTabList()
2352+ secondTabs.Add(secondTab)
2353+ secondTabs.SetActive(secondTab.ID)
2354+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2355+
2356+ app := &App{
2357+ deps: &Dependencies{
2358+ Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}},
2359+ },
2360+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2361+ browserWindows: map[string]*browserWindow{second.id: second},
2362+ windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
2363+ }
2364+ app.initTabCoordinator(ctx)
2365+
2366+ err := app.switchBrowserWindowTabIndex(ctx, second, -1)
2367+ if err != nil {
2368+ t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
2369+ }
2370+ if got := secondTabs.Count(); got != 1 {
2371+ t.Fatalf("second target tab count = %d, want 1", got)
2372+ }
2373+ if got := secondTabs.ActiveTabID; got != secondTab.ID {
2374+ t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2375+ }
2376+}
2377+
2378+func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) {
2379+ ctx := context.Background()
2380+
2381+ second := &browserWindow{id: "window-2"}
2382+
2383+ app := &App{
2384+ deps: &Dependencies{
2385+ Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}},
2386+ },
2387+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2388+ browserWindows: map[string]*browserWindow{second.id: second},
2389+ lastFocusedWindowID: "window-1",
2390+ }
2391+ app.initTabCoordinator(ctx)
2392+
2393+ err := app.switchBrowserWindowTabIndex(ctx, second, -1)
2394+ if err != nil {
2395+ t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
2396+ }
2397+ if got := app.lastFocusedWindowID; got != "window-1" {
2398+ t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
2399+ }
2400+ if second.tabs != nil {
2401+ t.Fatalf("second tabs should stay nil for invalid index")
2402+ }
2403+}
2404+
2405+func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
2406+ ctx := context.Background()
2407+
2408+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2409+ secondTabs := entity.NewTabList()
2410+ secondTabs.Add(secondTab)
2411+ secondTabs.SetActive(secondTab.ID)
2412+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2413+
2414+ app := &App{
2415+ deps: &Dependencies{Config: &config.Config{}},
2416+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2417+ browserWindows: map[string]*browserWindow{second.id: second},
2418+ windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
2419+ }
2420+ app.initTabCoordinator(ctx)
2421+
2422+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2423+ if err == nil {
2424+ t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
2425+ }
2426+ if got := secondTabs.Count(); got != 1 {
2427+ t.Fatalf("second target tab count = %d, want 1", got)
2428+ }
2429+ if got := secondTabs.ActiveTabID; got != secondTab.ID {
2430+ t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2431+ }
2432+}
2433+
2434+func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) {
2435+ ctx := context.Background()
2436+
2437+ second := &browserWindow{id: "window-2"}
2438+
2439+ app := &App{
2440+ deps: &Dependencies{Config: &config.Config{}},
2441+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2442+ browserWindows: map[string]*browserWindow{second.id: second},
2443+ lastFocusedWindowID: "window-1",
2444+ }
2445+ app.initTabCoordinator(ctx)
2446+
2447+ err := app.switchBrowserWindowTabIndex(ctx, second, 3)
2448+ if err == nil {
2449+ t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error")
2450+ }
2451+ if got := app.lastFocusedWindowID; got != "window-1" {
2452+ t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
2453+ }
2454+ if second.tabs != nil {
2455+ t.Fatalf("second tabs should stay nil when new pane URL is missing")
2301 }2456 }
2302 }2457 }
2303 2458
M internal/ui/input/global_shortcuts.go +2 -0
@@ -429,6 +429,8 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
429429 ActionPrintPage,
430430 ActionOpenOmnibox,
431431 ActionOpenFind,
432+ ActionFindNext,
433+ ActionFindPrev,
432434 ActionOpenDevTools,
433435 ActionToggleFullscreen,
434436 ActionToggleFloatingPane,
@@ -429,6 +429,8 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
429 ActionPrintPage,429 ActionPrintPage,
430 ActionOpenOmnibox,430 ActionOpenOmnibox,
431 ActionOpenFind,431 ActionOpenFind,
432+ ActionFindNext,
433+ ActionFindPrev,
432 ActionOpenDevTools,434 ActionOpenDevTools,
433 ActionToggleFullscreen,435 ActionToggleFullscreen,
434 ActionToggleFloatingPane,436 ActionToggleFloatingPane,
M internal/ui/input/global_shortcuts_test.go +2 -0
@@ -129,6 +129,8 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
129129 ActionToggleHistorySystemView,
130130 ActionOpenOmnibox,
131131 ActionOpenFind,
132+ ActionFindNext,
133+ ActionFindPrev,
132134 ActionOpenDevTools,
133135 ActionToggleFloatingPane,
134136 ActionToggleFavoritesSystemView,
@@ -129,6 +129,8 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
129 ActionToggleHistorySystemView,129 ActionToggleHistorySystemView,
130 ActionOpenOmnibox,130 ActionOpenOmnibox,
131 ActionOpenFind,131 ActionOpenFind,
132+ ActionFindNext,
133+ ActionFindPrev,
132 ActionOpenDevTools,134 ActionOpenDevTools,
133 ActionToggleFloatingPane,135 ActionToggleFloatingPane,
134 ActionToggleFavoritesSystemView,136 ActionToggleFavoritesSystemView,

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