fix(cef): stabilize osr browser creation and tab shortcuts
- commit
- 3929f92a8d304179ae7ffab53c9d8b7d3b9fa233
- author
- brice · · 2026-05-21 05:15 UTC
- parent
- ac5bf21
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 103 | 103 | ||
| 104 | 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 | 108 | ## Keyboard-Driven | |
| 109 | 109 | ||
| @@ -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-Driven | 108 | ## Keyboard-Driven | ||
| 109 | 109 | ||||
M docs/config/index.md +1 -1
| @@ -192,7 +192,7 @@ border = "#363636" | |||
| 192 | 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | |
| 193 | 193 | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) | | |
| 194 | 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 | 196 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | |
| 197 | 197 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | |
| 198 | 198 | ||
| @@ -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 ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | 9 | 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 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.3 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | 13 | github.com/bnema/puregotk v0.5.2 | |
| @@ -83,3 +83,4 @@ require ( | |||
| 83 | 83 | ) | |
| 84 | 84 | ||
| 85 | 85 | tool github.com/a-h/templ/cmd/templ | |
| 86 | + | ||
| @@ -7,7 +7,7 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | github.com/bnema/purego-cef v0.13.0 | 9 | 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 |
| 11 | github.com/bnema/purego-pipewire v0.1.3 | 11 | github.com/bnema/purego-pipewire v0.1.3 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | github.com/bnema/puregotk v0.5.2 | 13 | github.com/bnema/puregotk v0.5.2 | ||
| @@ -83,3 +83,4 @@ require ( | |||||
| 83 | ) | 83 | ) | ||
| 84 | 84 | ||||
| 85 | tool github.com/a-h/templ/cmd/templ | 85 | tool github.com/a-h/templ/cmd/templ | ||
| 86 | + | ||||
M go.sum +2 -0
| @@ -16,6 +16,8 @@ github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnv | |||
| 16 | 16 | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 17 | 17 | github.com/bnema/purego-cef2gtk v0.5.0 h1:qRCVQ/p5mnBDfKLCmD94OnzL6uGu5EPsJxvJ7CTR59Y= | |
| 18 | 18 | github.com/bnema/purego-cef2gtk v0.5.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | |
| 19 | + | github.com/bnema/purego-cef2gtk v0.5.1 h1:/34AfjhaRbQy3EoRjwUOiYtdNkdmp9KPMQhuOWhSVvI= | |
| 20 | + | github.com/bnema/purego-cef2gtk v0.5.1/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | |
| 19 | 21 | github.com/bnema/purego-pipewire v0.1.3 h1:A3jYkuMe1KseJ8yr5Am1iLvRFu6yDWM2AwUIpl88460= | |
| 20 | 22 | github.com/bnema/purego-pipewire v0.1.3/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | |
| 21 | 23 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | |
| @@ -16,6 +16,8 @@ github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnv | |||||
| 16 | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | 16 | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | ||
| 17 | github.com/bnema/purego-cef2gtk v0.5.0 h1:qRCVQ/p5mnBDfKLCmD94OnzL6uGu5EPsJxvJ7CTR59Y= | 17 | github.com/bnema/purego-cef2gtk v0.5.0 h1:qRCVQ/p5mnBDfKLCmD94OnzL6uGu5EPsJxvJ7CTR59Y= | ||
| 18 | github.com/bnema/purego-cef2gtk v0.5.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | 18 | github.com/bnema/purego-cef2gtk v0.5.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | ||
| 19 | + | github.com/bnema/purego-cef2gtk v0.5.1 h1:/34AfjhaRbQy3EoRjwUOiYtdNkdmp9KPMQhuOWhSVvI= | |||
| 20 | + | github.com/bnema/purego-cef2gtk v0.5.1/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | |||
| 19 | github.com/bnema/purego-pipewire v0.1.3 h1:A3jYkuMe1KseJ8yr5Am1iLvRFu6yDWM2AwUIpl88460= | 21 | github.com/bnema/purego-pipewire v0.1.3 h1:A3jYkuMe1KseJ8yr5Am1iLvRFu6yDWM2AwUIpl88460= | ||
| 20 | github.com/bnema/purego-pipewire v0.1.3/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | 22 | github.com/bnema/purego-pipewire v0.1.3/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | ||
| 21 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | 23 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | ||
M internal/infrastructure/audio/factory/output_test.go +14 -25
| @@ -3,6 +3,7 @@ package factory | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -133,40 +134,28 @@ func TestSelector_BothFail_ReturnsError(t *testing.T) { | |||
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | 136 | // 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. | |
| 138 | 139 | func TestNewAudioOutputFactory_ReturnsWorkingFactory(t *testing.T) { | |
| 139 | 140 | factory := NewAudioOutputFactory() | |
| 140 | 141 | if factory == nil { | |
| 141 | 142 | t.Fatal("Expected non-nil factory") | |
| 142 | 143 | } | |
| 143 | 144 | ||
| 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) | |
| 149 | 148 | } | |
| 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") | |
| 155 | 151 | } | |
| 156 | - | if stream == nil { | |
| 157 | - | t.Fatal("Expected non-nil stream") | |
| 152 | + | if selector.Fallback == nil { | |
| 153 | + | t.Fatal("Expected non-nil fallback backend") | |
| 158 | 154 | } | |
| 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) | |
| 164 | 157 | } | |
| 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) | |
| 171 | 160 | } | |
| 172 | 161 | } | |
| @@ -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 that | 136 | // TestNewAudioOutputFactory_ReturnsWorkingFactory verifies that | ||
| 136 | - | // NewAudioOutputFactory returns a working Selector with PipeWire primary | 137 | + | // 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 stream | 145 | + | 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 works | 156 | + | 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) { | |||
| 165 | 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 | 183 | // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk. | |
| 169 | 184 | // Must be called on the GTK main thread. | |
| 170 | 185 | 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( | |||
| 160 | 160 | if wv.viewBridge == nil { | |
| 161 | 161 | return | |
| 162 | 162 | } | |
| 163 | - | browserCreateScheduled := false | |
| 164 | 163 | wv.runOnGTKSync(func() { | |
| 165 | 164 | 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() { | |
| 170 | 170 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |
| 171 | 171 | logging.FromContext(ctx).Warn(). | |
| 172 | 172 | Err(err). | |
| @@ -176,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 176 | 176 | Msg("cef: cef2gtk view not ready after resize, will retry") | |
| 177 | 177 | return | |
| 178 | 178 | } | |
| 179 | - | browserCreateScheduled = true | |
| 179 | + | wv.markInitialBrowserCreateResizeHandled() | |
| 180 | 180 | onFirstResize(w, h) | |
| 181 | 181 | return | |
| 182 | 182 | } | |
| @@ -160,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||||
| 160 | if wv.viewBridge == nil { | 160 | if wv.viewBridge == nil { | ||
| 161 | return | 161 | 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 it | 165 | + | // Size observers run on the GTK thread. Prepare the GtkGLArea only for |
| 167 | - | // has been packed and allocated; preparing at WebView construction time can | 166 | + | // 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 | return | 177 | return | ||
| 178 | } | 178 | } | ||
| 179 | - | browserCreateScheduled = true | 179 | + | wv.markInitialBrowserCreateResizeHandled() |
| 180 | onFirstResize(w, h) | 180 | onFirstResize(w, h) | ||
| 181 | return | 181 | return | ||
| 182 | } | 182 | } | ||
M internal/infrastructure/cef/factory_resize_test.go +16 -0
| @@ -39,6 +39,22 @@ func TestConfigureWindowInfo_SetsAcceleratedWindowlessAndSharedTexture(t *testin | |||
| 39 | 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 | 58 | func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) { | |
| 43 | 59 | oldNewTask := cefNewTask | |
| 44 | 60 | 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 := cefNewTask | 59 | oldNewTask := cefNewTask | ||
| 44 | oldPostTask := cefPostTask | 60 | oldPostTask := cefPostTask | ||
M internal/infrastructure/cef/webview.go +29 -5
| @@ -116,6 +116,10 @@ type WebView struct { | |||
| 116 | 116 | ||
| 117 | 117 | // pendingCreate holds browser creation params until the GL area is realized. | |
| 118 | 118 | 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 | 124 | // pendingURI is set when LoadURI is called before the browser exists. | |
| 121 | 125 | pendingURI string | |
| @@ -211,11 +215,13 @@ type WebView struct { | |||
| 211 | 215 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| 212 | 216 | // deferred until the GL area has a non-zero size. | |
| 213 | 217 | 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 | |
| 219 | 225 | } | |
| 220 | 226 | ||
| 221 | 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 | 1887 | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |
| 1864 | 1888 | wv.mu.Lock() | |
| 1865 | 1889 | 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 *pendingBrowserCreate | 118 | 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 string | 125 | 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.WindowInfo | 218 | + | windowInfo *purecef.WindowInfo |
| 215 | - | client purecef.RawClient | 219 | + | client purecef.RawClient |
| 216 | - | settings *purecef.BrowserSettings | 220 | + | settings *purecef.BrowserSettings |
| 217 | - | extraInfo purecef.DictionaryValue | 221 | + | extraInfo purecef.DictionaryValue |
| 218 | - | postTaskRetries int | 222 | + | 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( | |||
| 669 | 669 | if h.wv != nil { | |
| 670 | 670 | if h.wv.ctx != nil { | |
| 671 | 671 | 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") | |
| 674 | 674 | } | |
| 675 | 675 | h.wv.SyncViewport(h.wv.ctx, "after-created") | |
| 676 | 676 | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "strings" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 9 | "github.com/bnema/puregotk/v4/gobject" | |
| @@ -17,6 +18,29 @@ type viewportSyncBrowserHost interface { | |||
| 17 | 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 | 44 | // SyncViewport requests an explicit viewport resync for CEF OSR browsers. | |
| 21 | 45 | // | |
| 22 | 46 | // 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 | 206 | Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet") | |
| 183 | 207 | 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 | 267 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |
| 186 | 268 | logging.FromContext(ctx).Debug(). | |
| 187 | 269 | Err(err). | |
| @@ -193,6 +275,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||
| 193 | 275 | return false | |
| 194 | 276 | } | |
| 195 | 277 | ||
| 278 | + | wv.markInitialBrowserCreateResizeHandled() | |
| 196 | 279 | wv.factory.postPendingBrowserCreate(ctx, wv, width, height) | |
| 197 | 280 | logging.FromContext(ctx).Debug(). | |
| 198 | 281 | Uint64("webview_id", uint64(wv.id)). | |
| @@ -203,6 +286,115 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||
| 203 | 286 | 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 | 398 | func (wv *WebView) installViewportSyncHooks() { | |
| 207 | 399 | if wv == nil || wv.viewBridge == nil { | |
| 208 | 400 | 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 a | 46 | // 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 false | 207 | 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 false | 275 | 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 true | 286 | 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 | return | 400 | return | ||
M internal/infrastructure/cef/webview_viewport_test.go +161 -0
| @@ -3,6 +3,7 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| @@ -14,6 +15,27 @@ type viewportSyncOrderHost struct { | |||
| 14 | 15 | 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 | 39 | func (h *viewportSyncOrderHost) WasHidden(state int32) { | |
| 18 | 40 | h.calls = append(h.calls, "WasHidden") | |
| 19 | 41 | if state != 0 { | |
| @@ -149,6 +171,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) { | |||
| 149 | 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 | 313 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | |
| 153 | 314 | oldNewTask := cefNewTask | |
| 154 | 315 | 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 float64 | 15 | 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 := cefNewTask | 314 | oldNewTask := cefNewTask | ||
| 154 | oldPostDelayedTask := cefPostDelayedTask | 315 | oldPostDelayedTask := cefPostDelayedTask | ||
M internal/infrastructure/config/transform_legacy.go +3 -1
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | + | import "math" | |
| 4 | + | ||
| 3 | 5 | const ( | |
| 4 | 6 | sectionWorkspace = "workspace" | |
| 5 | 7 | sectionSession = "session" | |
| @@ -93,7 +95,7 @@ func int64ConfigValue(value any) (int64, bool) { | |||
| 93 | 95 | case uint32: | |
| 94 | 96 | return int64(v), true | |
| 95 | 97 | case uint64: | |
| 96 | - | if v > 1<<63-1 { | |
| 98 | + | if v > math.MaxInt64 { | |
| 97 | 99 | return 0, false | |
| 98 | 100 | } | |
| 99 | 101 | return int64(v), true | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package config | 1 | 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), true | 96 | return int64(v), true | ||
| 95 | case uint64: | 97 | case uint64: | ||
| 96 | - | if v > 1<<63-1 { | 98 | + | if v > math.MaxInt64 { |
| 97 | return 0, false | 99 | return 0, false | ||
| 98 | } | 100 | } | ||
| 99 | return int64(v), true | 101 | return int64(v), true | ||
M internal/ui/app.go +17 -5
| @@ -2347,7 +2347,11 @@ func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow | |||
| 2347 | 2347 | } | |
| 2348 | 2348 | ||
| 2349 | 2349 | // 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 | 2355 | func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error { | |
| 2352 | 2356 | if a.tabCoord == nil { | |
| 2353 | 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 | 2364 | a.activateBrowserWindow(bw) | |
| 2361 | 2365 | 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") | |
| 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 | 2381 | // 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 to | 2349 | // 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 { | |||
| 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 | 2292 | ctx := context.Background() | |
| 2248 | 2293 | ||
| 2249 | - | // Two windows, each with one tab. | |
| 2250 | 2294 | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |
| 2251 | 2295 | firstTabs := entity.NewTabList() | |
| 2252 | 2296 | firstTabs.Add(firstTab) | |
| @@ -2280,24 +2324,83 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *t | |||
| 2280 | 2324 | t.Fatalf("dispatchBrowserWindowAction returned error: %v", err) | |
| 2281 | 2325 | } | |
| 2282 | 2326 | ||
| 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) | |
| 2286 | 2330 | } | |
| 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) | |
| 2289 | 2333 | } | |
| 2290 | 2334 | ||
| 2291 | - | // firstTabs must be completely unchanged. | |
| 2292 | 2335 | if got := firstTabs.Count(); got != 1 { | |
| 2293 | 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. | |
| 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) | |
| 2301 | 2404 | } | |
| 2302 | 2405 | } | |
| 2303 | 2406 | ||
| @@ -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 { | |||
| 429 | 429 | ActionPrintPage, | |
| 430 | 430 | ActionOpenOmnibox, | |
| 431 | 431 | ActionOpenFind, | |
| 432 | + | ActionFindNext, | |
| 433 | + | ActionFindPrev, | |
| 432 | 434 | ActionOpenDevTools, | |
| 433 | 435 | ActionToggleFullscreen, | |
| 434 | 436 | 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 | |||
| 129 | 129 | ActionToggleHistorySystemView, | |
| 130 | 130 | ActionOpenOmnibox, | |
| 131 | 131 | ActionOpenFind, | |
| 132 | + | ActionFindNext, | |
| 133 | + | ActionFindPrev, | |
| 132 | 134 | ActionOpenDevTools, | |
| 133 | 135 | ActionToggleFloatingPane, | |
| 134 | 136 | 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, | ||