detached 3929f92

fix(cef): stabilize osr browser creation and tab shortcuts

commit
3929f92a8d304179ae7ffab53c9d8b7d3b9fa233
author
brice · · 2026-05-21 05:15 UTC
parent
ac5bf21
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +581 -60
M README.md +1 -1
@@ -103,7 +103,7 @@ 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 by default when `engine.cef.adaptive_windowless_frame_rate = true`. Adaptive mode is capped by `engine.cef.windowless_frame_rate_max = 240`. To use a fixed frame rate instead, set `engine.cef.windowless_frame_rate` to a positive value.
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.
107107
108108 ## Keyboard-Driven
109109
@@ -103,7 +103,7 @@ 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 by default when `engine.cef.adaptive_windowless_frame_rate = true`. Adaptive mode is capped by `engine.cef.windowless_frame_rate_max = 240`. To use a fixed frame rate instead, set `engine.cef.windowless_frame_rate` to a positive value.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 107
108 ## Keyboard-Driven108 ## Keyboard-Driven
109 109
M docs/config/index.md +1 -1
@@ -192,7 +192,7 @@ border = "#363636"
192192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |
193193 | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) |
194194 | `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 uses the built-in default cap |
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 |
196196 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |
197197 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |
198198
@@ -192,7 +192,7 @@ border = "#363636"
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`) |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 |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 uses the built-in default cap |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 |
196 | `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%) |
197 | `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%) |
198 198
M go.mod +2 -1
@@ -7,7 +7,7 @@ require (
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
99 github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.5.0
10+ github.com/bnema/purego-cef2gtk v0.5.1
1111 github.com/bnema/purego-pipewire v0.1.3
1212 github.com/bnema/purego-sqlite v0.1.2
1313 github.com/bnema/puregotk v0.5.2
@@ -83,3 +83,4 @@ require (
8383 )
8484
8585 tool github.com/a-h/templ/cmd/templ
86+
@@ -7,7 +7,7 @@ require (
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9 github.com/bnema/purego-cef v0.13.09 github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.5.010+ github.com/bnema/purego-cef2gtk v0.5.1
11 github.com/bnema/purego-pipewire v0.1.311 github.com/bnema/purego-pipewire v0.1.3
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
13 github.com/bnema/puregotk v0.5.213 github.com/bnema/puregotk v0.5.2
@@ -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 go.sum +2 -0 generated file: collapsed
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 }
M internal/infrastructure/cef/cef2gtk_adapter.go +15 -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 {
@@ -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 {
M internal/infrastructure/cef/factory.go +6 -6
@@ -160,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
160160 if wv.viewBridge == nil {
161161 return
162162 }
163- browserCreateScheduled := false
164163 wv.runOnGTKSync(func() {
165164 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
166- // Size observers run on the GTK thread. Prepare the GtkGLArea only after it
167- // has been packed and allocated; preparing at WebView construction time can
168- // run before GTK has created a GL context.
169- 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() {
170170 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
171171 logging.FromContext(ctx).Warn().
172172 Err(err).
@@ -176,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
176176 Msg("cef: cef2gtk view not ready after resize, will retry")
177177 return
178178 }
179- browserCreateScheduled = true
179+ wv.markInitialBrowserCreateResizeHandled()
180180 onFirstResize(w, h)
181181 return
182182 }
@@ -160,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
160 if wv.viewBridge == nil {160 if wv.viewBridge == nil {
161 return161 return
162 }162 }
163- browserCreateScheduled := false
164 wv.runOnGTKSync(func() {163 wv.runOnGTKSync(func() {
165 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {164 wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
166- // 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
167- // has been packed and allocated; preparing at WebView construction time can166+ // the one-shot initial resize path; once that path has been consumed,
168- // run before GTK has created a GL context.167+ // later size events must go through normal viewport sync even if popup
169- if !browserCreateScheduled {168+ // attachment keeps pendingCreate alive a little longer.
169+ if wv.shouldStartBrowserCreateFromSizeObserver() {
170 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {170 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
171 logging.FromContext(ctx).Warn().171 logging.FromContext(ctx).Warn().
172 Err(err).172 Err(err).
@@ -176,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
176 Msg("cef: cef2gtk view not ready after resize, will retry")176 Msg("cef: cef2gtk view not ready after resize, will retry")
177 return177 return
178 }178 }
179- browserCreateScheduled = true179+ wv.markInitialBrowserCreateResizeHandled()
180 onFirstResize(w, h)180 onFirstResize(w, h)
181 return181 return
182 }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/webview.go +29 -5
@@ -116,6 +116,10 @@ type WebView struct {
116116
117117 // pendingCreate holds browser creation params until the GL area is realized.
118118 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
119123
120124 // pendingURI is set when LoadURI is called before the browser exists.
121125 pendingURI string
@@ -211,11 +215,13 @@ type WebView struct {
211215 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
212216 // deferred until the GL area has a non-zero size.
213217 type pendingBrowserCreate struct {
214- windowInfo *purecef.WindowInfo
215- client purecef.RawClient
216- settings *purecef.BrowserSettings
217- extraInfo purecef.DictionaryValue
218- postTaskRetries int
218+ windowInfo *purecef.WindowInfo
219+ client purecef.RawClient
220+ settings *purecef.BrowserSettings
221+ extraInfo purecef.DictionaryValue
222+ postTaskRetries int
223+ observedSizeRetries int
224+ observedSizeRetryScheduled bool
219225 }
220226
221227 type cefTaskFunc func()
@@ -1860,6 +1866,24 @@ func (wv *WebView) runNavigationCallbacks(uri string) {
18601866 })
18611867 }
18621868
1869+func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool {
1870+ if wv == nil {
1871+ return false
1872+ }
1873+ wv.mu.RLock()
1874+ defer wv.mu.RUnlock()
1875+ return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled
1876+}
1877+
1878+func (wv *WebView) markInitialBrowserCreateResizeHandled() {
1879+ if wv == nil {
1880+ return
1881+ }
1882+ wv.mu.Lock()
1883+ defer wv.mu.Unlock()
1884+ wv.initialBrowserCreateResizeHandled = true
1885+}
1886+
18631887 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
18641888 wv.mu.Lock()
18651889 defer wv.mu.Unlock()
@@ -116,6 +116,10 @@ type WebView struct {
116 116
117 // pendingCreate holds browser creation params until the GL area is realized.117 // pendingCreate holds browser creation params until the GL area is realized.
118 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
119 123
120 // pendingURI is set when LoadURI is called before the browser exists.124 // pendingURI is set when LoadURI is called before the browser exists.
121 pendingURI string125 pendingURI string
@@ -211,11 +215,13 @@ type WebView struct {
211 // pendingBrowserCreate holds the parameters needed to create a CEF browser,215 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
212 // deferred until the GL area has a non-zero size.216 // deferred until the GL area has a non-zero size.
213 type pendingBrowserCreate struct {217 type pendingBrowserCreate struct {
214- windowInfo *purecef.WindowInfo218+ windowInfo *purecef.WindowInfo
215- client purecef.RawClient219+ client purecef.RawClient
216- settings *purecef.BrowserSettings220+ settings *purecef.BrowserSettings
217- extraInfo purecef.DictionaryValue221+ extraInfo purecef.DictionaryValue
218- postTaskRetries int222+ postTaskRetries int
223+ observedSizeRetries int
224+ observedSizeRetryScheduled bool
219 }225 }
220 226
221 type cefTaskFunc func()227 type cefTaskFunc func()
@@ -1860,6 +1866,24 @@ func (wv *WebView) runNavigationCallbacks(uri string) {
1860 })1866 })
1861 }1867 }
1862 1868
1869+func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool {
1870+ if wv == nil {
1871+ return false
1872+ }
1873+ wv.mu.RLock()
1874+ defer wv.mu.RUnlock()
1875+ return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled
1876+}
1877+
1878+func (wv *WebView) markInitialBrowserCreateResizeHandled() {
1879+ if wv == nil {
1880+ return
1881+ }
1882+ wv.mu.Lock()
1883+ defer wv.mu.Unlock()
1884+ wv.initialBrowserCreateResizeHandled = true
1885+}
1886+
1863 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {1887 func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
1864 wv.mu.Lock()1888 wv.mu.Lock()
1865 defer wv.mu.Unlock()1889 defer wv.mu.Unlock()
M internal/infrastructure/cef/webview_handlers.go +2 -2
@@ -669,8 +669,8 @@ func (h *handlerSet) attachAfterCreatedBrowser(
669669 if h.wv != nil {
670670 if h.wv.ctx != nil {
671671 logging.FromContext(h.wv.ctx).Info().
672- Int32("windowless_frame_rate_runtime", host.GetWindowlessFrameRate()).
673- Msg("cef: browser runtime windowless frame rate")
672+ Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
673+ Msg("cef: browser initial/configured windowless frame rate")
674674 }
675675 h.wv.SyncViewport(h.wv.ctx, "after-created")
676676 }
@@ -669,8 +669,8 @@ func (h *handlerSet) attachAfterCreatedBrowser(
669 if h.wv != nil {669 if h.wv != nil {
670 if h.wv.ctx != nil {670 if h.wv.ctx != nil {
671 logging.FromContext(h.wv.ctx).Info().671 logging.FromContext(h.wv.ctx).Info().
672- Int32("windowless_frame_rate_runtime", host.GetWindowlessFrameRate()).672+ Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
673- Msg("cef: browser runtime windowless frame rate")673+ Msg("cef: browser initial/configured windowless frame rate")
674 }674 }
675 h.wv.SyncViewport(h.wv.ctx, "after-created")675 h.wv.SyncViewport(h.wv.ctx, "after-created")
676 }676 }
M internal/infrastructure/cef/webview_viewport.go +192 -0
@@ -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
@@ -182,6 +206,64 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
182206 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
183207 return false
184208 }
209+
210+ observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height)
211+ if !observedReady {
212+ widget.QueueAllocate()
213+ widget.QueueResize()
214+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason)
215+ switch action {
216+ case pendingBrowserCreateObservedSizeRetryUnavailable:
217+ return false
218+ case pendingBrowserCreateObservedSizeRetryAlreadyScheduled:
219+ logging.FromContext(ctx).Debug().
220+ Uint64("webview_id", uint64(wv.id)).
221+ Int32("observed_width", observedWidth).
222+ Int32("observed_height", observedHeight).
223+ Int32("width", width).
224+ Int32("height", height).
225+ Int("attempt", attempt).
226+ Str("reason", reason).
227+ Msg("cef: pending browser creation retry already scheduled while awaiting bridge size")
228+ return false
229+ case pendingBrowserCreateObservedSizeRetryScheduled:
230+ logging.FromContext(ctx).Debug().
231+ Uint64("webview_id", uint64(wv.id)).
232+ Int32("observed_width", observedWidth).
233+ Int32("observed_height", observedHeight).
234+ Int32("width", width).
235+ Int32("height", height).
236+ Int("attempt", attempt).
237+ Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries).
238+ Str("reason", reason).
239+ Msg("cef: delaying pending browser creation until bridge observes widget size")
240+ return false
241+ case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay:
242+ // Creating the browser here is intentional: after bounded retries we
243+ // fall back to the current GTK allocation rather than stalling popup
244+ // creation.
245+ logging.FromContext(ctx).Debug().
246+ Uint64("webview_id", uint64(wv.id)).
247+ Int32("observed_width", observedWidth).
248+ Int32("observed_height", observedHeight).
249+ Int32("width", width).
250+ Int32("height", height).
251+ Int("attempt", attempt).
252+ Str("reason", reason).
253+ Msg("cef: bridge size observation did not settle before browser creation; continuing")
254+ }
255+ }
256+
257+ if wv.shouldDeferPendingBrowserCreateFromViewportSync() {
258+ logging.FromContext(ctx).Debug().
259+ Uint64("webview_id", uint64(wv.id)).
260+ Int32("width", width).
261+ Int32("height", height).
262+ Str("reason", reason).
263+ Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach")
264+ return false
265+ }
266+
185267 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
186268 logging.FromContext(ctx).Debug().
187269 Err(err).
@@ -193,6 +275,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
193275 return false
194276 }
195277
278+ wv.markInitialBrowserCreateResizeHandled()
196279 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
197280 logging.FromContext(ctx).Debug().
198281 Uint64("webview_id", uint64(wv.id)).
@@ -203,6 +286,115 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
203286 return true
204287 }
205288
289+func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool {
290+ return wv != nil && wv.awaitsNativePopupAttachment()
291+}
292+
293+func pendingBrowserCreateObservedSize(bridge pendingBrowserCreateObservedSizeBridge, allocatedWidth, allocatedHeight int32) (observedWidth, observedHeight int32, ready bool) {
294+ if bridge == nil {
295+ return 0, 0, false
296+ }
297+ observedWidth, observedHeight = bridge.Size()
298+ if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) {
299+ return observedWidth, observedHeight, true
300+ }
301+ observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread()
302+ return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight)
303+}
304+
305+// observedViewportSizeReady reports whether observedWidth/observedHeight match
306+// allocatedWidth/allocatedHeight within a ±1px tolerance.
307+//
308+// Fallback observed sizes (<=1) and non-positive allocated dimensions are
309+// treated as not ready.
310+func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool {
311+ if allocatedWidth <= 0 || allocatedHeight <= 0 {
312+ return false
313+ }
314+ if observedWidth <= 1 || observedHeight <= 1 {
315+ return false
316+ }
317+ return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1
318+}
319+
320+// abs32 returns the absolute value of v.
321+func abs32(v int32) int32 {
322+ if v < 0 {
323+ return -v
324+ }
325+ return v
326+}
327+
328+// preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry
329+// state machine for a pending browser create.
330+//
331+// It returns the current attempt count and the resulting action. Scheduling is
332+// coalesced so only one delayed retry is pending at a time, while the max-retry
333+// path proceeds without leaving the scheduled flag set.
334+func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) {
335+ if wv == nil {
336+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
337+ }
338+
339+ shouldSchedule := false
340+ wv.mu.Lock()
341+ if wv.pendingCreate == nil {
342+ wv.mu.Unlock()
343+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
344+ }
345+ if wv.pendingCreate.observedSizeRetryScheduled {
346+ attempt = wv.pendingCreate.observedSizeRetries
347+ wv.mu.Unlock()
348+ return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled
349+ }
350+ wv.pendingCreate.observedSizeRetries++
351+ attempt = wv.pendingCreate.observedSizeRetries
352+ if attempt <= pendingBrowserCreateObservedSizeMaxRetries {
353+ wv.pendingCreate.observedSizeRetryScheduled = true
354+ shouldSchedule = true
355+ }
356+ wv.mu.Unlock()
357+
358+ if !shouldSchedule {
359+ return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
360+ }
361+ wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason)
362+ return attempt, pendingBrowserCreateObservedSizeRetryScheduled
363+}
364+
365+// clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag
366+// so a future observed-size reservation can schedule another attempt.
367+func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() {
368+ if wv == nil {
369+ return
370+ }
371+ wv.mu.Lock()
372+ defer wv.mu.Unlock()
373+ if wv.pendingCreate != nil {
374+ wv.pendingCreate.observedSizeRetryScheduled = false
375+ }
376+}
377+
378+// schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport
379+// sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay.
380+//
381+// It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a
382+// retry-specific suffix appended to reason.
383+func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) {
384+ if wv == nil || wv.destroyed.Load() {
385+ return
386+ }
387+ cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() {
388+ if wv == nil || wv.destroyed.Load() {
389+ return
390+ }
391+ wv.clearPendingBrowserCreateObservedSizeRetry()
392+ wv.runOnGTK(func() {
393+ wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size")
394+ })
395+ })
396+}
397+
206398 func (wv *WebView) installViewportSyncHooks() {
207399 if wv == nil || wv.viewBridge == nil {
208400 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
@@ -182,6 +206,64 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
182 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")206 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
183 return false207 return false
184 }208 }
209+
210+ observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height)
211+ if !observedReady {
212+ widget.QueueAllocate()
213+ widget.QueueResize()
214+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason)
215+ switch action {
216+ case pendingBrowserCreateObservedSizeRetryUnavailable:
217+ return false
218+ case pendingBrowserCreateObservedSizeRetryAlreadyScheduled:
219+ logging.FromContext(ctx).Debug().
220+ Uint64("webview_id", uint64(wv.id)).
221+ Int32("observed_width", observedWidth).
222+ Int32("observed_height", observedHeight).
223+ Int32("width", width).
224+ Int32("height", height).
225+ Int("attempt", attempt).
226+ Str("reason", reason).
227+ Msg("cef: pending browser creation retry already scheduled while awaiting bridge size")
228+ return false
229+ case pendingBrowserCreateObservedSizeRetryScheduled:
230+ logging.FromContext(ctx).Debug().
231+ Uint64("webview_id", uint64(wv.id)).
232+ Int32("observed_width", observedWidth).
233+ Int32("observed_height", observedHeight).
234+ Int32("width", width).
235+ Int32("height", height).
236+ Int("attempt", attempt).
237+ Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries).
238+ Str("reason", reason).
239+ Msg("cef: delaying pending browser creation until bridge observes widget size")
240+ return false
241+ case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay:
242+ // Creating the browser here is intentional: after bounded retries we
243+ // fall back to the current GTK allocation rather than stalling popup
244+ // creation.
245+ logging.FromContext(ctx).Debug().
246+ Uint64("webview_id", uint64(wv.id)).
247+ Int32("observed_width", observedWidth).
248+ Int32("observed_height", observedHeight).
249+ Int32("width", width).
250+ Int32("height", height).
251+ Int("attempt", attempt).
252+ Str("reason", reason).
253+ Msg("cef: bridge size observation did not settle before browser creation; continuing")
254+ }
255+ }
256+
257+ if wv.shouldDeferPendingBrowserCreateFromViewportSync() {
258+ logging.FromContext(ctx).Debug().
259+ Uint64("webview_id", uint64(wv.id)).
260+ Int32("width", width).
261+ Int32("height", height).
262+ Str("reason", reason).
263+ Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach")
264+ return false
265+ }
266+
185 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {267 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
186 logging.FromContext(ctx).Debug().268 logging.FromContext(ctx).Debug().
187 Err(err).269 Err(err).
@@ -193,6 +275,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
193 return false275 return false
194 }276 }
195 277
278+ wv.markInitialBrowserCreateResizeHandled()
196 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)279 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
197 logging.FromContext(ctx).Debug().280 logging.FromContext(ctx).Debug().
198 Uint64("webview_id", uint64(wv.id)).281 Uint64("webview_id", uint64(wv.id)).
@@ -203,6 +286,115 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context,
203 return true286 return true
204 }287 }
205 288
289+func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool {
290+ return wv != nil && wv.awaitsNativePopupAttachment()
291+}
292+
293+func pendingBrowserCreateObservedSize(bridge pendingBrowserCreateObservedSizeBridge, allocatedWidth, allocatedHeight int32) (observedWidth, observedHeight int32, ready bool) {
294+ if bridge == nil {
295+ return 0, 0, false
296+ }
297+ observedWidth, observedHeight = bridge.Size()
298+ if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) {
299+ return observedWidth, observedHeight, true
300+ }
301+ observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread()
302+ return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight)
303+}
304+
305+// observedViewportSizeReady reports whether observedWidth/observedHeight match
306+// allocatedWidth/allocatedHeight within a ±1px tolerance.
307+//
308+// Fallback observed sizes (<=1) and non-positive allocated dimensions are
309+// treated as not ready.
310+func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool {
311+ if allocatedWidth <= 0 || allocatedHeight <= 0 {
312+ return false
313+ }
314+ if observedWidth <= 1 || observedHeight <= 1 {
315+ return false
316+ }
317+ return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1
318+}
319+
320+// abs32 returns the absolute value of v.
321+func abs32(v int32) int32 {
322+ if v < 0 {
323+ return -v
324+ }
325+ return v
326+}
327+
328+// preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry
329+// state machine for a pending browser create.
330+//
331+// It returns the current attempt count and the resulting action. Scheduling is
332+// coalesced so only one delayed retry is pending at a time, while the max-retry
333+// path proceeds without leaving the scheduled flag set.
334+func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) {
335+ if wv == nil {
336+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
337+ }
338+
339+ shouldSchedule := false
340+ wv.mu.Lock()
341+ if wv.pendingCreate == nil {
342+ wv.mu.Unlock()
343+ return 0, pendingBrowserCreateObservedSizeRetryUnavailable
344+ }
345+ if wv.pendingCreate.observedSizeRetryScheduled {
346+ attempt = wv.pendingCreate.observedSizeRetries
347+ wv.mu.Unlock()
348+ return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled
349+ }
350+ wv.pendingCreate.observedSizeRetries++
351+ attempt = wv.pendingCreate.observedSizeRetries
352+ if attempt <= pendingBrowserCreateObservedSizeMaxRetries {
353+ wv.pendingCreate.observedSizeRetryScheduled = true
354+ shouldSchedule = true
355+ }
356+ wv.mu.Unlock()
357+
358+ if !shouldSchedule {
359+ return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
360+ }
361+ wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason)
362+ return attempt, pendingBrowserCreateObservedSizeRetryScheduled
363+}
364+
365+// clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag
366+// so a future observed-size reservation can schedule another attempt.
367+func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() {
368+ if wv == nil {
369+ return
370+ }
371+ wv.mu.Lock()
372+ defer wv.mu.Unlock()
373+ if wv.pendingCreate != nil {
374+ wv.pendingCreate.observedSizeRetryScheduled = false
375+ }
376+}
377+
378+// schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport
379+// sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay.
380+//
381+// It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a
382+// retry-specific suffix appended to reason.
383+func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) {
384+ if wv == nil || wv.destroyed.Load() {
385+ return
386+ }
387+ cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() {
388+ if wv == nil || wv.destroyed.Load() {
389+ return
390+ }
391+ wv.clearPendingBrowserCreateObservedSizeRetry()
392+ wv.runOnGTK(func() {
393+ wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size")
394+ })
395+ })
396+}
397+
206 func (wv *WebView) installViewportSyncHooks() {398 func (wv *WebView) installViewportSyncHooks() {
207 if wv == nil || wv.viewBridge == nil {399 if wv == nil || wv.viewBridge == nil {
208 return400 return
M internal/infrastructure/cef/webview_viewport_test.go +161 -0
@@ -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 {
@@ -149,6 +171,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) {
149171 require.Empty(t, host.calls)
150172 }
151173
174+func TestObservedViewportSizeReady(t *testing.T) {
175+ tests := []struct {
176+ name string
177+ observedWidth int32
178+ observedHeight int32
179+ allocatedWidth int32
180+ allocatedHeight int32
181+ want bool
182+ }{
183+ {
184+ name: "exact match",
185+ observedWidth: 793,
186+ observedHeight: 1753,
187+ allocatedWidth: 793,
188+ allocatedHeight: 1753,
189+ want: true,
190+ },
191+ {
192+ name: "one pixel drift is accepted",
193+ observedWidth: 792,
194+ observedHeight: 1754,
195+ allocatedWidth: 793,
196+ allocatedHeight: 1753,
197+ want: true,
198+ },
199+ {
200+ name: "fallback observed size is rejected",
201+ observedWidth: 1,
202+ observedHeight: 1,
203+ allocatedWidth: 793,
204+ allocatedHeight: 1753,
205+ want: false,
206+ },
207+ {
208+ name: "missing allocation is rejected",
209+ observedWidth: 793,
210+ observedHeight: 1753,
211+ allocatedWidth: 0,
212+ allocatedHeight: 1753,
213+ want: false,
214+ },
215+ {
216+ name: "large mismatch is rejected",
217+ observedWidth: 1200,
218+ observedHeight: 1400,
219+ allocatedWidth: 1587,
220+ allocatedHeight: 1679,
221+ want: false,
222+ },
223+ }
224+
225+ for _, tt := range tests {
226+ t.Run(tt.name, func(t *testing.T) {
227+ require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight))
228+ })
229+ }
230+}
231+
232+func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) {
233+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{
234+ sizeWidth: 1,
235+ sizeHeight: 1,
236+ refreshWidth: 1055,
237+ refreshHeight: 1719,
238+ refreshProvided: true,
239+ }
240+
241+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
242+
243+ require.True(t, ready)
244+ require.Equal(t, int32(1055), observedWidth)
245+ require.Equal(t, int32(1719), observedHeight)
246+ require.Equal(t, 1, bridge.refreshCalls)
247+}
248+
249+func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) {
250+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719}
251+
252+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
253+
254+ require.True(t, ready)
255+ require.Equal(t, int32(1055), observedWidth)
256+ require.Equal(t, int32(1719), observedHeight)
257+ require.Equal(t, 0, bridge.refreshCalls)
258+}
259+
260+func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) {
261+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
262+
263+ require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
264+
265+ wv.nativePopupFallbackStarted = true
266+ require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
267+}
268+
269+func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) {
270+ oldScheduleAfter := cefScheduleAfter
271+ defer func() { cefScheduleAfter = oldScheduleAfter }()
272+
273+ scheduleCalls := 0
274+ cefScheduleAfter = func(delay time.Duration, fn func()) {
275+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
276+ require.NotNil(t, fn)
277+ scheduleCalls++
278+ }
279+
280+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
281+
282+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
283+ require.Equal(t, 1, attempt)
284+ require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action)
285+ require.Equal(t, 1, scheduleCalls)
286+
287+ attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
288+ require.Equal(t, 1, attempt)
289+ require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action)
290+ require.Equal(t, 1, scheduleCalls)
291+}
292+
293+func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) {
294+ oldScheduleAfter := cefScheduleAfter
295+ defer func() { cefScheduleAfter = oldScheduleAfter }()
296+
297+ scheduleCalls := 0
298+ cefScheduleAfter = func(delay time.Duration, fn func()) {
299+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
300+ require.NotNil(t, fn)
301+ scheduleCalls++
302+ }
303+
304+ wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}}
305+
306+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
307+ require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt)
308+ require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action)
309+ require.Equal(t, 0, scheduleCalls)
310+ require.False(t, wv.pendingCreate.observedSizeRetryScheduled)
311+}
312+
152313 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
153314 oldNewTask := cefNewTask
154315 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 {
@@ -149,6 +171,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) {
149 require.Empty(t, host.calls)171 require.Empty(t, host.calls)
150 }172 }
151 173
174+func TestObservedViewportSizeReady(t *testing.T) {
175+ tests := []struct {
176+ name string
177+ observedWidth int32
178+ observedHeight int32
179+ allocatedWidth int32
180+ allocatedHeight int32
181+ want bool
182+ }{
183+ {
184+ name: "exact match",
185+ observedWidth: 793,
186+ observedHeight: 1753,
187+ allocatedWidth: 793,
188+ allocatedHeight: 1753,
189+ want: true,
190+ },
191+ {
192+ name: "one pixel drift is accepted",
193+ observedWidth: 792,
194+ observedHeight: 1754,
195+ allocatedWidth: 793,
196+ allocatedHeight: 1753,
197+ want: true,
198+ },
199+ {
200+ name: "fallback observed size is rejected",
201+ observedWidth: 1,
202+ observedHeight: 1,
203+ allocatedWidth: 793,
204+ allocatedHeight: 1753,
205+ want: false,
206+ },
207+ {
208+ name: "missing allocation is rejected",
209+ observedWidth: 793,
210+ observedHeight: 1753,
211+ allocatedWidth: 0,
212+ allocatedHeight: 1753,
213+ want: false,
214+ },
215+ {
216+ name: "large mismatch is rejected",
217+ observedWidth: 1200,
218+ observedHeight: 1400,
219+ allocatedWidth: 1587,
220+ allocatedHeight: 1679,
221+ want: false,
222+ },
223+ }
224+
225+ for _, tt := range tests {
226+ t.Run(tt.name, func(t *testing.T) {
227+ require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight))
228+ })
229+ }
230+}
231+
232+func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) {
233+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{
234+ sizeWidth: 1,
235+ sizeHeight: 1,
236+ refreshWidth: 1055,
237+ refreshHeight: 1719,
238+ refreshProvided: true,
239+ }
240+
241+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
242+
243+ require.True(t, ready)
244+ require.Equal(t, int32(1055), observedWidth)
245+ require.Equal(t, int32(1719), observedHeight)
246+ require.Equal(t, 1, bridge.refreshCalls)
247+}
248+
249+func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) {
250+ bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719}
251+
252+ observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
253+
254+ require.True(t, ready)
255+ require.Equal(t, int32(1055), observedWidth)
256+ require.Equal(t, int32(1719), observedHeight)
257+ require.Equal(t, 0, bridge.refreshCalls)
258+}
259+
260+func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) {
261+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
262+
263+ require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
264+
265+ wv.nativePopupFallbackStarted = true
266+ require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
267+}
268+
269+func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) {
270+ oldScheduleAfter := cefScheduleAfter
271+ defer func() { cefScheduleAfter = oldScheduleAfter }()
272+
273+ scheduleCalls := 0
274+ cefScheduleAfter = func(delay time.Duration, fn func()) {
275+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
276+ require.NotNil(t, fn)
277+ scheduleCalls++
278+ }
279+
280+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
281+
282+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
283+ require.Equal(t, 1, attempt)
284+ require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action)
285+ require.Equal(t, 1, scheduleCalls)
286+
287+ attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
288+ require.Equal(t, 1, attempt)
289+ require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action)
290+ require.Equal(t, 1, scheduleCalls)
291+}
292+
293+func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) {
294+ oldScheduleAfter := cefScheduleAfter
295+ defer func() { cefScheduleAfter = oldScheduleAfter }()
296+
297+ scheduleCalls := 0
298+ cefScheduleAfter = func(delay time.Duration, fn func()) {
299+ require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
300+ require.NotNil(t, fn)
301+ scheduleCalls++
302+ }
303+
304+ wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}}
305+
306+ attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
307+ require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt)
308+ require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action)
309+ require.Equal(t, 0, scheduleCalls)
310+ require.False(t, wv.pendingCreate.observedSizeRetryScheduled)
311+}
312+
152 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {313 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
153 oldNewTask := cefNewTask314 oldNewTask := cefNewTask
154 oldPostDelayedTask := cefPostDelayedTask315 oldPostDelayedTask := cefPostDelayedTask
M internal/infrastructure/config/transform_legacy.go +3 -1
@@ -1,5 +1,7 @@
11 package config
22
3+import "math"
4+
35 const (
46 sectionWorkspace = "workspace"
57 sectionSession = "session"
@@ -93,7 +95,7 @@ func int64ConfigValue(value any) (int64, bool) {
9395 case uint32:
9496 return int64(v), true
9597 case uint64:
96- if v > 1<<63-1 {
98+ if v > math.MaxInt64 {
9799 return 0, false
98100 }
99101 return int64(v), true
@@ -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"
@@ -93,7 +95,7 @@ func int64ConfigValue(value any) (int64, bool) {
93 case uint32:95 case uint32:
94 return int64(v), true96 return int64(v), true
95 case uint64:97 case uint64:
96- if v > 1<<63-1 {98+ if v > math.MaxInt64 {
97 return 0, false99 return 0, false
98 }100 }
99 return int64(v), true101 return int64(v), true
M internal/ui/app.go +17 -5
@@ -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")
@@ -2359,11 +2363,19 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
23592363 }
23602364 a.activateBrowserWindow(bw)
23612365 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)
2366+ if index < 0 {
2367+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index")
2368+ return nil
2369+ }
2370+ if index < target.Tabs.Count() {
2371+ return a.tabCoord.SwitchByIndex(ctx, target, index)
2372+ }
2373+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
2374+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured")
2375+ return fmt.Errorf("newPaneURL is not configured")
23652376 }
2366- return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)
2377+ _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
2378+ return err
23672379 }
23682380
23692381 // 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")
@@ -2359,11 +2363,19 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
2359 }2363 }
2360 a.activateBrowserWindow(bw)2364 a.activateBrowserWindow(bw)
2361 target := a.ensureTabTargetForBrowserWindow(bw)2365 target := a.ensureTabTargetForBrowserWindow(bw)
2362- initialURL := ""2366+ if index < 0 {
2363- if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" {2367+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index")
2364- initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)2368+ return nil
2369+ }
2370+ if index < target.Tabs.Count() {
2371+ return a.tabCoord.SwitchByIndex(ctx, target, index)
2372+ }
2373+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
2374+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured")
2375+ return fmt.Errorf("newPaneURL is not configured")
2365 }2376 }
2366- return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)2377+ _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
2378+ return err
2367 }2379 }
2368 2380
2369 // windowOrder returns window IDs in registration order.2381 // windowOrder returns window IDs in registration order.
M internal/ui/app_browser_launch_test.go +116 -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,83 @@ 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_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
2379+ ctx := context.Background()
2380+
2381+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2382+ secondTabs := entity.NewTabList()
2383+ secondTabs.Add(secondTab)
2384+ secondTabs.SetActive(secondTab.ID)
2385+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2386+
2387+ app := &App{
2388+ deps: &Dependencies{Config: &config.Config{}},
2389+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2390+ browserWindows: map[string]*browserWindow{second.id: second},
2391+ windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
2392+ }
2393+ app.initTabCoordinator(ctx)
2394+
2395+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2396+ if err == nil {
2397+ t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
2398+ }
2399+ if got := secondTabs.Count(); got != 1 {
2400+ t.Fatalf("second target tab count = %d, want 1", got)
2401+ }
2402+ if got := secondTabs.ActiveTabID; got != secondTab.ID {
2403+ t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
23012404 }
23022405 }
23032406
@@ -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,83 @@ 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_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
2379+ ctx := context.Background()
2380+
2381+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2382+ secondTabs := entity.NewTabList()
2383+ secondTabs.Add(secondTab)
2384+ secondTabs.SetActive(secondTab.ID)
2385+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2386+
2387+ app := &App{
2388+ deps: &Dependencies{Config: &config.Config{}},
2389+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2390+ browserWindows: map[string]*browserWindow{second.id: second},
2391+ windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
2392+ }
2393+ app.initTabCoordinator(ctx)
2394+
2395+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2396+ if err == nil {
2397+ t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
2398+ }
2399+ if got := secondTabs.Count(); got != 1 {
2400+ t.Fatalf("second target tab count = %d, want 1", got)
2401+ }
2402+ if got := secondTabs.ActiveTabID; got != secondTab.ID {
2403+ t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2301 }2404 }
2302 }2405 }
2303 2406
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