Merge pull request #258 from bnema/fix/cef-osr-stability-hardening
fix(cef): harden osr stability, browser creation, and shortcuts
32 files · +1491 -126
M README.md +2 -0
| @@ -103,6 +103,8 @@ 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 when both `engine.cef.adaptive_windowless_frame_rate = true` and `engine.cef.windowless_frame_rate = 0` (the defaults). Adaptive mode is capped by `engine.cef.windowless_frame_rate_max = 240`. Setting `engine.cef.windowless_frame_rate` to a positive value forces a fixed rate instead. This adaptive polling path is Wayland-specific; other platforms fall back to the configured fixed/default CEF behavior. | |
| 107 | + | ||
| 106 | 108 | ## Keyboard-Driven | |
| 107 | 109 | ||
| 108 | 110 | Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern. | |
| @@ -103,6 +103,8 @@ Then set `engine.type = "cef"` in your config. | |||||
| 103 | 103 | ||||
| 104 | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. | 104 | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. | ||
| 105 | 105 | ||||
| 106 | + | CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both `engine.cef.adaptive_windowless_frame_rate = true` and `engine.cef.windowless_frame_rate = 0` (the defaults). Adaptive mode is capped by `engine.cef.windowless_frame_rate_max = 240`. Setting `engine.cef.windowless_frame_rate` to a positive value forces a fixed rate instead. This adaptive polling path is Wayland-specific; other platforms fall back to the configured fixed/default CEF behavior. | |||
| 107 | + | ||||
| 106 | ## Keyboard-Driven | 108 | ## Keyboard-Driven | ||
| 107 | 109 | ||||
| 108 | Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern. | 110 | Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern. | ||
M docs/config/index.md +3 -0
| @@ -190,6 +190,9 @@ border = "#363636" | |||
| 190 | 190 | | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) | | |
| 191 | 191 | | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) | | |
| 192 | 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | |
| 193 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) | | |
| 194 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default | | |
| 195 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF | | |
| 193 | 196 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | |
| 194 | 197 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | |
| 195 | 198 | ||
| @@ -190,6 +190,9 @@ border = "#363636" | |||||
| 190 | | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) | | 190 | | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) | | ||
| 191 | | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) | | 191 | | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) | | ||
| 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | 192 | | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack | | ||
| 193 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Enable adaptive CEF OSR FPS polling (only applies when `windowless_frame_rate = 0`) | | |||
| 194 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 uses adaptive mode if enabled, otherwise CEF default | | |||
| 195 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | Hard cap for adaptive CEF OSR FPS; 0 falls back to the built-in default cap selected by Dumber/CEF | | |||
| 193 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | 196 | | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) | | ||
| 194 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | 197 | | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) | | ||
| 195 | 198 | ||||
M docs/reference/configuration.md +3 -0
| @@ -60,6 +60,9 @@ | |||
| 60 | 60 | | `rendering.sample_memory` | bool | `false` | | | |
| 61 | 61 | | `rendering.debug_frames` | bool | `false` | | | |
| 62 | 62 | | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` | | |
| 63 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | | | |
| 64 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | |
| 65 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | | |
| 63 | 66 | | `default_ui_scale` | float | `1.0` | > 0 | | |
| 64 | 67 | | `default_webpage_zoom` | float | `1.2` | > 0 | | |
| 65 | 68 | | `workspace.new_pane_url` | string | `about:blank` | | | |
| @@ -60,6 +60,9 @@ | |||||
| 60 | | `rendering.sample_memory` | bool | `false` | | | 60 | | `rendering.sample_memory` | bool | `false` | | | ||
| 61 | | `rendering.debug_frames` | bool | `false` | | | 61 | | `rendering.debug_frames` | bool | `false` | | | ||
| 62 | | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` | | 62 | | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` | | ||
| 63 | + | | `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | | | |||
| 64 | + | | `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | | |||
| 65 | + | | `engine.cef.windowless_frame_rate_max` | int32 | `240` | >= 0 | | |||
| 63 | | `default_ui_scale` | float | `1.0` | > 0 | | 66 | | `default_ui_scale` | float | `1.0` | > 0 | | ||
| 64 | | `default_webpage_zoom` | float | `1.2` | > 0 | | 67 | | `default_webpage_zoom` | float | `1.2` | > 0 | | ||
| 65 | | `workspace.new_pane_url` | string | `about:blank` | | | 68 | | `workspace.new_pane_url` | string | `about:blank` | | | ||
M go.mod +1 -0
| @@ -83,3 +83,4 @@ require ( | |||
| 83 | 83 | ) | |
| 84 | 84 | ||
| 85 | 85 | tool github.com/a-h/templ/cmd/templ | |
| 86 | + | ||
| @@ -83,3 +83,4 @@ require ( | |||||
| 83 | ) | 83 | ) | ||
| 84 | 84 | ||||
| 85 | tool github.com/a-h/templ/cmd/templ | 85 | tool github.com/a-h/templ/cmd/templ | ||
| 86 | + | ||||
M internal/bootstrap/engine.go +9 -7
| @@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 77 | 77 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 78 | 78 | } | |
| 79 | 79 | cefCfg := cef.RuntimeConfig{ | |
| 80 | - | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 81 | - | LogFile: cfg.Engine.CEF.LogFile, | |
| 82 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 83 | - | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), | |
| 84 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 85 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 86 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 80 | + | CEFDir: cfg.Engine.CEF.CEFDir, | |
| 81 | + | LogFile: cfg.Engine.CEF.LogFile, | |
| 82 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, | |
| 83 | + | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), | |
| 84 | + | AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(), | |
| 85 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | |
| 86 | + | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), | |
| 87 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |
| 88 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |
| 87 | 89 | } | |
| 88 | 90 | deps := cef.EngineDependencies{ | |
| 89 | 91 | RegisterHandlers: handlers.RegisterAll, | |
| @@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 77 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 77 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 78 | } | 78 | } | ||
| 79 | cefCfg := cef.RuntimeConfig{ | 79 | cefCfg := cef.RuntimeConfig{ | ||
| 80 | - | CEFDir: cfg.Engine.CEF.CEFDir, | 80 | + | CEFDir: cfg.Engine.CEF.CEFDir, |
| 81 | - | LogFile: cfg.Engine.CEF.LogFile, | 81 | + | LogFile: cfg.Engine.CEF.LogFile, |
| 82 | - | LogSeverity: cfg.Engine.CEF.LogSeverity, | 82 | + | LogSeverity: cfg.Engine.CEF.LogSeverity, |
| 83 | - | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), | 83 | + | RenderStack: string(cfg.Engine.CEF.CEFRenderStack()), |
| 84 | - | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, | 84 | + | AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(), |
| 85 | - | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | 85 | + | WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate, |
| 86 | - | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | 86 | + | WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(), |
| 87 | + | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | |||
| 88 | + | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | |||
| 87 | } | 89 | } | ||
| 88 | deps := cef.EngineDependencies{ | 90 | deps := cef.EngineDependencies{ | ||
| 89 | RegisterHandlers: handlers.RegisterAll, | 91 | RegisterHandlers: handlers.RegisterAll, | ||
M internal/infrastructure/audio/factory/output_test.go +14 -25
| @@ -3,6 +3,7 @@ package factory | |||
| 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 | } | ||
A internal/infrastructure/cef/adaptive_frame_rate.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 7 | + | "github.com/bnema/puregotk/v4/glib" | |
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | adaptiveFrameRatePollIntervalMS = 1000 | |
| 15 | + | adaptiveFrameRateMinFPS = 30 | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 { | |
| 19 | + | return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{ | |
| 20 | + | DefaultFPS: fallbackFPS, | |
| 21 | + | MinFPS: adaptiveFrameRateMinFPS, | |
| 22 | + | MaxFPS: maxFPS, | |
| 23 | + | }) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | |
| 27 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() }) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() { | |
| 34 | + | if wv == nil { | |
| 35 | + | return | |
| 36 | + | } | |
| 37 | + | wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() }) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (wv *WebView) startAdaptiveFrameRatePolling() { | |
| 41 | + | if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | wv.mu.Lock() | |
| 46 | + | if wv.adaptiveFrameRatePollID != 0 || wv.host == nil { | |
| 47 | + | wv.mu.Unlock() | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | cb := new(glib.SourceFunc) | |
| 51 | + | *cb = func(uintptr) bool { | |
| 52 | + | if wv.destroyed.Load() { | |
| 53 | + | return false | |
| 54 | + | } | |
| 55 | + | wv.applyAdaptiveWindowlessFrameRate() | |
| 56 | + | return true | |
| 57 | + | } | |
| 58 | + | wv.adaptiveFrameRatePoll = cb | |
| 59 | + | wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0) | |
| 60 | + | wv.mu.Unlock() | |
| 61 | + | ||
| 62 | + | wv.applyAdaptiveWindowlessFrameRate() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (wv *WebView) stopAdaptiveFrameRatePolling() { | |
| 66 | + | if wv == nil { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | wv.mu.Lock() | |
| 70 | + | pollID := wv.adaptiveFrameRatePollID | |
| 71 | + | wv.adaptiveFrameRatePollID = 0 | |
| 72 | + | wv.adaptiveFrameRatePoll = nil | |
| 73 | + | wv.mu.Unlock() | |
| 74 | + | if pollID != 0 { | |
| 75 | + | glib.SourceRemove(pollID) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (wv *WebView) applyAdaptiveWindowlessFrameRate() { | |
| 80 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil { | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | widget := wv.viewBridge.Widget() | |
| 84 | + | refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget) | |
| 85 | + | if !ok || !isWaylandDisplayName(displayName) { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax) | |
| 89 | + | ||
| 90 | + | wv.mu.Lock() | |
| 91 | + | host := wv.host | |
| 92 | + | if host == nil || fps == wv.lastAdaptiveFrameRate { | |
| 93 | + | wv.mu.Unlock() | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | old := wv.lastAdaptiveFrameRate | |
| 97 | + | wv.lastAdaptiveFrameRate = fps | |
| 98 | + | wv.mu.Unlock() | |
| 99 | + | ||
| 100 | + | host.SetWindowlessFrameRate(fps) | |
| 101 | + | if wv.ctx != nil { | |
| 102 | + | logging.FromContext(wv.ctx).Info(). | |
| 103 | + | Int32("old_windowless_frame_rate", old). | |
| 104 | + | Int32("windowless_frame_rate", fps). | |
| 105 | + | Int("monitor_refresh_millihz", refreshRateMilliHz). | |
| 106 | + | Str("display", displayName). | |
| 107 | + | Msg("cef: adaptive windowless frame rate updated") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) { | |
| 112 | + | if widget == nil { | |
| 113 | + | return 0, "", false | |
| 114 | + | } | |
| 115 | + | native := widget.GetNative() | |
| 116 | + | if native == nil { | |
| 117 | + | return 0, "", false | |
| 118 | + | } | |
| 119 | + | surface := native.GetSurface() | |
| 120 | + | if surface == nil { | |
| 121 | + | return 0, "", false | |
| 122 | + | } | |
| 123 | + | display := surface.GetDisplay() | |
| 124 | + | if display == nil { | |
| 125 | + | return 0, "", false | |
| 126 | + | } | |
| 127 | + | monitor := display.GetMonitorAtSurface(surface) | |
| 128 | + | if monitor == nil { | |
| 129 | + | return 0, display.GetName(), false | |
| 130 | + | } | |
| 131 | + | return monitor.GetRefreshRate(), display.GetName(), true | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func isWaylandDisplayName(name string) bool { | |
| 135 | + | return strings.Contains(strings.ToLower(name), "wayland") | |
| 136 | + | } | |
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |||
| 7 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | adaptiveFrameRatePollIntervalMS = 1000 | |||
| 15 | + | adaptiveFrameRateMinFPS = 30 | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 { | |||
| 19 | + | return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{ | |||
| 20 | + | DefaultFPS: fallbackFPS, | |||
| 21 | + | MinFPS: adaptiveFrameRateMinFPS, | |||
| 22 | + | MaxFPS: maxFPS, | |||
| 23 | + | }) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() { | |||
| 27 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() }) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() { | |||
| 34 | + | if wv == nil { | |||
| 35 | + | return | |||
| 36 | + | } | |||
| 37 | + | wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() }) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (wv *WebView) startAdaptiveFrameRatePolling() { | |||
| 41 | + | if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | wv.mu.Lock() | |||
| 46 | + | if wv.adaptiveFrameRatePollID != 0 || wv.host == nil { | |||
| 47 | + | wv.mu.Unlock() | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | cb := new(glib.SourceFunc) | |||
| 51 | + | *cb = func(uintptr) bool { | |||
| 52 | + | if wv.destroyed.Load() { | |||
| 53 | + | return false | |||
| 54 | + | } | |||
| 55 | + | wv.applyAdaptiveWindowlessFrameRate() | |||
| 56 | + | return true | |||
| 57 | + | } | |||
| 58 | + | wv.adaptiveFrameRatePoll = cb | |||
| 59 | + | wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0) | |||
| 60 | + | wv.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | wv.applyAdaptiveWindowlessFrameRate() | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (wv *WebView) stopAdaptiveFrameRatePolling() { | |||
| 66 | + | if wv == nil { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | wv.mu.Lock() | |||
| 70 | + | pollID := wv.adaptiveFrameRatePollID | |||
| 71 | + | wv.adaptiveFrameRatePollID = 0 | |||
| 72 | + | wv.adaptiveFrameRatePoll = nil | |||
| 73 | + | wv.mu.Unlock() | |||
| 74 | + | if pollID != 0 { | |||
| 75 | + | glib.SourceRemove(pollID) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (wv *WebView) applyAdaptiveWindowlessFrameRate() { | |||
| 80 | + | if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil { | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | widget := wv.viewBridge.Widget() | |||
| 84 | + | refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget) | |||
| 85 | + | if !ok || !isWaylandDisplayName(displayName) { | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax) | |||
| 89 | + | ||||
| 90 | + | wv.mu.Lock() | |||
| 91 | + | host := wv.host | |||
| 92 | + | if host == nil || fps == wv.lastAdaptiveFrameRate { | |||
| 93 | + | wv.mu.Unlock() | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | old := wv.lastAdaptiveFrameRate | |||
| 97 | + | wv.lastAdaptiveFrameRate = fps | |||
| 98 | + | wv.mu.Unlock() | |||
| 99 | + | ||||
| 100 | + | host.SetWindowlessFrameRate(fps) | |||
| 101 | + | if wv.ctx != nil { | |||
| 102 | + | logging.FromContext(wv.ctx).Info(). | |||
| 103 | + | Int32("old_windowless_frame_rate", old). | |||
| 104 | + | Int32("windowless_frame_rate", fps). | |||
| 105 | + | Int("monitor_refresh_millihz", refreshRateMilliHz). | |||
| 106 | + | Str("display", displayName). | |||
| 107 | + | Msg("cef: adaptive windowless frame rate updated") | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) { | |||
| 112 | + | if widget == nil { | |||
| 113 | + | return 0, "", false | |||
| 114 | + | } | |||
| 115 | + | native := widget.GetNative() | |||
| 116 | + | if native == nil { | |||
| 117 | + | return 0, "", false | |||
| 118 | + | } | |||
| 119 | + | surface := native.GetSurface() | |||
| 120 | + | if surface == nil { | |||
| 121 | + | return 0, "", false | |||
| 122 | + | } | |||
| 123 | + | display := surface.GetDisplay() | |||
| 124 | + | if display == nil { | |||
| 125 | + | return 0, "", false | |||
| 126 | + | } | |||
| 127 | + | monitor := display.GetMonitorAtSurface(surface) | |||
| 128 | + | if monitor == nil { | |||
| 129 | + | return 0, display.GetName(), false | |||
| 130 | + | } | |||
| 131 | + | return monitor.GetRefreshRate(), display.GetName(), true | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func isWaylandDisplayName(name string) bool { | |||
| 135 | + | return strings.Contains(strings.ToLower(name), "wayland") | |||
| 136 | + | } | |||
A internal/infrastructure/cef/adaptive_frame_rate_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestAdaptiveFrameRateForRefresh(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | refreshMilliHz int | |
| 9 | + | fallbackFPS int32 | |
| 10 | + | maxFPS int32 | |
| 11 | + | want int32 | |
| 12 | + | }{ | |
| 13 | + | {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60}, | |
| 14 | + | {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144}, | |
| 15 | + | {name: "uses lower refresh monitor", refreshMilliHz: 50000, fallbackFPS: 60, maxFPS: 240, want: 50}, | |
| 16 | + | {name: "floors very low refresh monitor", refreshMilliHz: 24000, fallbackFPS: 60, maxFPS: 240, want: 30}, | |
| 17 | + | {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240}, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | for _, tt := range tests { | |
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |
| 22 | + | got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS) | |
| 23 | + | if got != tt.want { | |
| 24 | + | t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want) | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestIsWaylandDisplayName(t *testing.T) { | |
| 31 | + | tests := []struct { | |
| 32 | + | name string | |
| 33 | + | input string | |
| 34 | + | want bool | |
| 35 | + | }{ | |
| 36 | + | {name: "exact lowercase", input: "wayland", want: true}, | |
| 37 | + | {name: "exact uppercase", input: "WAYLAND", want: true}, | |
| 38 | + | {name: "mixed case", input: "WayLand", want: true}, | |
| 39 | + | {name: "numeric suffix", input: "wayland-1", want: true}, | |
| 40 | + | {name: "punctuation suffix", input: "wayland:0", want: true}, | |
| 41 | + | {name: "leading whitespace", input: " wayland ", want: true}, | |
| 42 | + | {name: "substring", input: "mywayland-display", want: true}, | |
| 43 | + | {name: "x11", input: "x11", want: false}, | |
| 44 | + | {name: "empty", input: "", want: false}, | |
| 45 | + | {name: "whitespace", input: " ", want: false}, | |
| 46 | + | } | |
| 47 | + | for _, tt := range tests { | |
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |
| 49 | + | if got := isWaylandDisplayName(tt.input); got != tt.want { | |
| 50 | + | t.Fatalf("isWaylandDisplayName(%q) = %v, want %v", tt.input, got, tt.want) | |
| 51 | + | } | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestAdaptiveFrameRateForRefresh(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | refreshMilliHz int | |||
| 9 | + | fallbackFPS int32 | |||
| 10 | + | maxFPS int32 | |||
| 11 | + | want int32 | |||
| 12 | + | }{ | |||
| 13 | + | {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60}, | |||
| 14 | + | {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144}, | |||
| 15 | + | {name: "uses lower refresh monitor", refreshMilliHz: 50000, fallbackFPS: 60, maxFPS: 240, want: 50}, | |||
| 16 | + | {name: "floors very low refresh monitor", refreshMilliHz: 24000, fallbackFPS: 60, maxFPS: 240, want: 30}, | |||
| 17 | + | {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240}, | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | for _, tt := range tests { | |||
| 21 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 22 | + | got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS) | |||
| 23 | + | if got != tt.want { | |||
| 24 | + | t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want) | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestIsWaylandDisplayName(t *testing.T) { | |||
| 31 | + | tests := []struct { | |||
| 32 | + | name string | |||
| 33 | + | input string | |||
| 34 | + | want bool | |||
| 35 | + | }{ | |||
| 36 | + | {name: "exact lowercase", input: "wayland", want: true}, | |||
| 37 | + | {name: "exact uppercase", input: "WAYLAND", want: true}, | |||
| 38 | + | {name: "mixed case", input: "WayLand", want: true}, | |||
| 39 | + | {name: "numeric suffix", input: "wayland-1", want: true}, | |||
| 40 | + | {name: "punctuation suffix", input: "wayland:0", want: true}, | |||
| 41 | + | {name: "leading whitespace", input: " wayland ", want: true}, | |||
| 42 | + | {name: "substring", input: "mywayland-display", want: true}, | |||
| 43 | + | {name: "x11", input: "x11", want: false}, | |||
| 44 | + | {name: "empty", input: "", want: false}, | |||
| 45 | + | {name: "whitespace", input: " ", want: false}, | |||
| 46 | + | } | |||
| 47 | + | for _, tt := range tests { | |||
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 49 | + | if got := isWaylandDisplayName(tt.input); got != tt.want { | |||
| 50 | + | t.Fatalf("isWaylandDisplayName(%q) = %v, want %v", tt.input, got, tt.want) | |||
| 51 | + | } | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
M internal/infrastructure/cef/app.go +2 -0
| @@ -219,6 +219,7 @@ func appendUniqueCommaSeparatedSwitchValues(commandLine purecef.CommandLine, nam | |||
| 219 | 219 | if len(combined) == 0 { | |
| 220 | 220 | return | |
| 221 | 221 | } | |
| 222 | + | commandLine.RemoveSwitch(name) | |
| 222 | 223 | commandLine.AppendSwitchWithValue(name, strings.Join(combined, ",")) | |
| 223 | 224 | } | |
| 224 | 225 | ||
| @@ -261,6 +262,7 @@ func removeCommaSeparatedSwitchValues(commandLine purecef.CommandLine, name stri | |||
| 261 | 262 | return | |
| 262 | 263 | } | |
| 263 | 264 | ||
| 265 | + | commandLine.RemoveSwitch(name) | |
| 264 | 266 | commandLine.AppendSwitchWithValue(name, strings.Join(cleaned, ",")) | |
| 265 | 267 | } | |
| 266 | 268 | ||
| @@ -219,6 +219,7 @@ func appendUniqueCommaSeparatedSwitchValues(commandLine purecef.CommandLine, nam | |||||
| 219 | if len(combined) == 0 { | 219 | if len(combined) == 0 { | ||
| 220 | return | 220 | return | ||
| 221 | } | 221 | } | ||
| 222 | + | commandLine.RemoveSwitch(name) | |||
| 222 | commandLine.AppendSwitchWithValue(name, strings.Join(combined, ",")) | 223 | commandLine.AppendSwitchWithValue(name, strings.Join(combined, ",")) | ||
| 223 | } | 224 | } | ||
| 224 | 225 | ||||
| @@ -261,6 +262,7 @@ func removeCommaSeparatedSwitchValues(commandLine purecef.CommandLine, name stri | |||||
| 261 | return | 262 | return | ||
| 262 | } | 263 | } | ||
| 263 | 264 | ||||
| 265 | + | commandLine.RemoveSwitch(name) | |||
| 264 | commandLine.AppendSwitchWithValue(name, strings.Join(cleaned, ",")) | 266 | commandLine.AppendSwitchWithValue(name, strings.Join(cleaned, ",")) | ||
| 265 | } | 267 | } | ||
| 266 | 268 | ||||
M internal/infrastructure/cef/cef2gtk_adapter.go +29 -0
| @@ -165,6 +165,21 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) { | |||
| 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 { | |
| @@ -209,6 +224,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error { | |||
| 209 | 224 | return a.view.ConfigureProfiling(opts) | |
| 210 | 225 | } | |
| 211 | 226 | ||
| 227 | + | // RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in | |
| 228 | + | // the bridge profiler when profiling is enabled. It is a no-op otherwise. | |
| 229 | + | func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() { | |
| 230 | + | if a == nil || a.destroyed.Load() { | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | a.viewMu.RLock() | |
| 234 | + | defer a.viewMu.RUnlock() | |
| 235 | + | if a.view == nil { | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | a.view.RecordExternalBeginFrameSent() | |
| 239 | + | } | |
| 240 | + | ||
| 212 | 241 | // AttachInput attaches GTK event controllers to the view and forwards input | |
| 213 | 242 | // to the given CEF browser host. Must be called on the GTK main thread. | |
| 214 | 243 | // | |
| @@ -165,6 +165,21 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) { | |||||
| 165 | a.view.SetCursorFromName(name) | 165 | a.view.SetCursorFromName(name) | ||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | + | // RefreshObservedSizeOnGTKThread synchronously refreshes the bridge's cached | |||
| 169 | + | // observed size from the current GTK allocation. Must be called on the GTK | |||
| 170 | + | // main thread. | |||
| 171 | + | func (a *Cef2gtkAdapter) RefreshObservedSizeOnGTKThread() (int32, int32) { | |||
| 172 | + | if a == nil || a.destroyed.Load() { | |||
| 173 | + | return 1, 1 | |||
| 174 | + | } | |||
| 175 | + | a.viewMu.RLock() | |||
| 176 | + | defer a.viewMu.RUnlock() | |||
| 177 | + | if a.view == nil { | |||
| 178 | + | return 1, 1 | |||
| 179 | + | } | |||
| 180 | + | return a.view.RefreshObservedSizeOnGTKThread() | |||
| 181 | + | } | |||
| 182 | + | ||||
| 168 | // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk. | 183 | // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk. | ||
| 169 | // Must be called on the GTK main thread. | 184 | // Must be called on the GTK main thread. | ||
| 170 | func (a *Cef2gtkAdapter) PrepareOnGTKThread() error { | 185 | func (a *Cef2gtkAdapter) PrepareOnGTKThread() error { | ||
| @@ -209,6 +224,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error { | |||||
| 209 | return a.view.ConfigureProfiling(opts) | 224 | return a.view.ConfigureProfiling(opts) | ||
| 210 | } | 225 | } | ||
| 211 | 226 | ||||
| 227 | + | // RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in | |||
| 228 | + | // the bridge profiler when profiling is enabled. It is a no-op otherwise. | |||
| 229 | + | func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() { | |||
| 230 | + | if a == nil || a.destroyed.Load() { | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | a.viewMu.RLock() | |||
| 234 | + | defer a.viewMu.RUnlock() | |||
| 235 | + | if a.view == nil { | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | a.view.RecordExternalBeginFrameSent() | |||
| 239 | + | } | |||
| 240 | + | ||||
| 212 | // AttachInput attaches GTK event controllers to the view and forwards input | 241 | // AttachInput attaches GTK event controllers to the view and forwards input | ||
| 213 | // to the given CEF browser host. Must be called on the GTK main thread. | 242 | // to the given CEF browser host. Must be called on the GTK main thread. | ||
| 214 | // | 243 | // | ||
M internal/infrastructure/cef/engine_init.go +8 -7
| @@ -97,8 +97,12 @@ func NewEngine( | |||
| 97 | 97 | return wireEngine( | |
| 98 | 98 | ctx, | |
| 99 | 99 | eng, | |
| 100 | - | windowlessFrameRate, | |
| 101 | - | audioFactory, | |
| 100 | + | webViewFactoryOptions{ | |
| 101 | + | adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate, | |
| 102 | + | windowlessFrameRate: windowlessFrameRate, | |
| 103 | + | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | |
| 104 | + | audioOutputFactory: audioFactory, | |
| 105 | + | }, | |
| 102 | 106 | logger, | |
| 103 | 107 | deps.CurrentConfigPayload, | |
| 104 | 108 | deps.DefaultConfigPayload, | |
| @@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string { | |||
| 215 | 219 | // wireEngine creates factory, pool, and scheme handler after CEF init. | |
| 216 | 220 | func wireEngine( | |
| 217 | 221 | ctx context.Context, eng *Engine, | |
| 218 | - | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 222 | + | factoryOptions webViewFactoryOptions, logger *zerolog.Logger, | |
| 219 | 223 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| 220 | 224 | ) (*Engine, error) { | |
| 221 | - | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | |
| 222 | - | windowlessFrameRate: windowlessFrameRate, | |
| 223 | - | audioOutputFactory: audioFactory, | |
| 224 | - | }) | |
| 225 | + | eng.factory = newWebViewFactory(eng, factoryOptions) | |
| 225 | 226 | eng.pool = newWebViewPool(eng.factory) | |
| 226 | 227 | eng.contentInj = newContentInjector(eng, nil) | |
| 227 | 228 | ||
| @@ -97,8 +97,12 @@ func NewEngine( | |||||
| 97 | return wireEngine( | 97 | return wireEngine( | ||
| 98 | ctx, | 98 | ctx, | ||
| 99 | eng, | 99 | eng, | ||
| 100 | - | windowlessFrameRate, | 100 | + | webViewFactoryOptions{ |
| 101 | - | audioFactory, | 101 | + | adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate, |
| 102 | + | windowlessFrameRate: windowlessFrameRate, | |||
| 103 | + | windowlessFrameRateMax: cfg.WindowlessFrameRateMax, | |||
| 104 | + | audioOutputFactory: audioFactory, | |||
| 105 | + | }, | |||
| 102 | logger, | 106 | logger, | ||
| 103 | deps.CurrentConfigPayload, | 107 | deps.CurrentConfigPayload, | ||
| 104 | deps.DefaultConfigPayload, | 108 | deps.DefaultConfigPayload, | ||
| @@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string { | |||||
| 215 | // wireEngine creates factory, pool, and scheme handler after CEF init. | 219 | // wireEngine creates factory, pool, and scheme handler after CEF init. | ||
| 216 | func wireEngine( | 220 | func wireEngine( | ||
| 217 | ctx context.Context, eng *Engine, | 221 | ctx context.Context, eng *Engine, | ||
| 218 | - | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | 222 | + | factoryOptions webViewFactoryOptions, logger *zerolog.Logger, |
| 219 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | 223 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | ||
| 220 | ) (*Engine, error) { | 224 | ) (*Engine, error) { | ||
| 221 | - | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | 225 | + | eng.factory = newWebViewFactory(eng, factoryOptions) |
| 222 | - | windowlessFrameRate: windowlessFrameRate, | |||
| 223 | - | audioOutputFactory: audioFactory, | |||
| 224 | - | }) | |||
| 225 | eng.pool = newWebViewPool(eng.factory) | 226 | eng.pool = newWebViewPool(eng.factory) | ||
| 226 | eng.contentInj = newContentInjector(eng, nil) | 227 | eng.contentInj = newContentInjector(eng, nil) | ||
| 227 | 228 | ||||
M internal/infrastructure/cef/factory.go +35 -24
| @@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||
| 20 | 20 | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | |
| 21 | 21 | // CEF browser (via BrowserHostCreateBrowser). | |
| 22 | 22 | type WebViewFactory struct { | |
| 23 | - | engine *Engine | |
| 24 | - | nextID atomic.Uint64 | |
| 25 | - | windowlessFrameRate int32 | |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 27 | - | audioOutputFactory port.AudioOutputFactory | |
| 23 | + | engine *Engine | |
| 24 | + | nextID atomic.Uint64 | |
| 25 | + | adaptiveWindowlessFrameRate bool | |
| 26 | + | windowlessFrameRate int32 | |
| 27 | + | windowlessFrameRateMax int32 | |
| 28 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 29 | + | audioOutputFactory port.AudioOutputFactory | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 30 | 32 | type webViewFactoryOptions struct { | |
| 31 | - | windowlessFrameRate int32 | |
| 32 | - | audioOutputFactory port.AudioOutputFactory | |
| 33 | + | adaptiveWindowlessFrameRate bool | |
| 34 | + | windowlessFrameRate int32 | |
| 35 | + | windowlessFrameRateMax int32 | |
| 36 | + | audioOutputFactory port.AudioOutputFactory | |
| 33 | 37 | } | |
| 34 | 38 | ||
| 35 | 39 | type resizeNotifiableBrowserHost interface { | |
| @@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto | |||
| 49 | 53 | if opts.windowlessFrameRate < 1 { | |
| 50 | 54 | opts.windowlessFrameRate = 60 | |
| 51 | 55 | } | |
| 56 | + | if opts.windowlessFrameRateMax < 1 { | |
| 57 | + | opts.windowlessFrameRateMax = 240 | |
| 58 | + | } | |
| 52 | 59 | return &WebViewFactory{ | |
| 53 | - | engine: engine, | |
| 54 | - | windowlessFrameRate: opts.windowlessFrameRate, | |
| 55 | - | audioOutputFactory: opts.audioOutputFactory, | |
| 60 | + | engine: engine, | |
| 61 | + | adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate, | |
| 62 | + | windowlessFrameRate: opts.windowlessFrameRate, | |
| 63 | + | windowlessFrameRateMax: opts.windowlessFrameRateMax, | |
| 64 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 56 | 65 | } | |
| 57 | 66 | } | |
| 58 | 67 | ||
| @@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 103 | 112 | } | |
| 104 | 113 | ||
| 105 | 114 | wv := &WebView{ | |
| 106 | - | id: id, | |
| 107 | - | ctx: ctx, | |
| 108 | - | engine: f.engine, | |
| 109 | - | factory: f, | |
| 110 | - | viewBridge: viewBridge, | |
| 111 | - | audioOutputFactory: f.audioOutputFactory, | |
| 112 | - | windowlessFrameRate: f.windowlessFrameRate, | |
| 113 | - | backgroundColor: f.bgColor.Load(), | |
| 115 | + | id: id, | |
| 116 | + | ctx: ctx, | |
| 117 | + | engine: f.engine, | |
| 118 | + | factory: f, | |
| 119 | + | viewBridge: viewBridge, | |
| 120 | + | audioOutputFactory: f.audioOutputFactory, | |
| 121 | + | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, | |
| 122 | + | windowlessFrameRate: f.windowlessFrameRate, | |
| 123 | + | windowlessFrameRateMax: f.windowlessFrameRateMax, | |
| 124 | + | backgroundColor: f.bgColor.Load(), | |
| 114 | 125 | } | |
| 115 | 126 | ||
| 116 | 127 | handlers := &handlerSet{wv: wv} | |
| @@ -149,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 149 | 160 | if wv.viewBridge == nil { | |
| 150 | 161 | return | |
| 151 | 162 | } | |
| 152 | - | browserCreateScheduled := false | |
| 153 | 163 | wv.runOnGTKSync(func() { | |
| 154 | 164 | wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) { | |
| 155 | - | // Size observers run on the GTK thread. Prepare the GtkGLArea only after it | |
| 156 | - | // has been packed and allocated; preparing at WebView construction time can | |
| 157 | - | // run before GTK has created a GL context. | |
| 158 | - | if !browserCreateScheduled { | |
| 165 | + | // Size observers run on the GTK thread. Prepare the GtkGLArea only for | |
| 166 | + | // the one-shot initial resize path; once that path has been consumed, | |
| 167 | + | // later size events must go through normal viewport sync even if popup | |
| 168 | + | // attachment keeps pendingCreate alive a little longer. | |
| 169 | + | if wv.shouldStartBrowserCreateFromSizeObserver() { | |
| 159 | 170 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |
| 160 | 171 | logging.FromContext(ctx).Warn(). | |
| 161 | 172 | Err(err). | |
| @@ -165,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 165 | 176 | Msg("cef: cef2gtk view not ready after resize, will retry") | |
| 166 | 177 | return | |
| 167 | 178 | } | |
| 168 | - | browserCreateScheduled = true | |
| 179 | + | wv.markInitialBrowserCreateResizeHandled() | |
| 169 | 180 | onFirstResize(w, h) | |
| 170 | 181 | return | |
| 171 | 182 | } | |
| @@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||||
| 20 | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | 20 | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | ||
| 21 | // CEF browser (via BrowserHostCreateBrowser). | 21 | // CEF browser (via BrowserHostCreateBrowser). | ||
| 22 | type WebViewFactory struct { | 22 | type WebViewFactory struct { | ||
| 23 | - | engine *Engine | 23 | + | engine *Engine |
| 24 | - | nextID atomic.Uint64 | 24 | + | nextID atomic.Uint64 |
| 25 | - | windowlessFrameRate int32 | 25 | + | adaptiveWindowlessFrameRate bool |
| 26 | - | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 26 | + | windowlessFrameRate int32 |
| 27 | - | audioOutputFactory port.AudioOutputFactory | 27 | + | windowlessFrameRateMax int32 |
| 28 | + | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |||
| 29 | + | audioOutputFactory port.AudioOutputFactory | |||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 30 | type webViewFactoryOptions struct { | 32 | type webViewFactoryOptions struct { | ||
| 31 | - | windowlessFrameRate int32 | 33 | + | adaptiveWindowlessFrameRate bool |
| 32 | - | audioOutputFactory port.AudioOutputFactory | 34 | + | windowlessFrameRate int32 |
| 35 | + | windowlessFrameRateMax int32 | |||
| 36 | + | audioOutputFactory port.AudioOutputFactory | |||
| 33 | } | 37 | } | ||
| 34 | 38 | ||||
| 35 | type resizeNotifiableBrowserHost interface { | 39 | type resizeNotifiableBrowserHost interface { | ||
| @@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto | |||||
| 49 | if opts.windowlessFrameRate < 1 { | 53 | if opts.windowlessFrameRate < 1 { | ||
| 50 | opts.windowlessFrameRate = 60 | 54 | opts.windowlessFrameRate = 60 | ||
| 51 | } | 55 | } | ||
| 56 | + | if opts.windowlessFrameRateMax < 1 { | |||
| 57 | + | opts.windowlessFrameRateMax = 240 | |||
| 58 | + | } | |||
| 52 | return &WebViewFactory{ | 59 | return &WebViewFactory{ | ||
| 53 | - | engine: engine, | 60 | + | engine: engine, |
| 54 | - | windowlessFrameRate: opts.windowlessFrameRate, | 61 | + | adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate, |
| 55 | - | audioOutputFactory: opts.audioOutputFactory, | 62 | + | windowlessFrameRate: opts.windowlessFrameRate, |
| 63 | + | windowlessFrameRateMax: opts.windowlessFrameRateMax, | |||
| 64 | + | audioOutputFactory: opts.audioOutputFactory, | |||
| 56 | } | 65 | } | ||
| 57 | } | 66 | } | ||
| 58 | 67 | ||||
| @@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 103 | } | 112 | } | ||
| 104 | 113 | ||||
| 105 | wv := &WebView{ | 114 | wv := &WebView{ | ||
| 106 | - | id: id, | 115 | + | id: id, |
| 107 | - | ctx: ctx, | 116 | + | ctx: ctx, |
| 108 | - | engine: f.engine, | 117 | + | engine: f.engine, |
| 109 | - | factory: f, | 118 | + | factory: f, |
| 110 | - | viewBridge: viewBridge, | 119 | + | viewBridge: viewBridge, |
| 111 | - | audioOutputFactory: f.audioOutputFactory, | 120 | + | audioOutputFactory: f.audioOutputFactory, |
| 112 | - | windowlessFrameRate: f.windowlessFrameRate, | 121 | + | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, |
| 113 | - | backgroundColor: f.bgColor.Load(), | 122 | + | windowlessFrameRate: f.windowlessFrameRate, |
| 123 | + | windowlessFrameRateMax: f.windowlessFrameRateMax, | |||
| 124 | + | backgroundColor: f.bgColor.Load(), | |||
| 114 | } | 125 | } | ||
| 115 | 126 | ||||
| 116 | handlers := &handlerSet{wv: wv} | 127 | handlers := &handlerSet{wv: wv} | ||
| @@ -149,13 +160,13 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||||
| 149 | if wv.viewBridge == nil { | 160 | if wv.viewBridge == nil { | ||
| 150 | return | 161 | return | ||
| 151 | } | 162 | } | ||
| 152 | - | browserCreateScheduled := false | |||
| 153 | wv.runOnGTKSync(func() { | 163 | wv.runOnGTKSync(func() { | ||
| 154 | wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) { | 164 | wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) { | ||
| 155 | - | // Size observers run on the GTK thread. Prepare the GtkGLArea only after it | 165 | + | // Size observers run on the GTK thread. Prepare the GtkGLArea only for |
| 156 | - | // has been packed and allocated; preparing at WebView construction time can | 166 | + | // the one-shot initial resize path; once that path has been consumed, |
| 157 | - | // run before GTK has created a GL context. | 167 | + | // later size events must go through normal viewport sync even if popup |
| 158 | - | if !browserCreateScheduled { | 168 | + | // attachment keeps pendingCreate alive a little longer. |
| 169 | + | if wv.shouldStartBrowserCreateFromSizeObserver() { | |||
| 159 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | 170 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | ||
| 160 | logging.FromContext(ctx).Warn(). | 171 | logging.FromContext(ctx).Warn(). | ||
| 161 | Err(err). | 172 | Err(err). | ||
| @@ -165,7 +176,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||||
| 165 | Msg("cef: cef2gtk view not ready after resize, will retry") | 176 | Msg("cef: cef2gtk view not ready after resize, will retry") | ||
| 166 | return | 177 | return | ||
| 167 | } | 178 | } | ||
| 168 | - | browserCreateScheduled = true | 179 | + | wv.markInitialBrowserCreateResizeHandled() |
| 169 | onFirstResize(w, h) | 180 | onFirstResize(w, h) | ||
| 170 | return | 181 | return | ||
| 171 | } | 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/runtime_config.go +9 -7
| @@ -15,13 +15,15 @@ var ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | type RuntimeConfig struct { | |
| 18 | - | CEFDir string | |
| 19 | - | LogFile string | |
| 20 | - | LogSeverity int32 | |
| 21 | - | RenderStack string | |
| 22 | - | WindowlessFrameRate int32 | |
| 23 | - | EnableAudioHandler bool | |
| 24 | - | TraceHandlers bool | |
| 18 | + | CEFDir string | |
| 19 | + | LogFile string | |
| 20 | + | LogSeverity int32 | |
| 21 | + | RenderStack string | |
| 22 | + | AdaptiveWindowlessFrameRate bool | |
| 23 | + | WindowlessFrameRate int32 | |
| 24 | + | WindowlessFrameRateMax int32 | |
| 25 | + | EnableAudioHandler bool | |
| 26 | + | TraceHandlers bool | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | |
| @@ -15,13 +15,15 @@ var ( | |||||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | type RuntimeConfig struct { | 17 | type RuntimeConfig struct { | ||
| 18 | - | CEFDir string | 18 | + | CEFDir string |
| 19 | - | LogFile string | 19 | + | LogFile string |
| 20 | - | LogSeverity int32 | 20 | + | LogSeverity int32 |
| 21 | - | RenderStack string | 21 | + | RenderStack string |
| 22 | - | WindowlessFrameRate int32 | 22 | + | AdaptiveWindowlessFrameRate bool |
| 23 | - | EnableAudioHandler bool | 23 | + | WindowlessFrameRate int32 |
| 24 | - | TraceHandlers bool | 24 | + | WindowlessFrameRateMax int32 |
| 25 | + | EnableAudioHandler bool | |||
| 26 | + | TraceHandlers bool | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | 29 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | ||
M internal/infrastructure/cef/webview.go +71 -13
| @@ -103,6 +103,12 @@ type WebView struct { | |||
| 103 | 103 | viewportRealizeSignalID uint | |
| 104 | 104 | viewportResizePulseSeq atomic.Uint64 | |
| 105 | 105 | ||
| 106 | + | adaptiveWindowlessFrameRate bool | |
| 107 | + | windowlessFrameRateMax int32 | |
| 108 | + | adaptiveFrameRatePoll *glib.SourceFunc | |
| 109 | + | adaptiveFrameRatePollID uint | |
| 110 | + | lastAdaptiveFrameRate int32 | |
| 111 | + | ||
| 106 | 112 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 107 | 113 | // widget is visible. Access is guarded by mu. | |
| 108 | 114 | beginFrameTick *gtk.TickCallback | |
| @@ -110,6 +116,10 @@ type WebView struct { | |||
| 110 | 116 | ||
| 111 | 117 | // pendingCreate holds browser creation params until the GL area is realized. | |
| 112 | 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 | |
| 113 | 123 | ||
| 114 | 124 | // pendingURI is set when LoadURI is called before the browser exists. | |
| 115 | 125 | pendingURI string | |
| @@ -181,11 +191,12 @@ type WebView struct { | |||
| 181 | 191 | loadDiagLastLoadStateAt time.Time | |
| 182 | 192 | ||
| 183 | 193 | // Atomic state. | |
| 184 | - | destroyed atomic.Bool | |
| 185 | - | fullscreen atomic.Bool | |
| 186 | - | generation atomic.Uint64 | |
| 187 | - | audioPlaying atomic.Bool | |
| 188 | - | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| 194 | + | destroyed atomic.Bool | |
| 195 | + | fullscreen atomic.Bool | |
| 196 | + | generation atomic.Uint64 | |
| 197 | + | audioPlaying atomic.Bool | |
| 198 | + | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| 199 | + | lastAppliedOSRBackingScaleBits atomic.Uint64 | |
| 189 | 200 | ||
| 190 | 201 | // Browser creation defaults copied from the factory so native popup shells | |
| 191 | 202 | // can apply the same settings in OnBeforePopup. | |
| @@ -205,11 +216,13 @@ type WebView struct { | |||
| 205 | 216 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| 206 | 217 | // deferred until the GL area has a non-zero size. | |
| 207 | 218 | type pendingBrowserCreate struct { | |
| 208 | - | windowInfo *purecef.WindowInfo | |
| 209 | - | client purecef.RawClient | |
| 210 | - | settings *purecef.BrowserSettings | |
| 211 | - | extraInfo purecef.DictionaryValue | |
| 212 | - | postTaskRetries int | |
| 219 | + | windowInfo *purecef.WindowInfo | |
| 220 | + | client purecef.RawClient | |
| 221 | + | settings *purecef.BrowserSettings | |
| 222 | + | extraInfo purecef.DictionaryValue | |
| 223 | + | postTaskRetries int | |
| 224 | + | observedSizeRetries int | |
| 225 | + | observedSizeRetryScheduled bool | |
| 213 | 226 | } | |
| 214 | 227 | ||
| 215 | 228 | type cefTaskFunc func() | |
| @@ -451,7 +464,7 @@ func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 { | |||
| 451 | 464 | return factorFromCEFZoom(level) * normalizeScale(backingScale) | |
| 452 | 465 | } | |
| 453 | 466 | ||
| 454 | - | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel float64) { | |
| 467 | + | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) { | |
| 455 | 468 | host.SetZoomLevel(cefLevel) | |
| 456 | 469 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | |
| 457 | 470 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | |
| @@ -460,6 +473,7 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel | |||
| 460 | 473 | // SynchronizeVisualProperties cycle, which makes the renderer produce | |
| 461 | 474 | // a new compositor frame at the new zoom level. | |
| 462 | 475 | host.NotifyScreenInfoChanged() | |
| 476 | + | wv.recordAppliedOSRBackingScale(backingScale) | |
| 463 | 477 | // Zoom is applied asynchronously in the renderer process. Request a couple | |
| 464 | 478 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | |
| 465 | 479 | // compositor frame after the zoom IPC has been processed. | |
| @@ -499,7 +513,7 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||
| 499 | 513 | Float64("cef_level", cefLevel). | |
| 500 | 514 | Float64("osr_backing_scale", backingScale). | |
| 501 | 515 | Msg("cef: SetZoomLevel") | |
| 502 | - | wv.applyCEFZoomLevel(host, factor, cefLevel) | |
| 516 | + | wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale) | |
| 503 | 517 | wv.zoomFactor.Store(factor) | |
| 504 | 518 | return nil | |
| 505 | 519 | } | |
| @@ -1093,6 +1107,7 @@ func (wv *WebView) Destroy() { | |||
| 1093 | 1107 | wv.stopRenderStallWatchdog() | |
| 1094 | 1108 | wv.cancelSelectionDebounce() | |
| 1095 | 1109 | wv.closeAudioStream() | |
| 1110 | + | wv.scheduleStopAdaptiveFrameRatePolling() | |
| 1096 | 1111 | wv.scheduleStopBeginFrameLoop() | |
| 1097 | 1112 | wv.mu.RLock() | |
| 1098 | 1113 | host := wv.host | |
| @@ -1402,6 +1417,25 @@ func (wv *WebView) osrBackingScaleFactor() float64 { | |||
| 1402 | 1417 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | |
| 1403 | 1418 | } | |
| 1404 | 1419 | ||
| 1420 | + | func (wv *WebView) recordAppliedOSRBackingScale(scale float64) { | |
| 1421 | + | if wv == nil { | |
| 1422 | + | return | |
| 1423 | + | } | |
| 1424 | + | wv.lastAppliedOSRBackingScaleBits.Store(math.Float64bits(normalizeScale(scale))) | |
| 1425 | + | } | |
| 1426 | + | ||
| 1427 | + | func (wv *WebView) shouldReapplyZoomForBackingScale(scale float64) bool { | |
| 1428 | + | if wv == nil { | |
| 1429 | + | return false | |
| 1430 | + | } | |
| 1431 | + | normalized := normalizeScale(scale) | |
| 1432 | + | bits := wv.lastAppliedOSRBackingScaleBits.Load() | |
| 1433 | + | if bits == 0 { | |
| 1434 | + | return true | |
| 1435 | + | } | |
| 1436 | + | return math.Float64frombits(bits) != normalized | |
| 1437 | + | } | |
| 1438 | + | ||
| 1405 | 1439 | func (wv *WebView) handleMiddleClickFromBridge() bool { | |
| 1406 | 1440 | if wv == nil { | |
| 1407 | 1441 | return false | |
| @@ -1688,7 +1722,7 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |||
| 1688 | 1722 | factor := wv.GetZoomLevel() | |
| 1689 | 1723 | backingScale := wv.osrBackingScaleFactor() | |
| 1690 | 1724 | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | |
| 1691 | - | wv.applyCEFZoomLevel(host, factor, cefLevel) | |
| 1725 | + | wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale) | |
| 1692 | 1726 | logging.FromContext(wv.ctx).Debug(). | |
| 1693 | 1727 | Str("reason", reason). | |
| 1694 | 1728 | Float64("factor", factor). | |
| @@ -1736,6 +1770,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1736 | 1770 | return false | |
| 1737 | 1771 | } | |
| 1738 | 1772 | host.SendExternalBeginFrame() | |
| 1773 | + | if wv.viewBridge != nil { | |
| 1774 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |
| 1775 | + | } | |
| 1739 | 1776 | return true | |
| 1740 | 1777 | } | |
| 1741 | 1778 | wv.beginFrameTick = cb | |
| @@ -1745,6 +1782,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1745 | 1782 | ||
| 1746 | 1783 | if host != nil { | |
| 1747 | 1784 | host.SendExternalBeginFrame() | |
| 1785 | + | if wv.viewBridge != nil { | |
| 1786 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |
| 1787 | + | } | |
| 1748 | 1788 | } | |
| 1749 | 1789 | } | |
| 1750 | 1790 | ||
| @@ -1847,6 +1887,24 @@ func (wv *WebView) runNavigationCallbacks(uri string) { | |||
| 1847 | 1887 | }) | |
| 1848 | 1888 | } | |
| 1849 | 1889 | ||
| 1890 | + | func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool { | |
| 1891 | + | if wv == nil { | |
| 1892 | + | return false | |
| 1893 | + | } | |
| 1894 | + | wv.mu.RLock() | |
| 1895 | + | defer wv.mu.RUnlock() | |
| 1896 | + | return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled | |
| 1897 | + | } | |
| 1898 | + | ||
| 1899 | + | func (wv *WebView) markInitialBrowserCreateResizeHandled() { | |
| 1900 | + | if wv == nil { | |
| 1901 | + | return | |
| 1902 | + | } | |
| 1903 | + | wv.mu.Lock() | |
| 1904 | + | defer wv.mu.Unlock() | |
| 1905 | + | wv.initialBrowserCreateResizeHandled = true | |
| 1906 | + | } | |
| 1907 | + | ||
| 1850 | 1908 | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |
| 1851 | 1909 | wv.mu.Lock() | |
| 1852 | 1910 | defer wv.mu.Unlock() | |
| @@ -103,6 +103,12 @@ type WebView struct { | |||||
| 103 | viewportRealizeSignalID uint | 103 | viewportRealizeSignalID uint | ||
| 104 | viewportResizePulseSeq atomic.Uint64 | 104 | viewportResizePulseSeq atomic.Uint64 | ||
| 105 | 105 | ||||
| 106 | + | adaptiveWindowlessFrameRate bool | |||
| 107 | + | windowlessFrameRateMax int32 | |||
| 108 | + | adaptiveFrameRatePoll *glib.SourceFunc | |||
| 109 | + | adaptiveFrameRatePollID uint | |||
| 110 | + | lastAdaptiveFrameRate int32 | |||
| 111 | + | ||||
| 106 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 112 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | ||
| 107 | // widget is visible. Access is guarded by mu. | 113 | // widget is visible. Access is guarded by mu. | ||
| 108 | beginFrameTick *gtk.TickCallback | 114 | beginFrameTick *gtk.TickCallback | ||
| @@ -110,6 +116,10 @@ type WebView struct { | |||||
| 110 | 116 | ||||
| 111 | // pendingCreate holds browser creation params until the GL area is realized. | 117 | // pendingCreate holds browser creation params until the GL area is realized. | ||
| 112 | pendingCreate *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 | |||
| 113 | 123 | ||||
| 114 | // pendingURI is set when LoadURI is called before the browser exists. | 124 | // pendingURI is set when LoadURI is called before the browser exists. | ||
| 115 | pendingURI string | 125 | pendingURI string | ||
| @@ -181,11 +191,12 @@ type WebView struct { | |||||
| 181 | loadDiagLastLoadStateAt time.Time | 191 | loadDiagLastLoadStateAt time.Time | ||
| 182 | 192 | ||||
| 183 | // Atomic state. | 193 | // Atomic state. | ||
| 184 | - | destroyed atomic.Bool | 194 | + | destroyed atomic.Bool |
| 185 | - | fullscreen atomic.Bool | 195 | + | fullscreen atomic.Bool |
| 186 | - | generation atomic.Uint64 | 196 | + | generation atomic.Uint64 |
| 187 | - | audioPlaying atomic.Bool | 197 | + | audioPlaying atomic.Bool |
| 188 | - | zoomFactor atomic.Value // float64, initialized to 1.0 | 198 | + | zoomFactor atomic.Value // float64, initialized to 1.0 |
| 199 | + | lastAppliedOSRBackingScaleBits atomic.Uint64 | |||
| 189 | 200 | ||||
| 190 | // Browser creation defaults copied from the factory so native popup shells | 201 | // Browser creation defaults copied from the factory so native popup shells | ||
| 191 | // can apply the same settings in OnBeforePopup. | 202 | // can apply the same settings in OnBeforePopup. | ||
| @@ -205,11 +216,13 @@ type WebView struct { | |||||
| 205 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | 216 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | ||
| 206 | // deferred until the GL area has a non-zero size. | 217 | // deferred until the GL area has a non-zero size. | ||
| 207 | type pendingBrowserCreate struct { | 218 | type pendingBrowserCreate struct { | ||
| 208 | - | windowInfo *purecef.WindowInfo | 219 | + | windowInfo *purecef.WindowInfo |
| 209 | - | client purecef.RawClient | 220 | + | client purecef.RawClient |
| 210 | - | settings *purecef.BrowserSettings | 221 | + | settings *purecef.BrowserSettings |
| 211 | - | extraInfo purecef.DictionaryValue | 222 | + | extraInfo purecef.DictionaryValue |
| 212 | - | postTaskRetries int | 223 | + | postTaskRetries int |
| 224 | + | observedSizeRetries int | |||
| 225 | + | observedSizeRetryScheduled bool | |||
| 213 | } | 226 | } | ||
| 214 | 227 | ||||
| 215 | type cefTaskFunc func() | 228 | type cefTaskFunc func() | ||
| @@ -451,7 +464,7 @@ func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 { | |||||
| 451 | return factorFromCEFZoom(level) * normalizeScale(backingScale) | 464 | return factorFromCEFZoom(level) * normalizeScale(backingScale) | ||
| 452 | } | 465 | } | ||
| 453 | 466 | ||||
| 454 | - | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel float64) { | 467 | + | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) { |
| 455 | host.SetZoomLevel(cefLevel) | 468 | host.SetZoomLevel(cefLevel) | ||
| 456 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | 469 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | ||
| 457 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | 470 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | ||
| @@ -460,6 +473,7 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel | |||||
| 460 | // SynchronizeVisualProperties cycle, which makes the renderer produce | 473 | // SynchronizeVisualProperties cycle, which makes the renderer produce | ||
| 461 | // a new compositor frame at the new zoom level. | 474 | // a new compositor frame at the new zoom level. | ||
| 462 | host.NotifyScreenInfoChanged() | 475 | host.NotifyScreenInfoChanged() | ||
| 476 | + | wv.recordAppliedOSRBackingScale(backingScale) | |||
| 463 | // Zoom is applied asynchronously in the renderer process. Request a couple | 477 | // Zoom is applied asynchronously in the renderer process. Request a couple | ||
| 464 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | 478 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | ||
| 465 | // compositor frame after the zoom IPC has been processed. | 479 | // compositor frame after the zoom IPC has been processed. | ||
| @@ -499,7 +513,7 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||||
| 499 | Float64("cef_level", cefLevel). | 513 | Float64("cef_level", cefLevel). | ||
| 500 | Float64("osr_backing_scale", backingScale). | 514 | Float64("osr_backing_scale", backingScale). | ||
| 501 | Msg("cef: SetZoomLevel") | 515 | Msg("cef: SetZoomLevel") | ||
| 502 | - | wv.applyCEFZoomLevel(host, factor, cefLevel) | 516 | + | wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale) |
| 503 | wv.zoomFactor.Store(factor) | 517 | wv.zoomFactor.Store(factor) | ||
| 504 | return nil | 518 | return nil | ||
| 505 | } | 519 | } | ||
| @@ -1093,6 +1107,7 @@ func (wv *WebView) Destroy() { | |||||
| 1093 | wv.stopRenderStallWatchdog() | 1107 | wv.stopRenderStallWatchdog() | ||
| 1094 | wv.cancelSelectionDebounce() | 1108 | wv.cancelSelectionDebounce() | ||
| 1095 | wv.closeAudioStream() | 1109 | wv.closeAudioStream() | ||
| 1110 | + | wv.scheduleStopAdaptiveFrameRatePolling() | |||
| 1096 | wv.scheduleStopBeginFrameLoop() | 1111 | wv.scheduleStopBeginFrameLoop() | ||
| 1097 | wv.mu.RLock() | 1112 | wv.mu.RLock() | ||
| 1098 | host := wv.host | 1113 | host := wv.host | ||
| @@ -1402,6 +1417,25 @@ func (wv *WebView) osrBackingScaleFactor() float64 { | |||||
| 1402 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | 1417 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | ||
| 1403 | } | 1418 | } | ||
| 1404 | 1419 | ||||
| 1420 | + | func (wv *WebView) recordAppliedOSRBackingScale(scale float64) { | |||
| 1421 | + | if wv == nil { | |||
| 1422 | + | return | |||
| 1423 | + | } | |||
| 1424 | + | wv.lastAppliedOSRBackingScaleBits.Store(math.Float64bits(normalizeScale(scale))) | |||
| 1425 | + | } | |||
| 1426 | + | ||||
| 1427 | + | func (wv *WebView) shouldReapplyZoomForBackingScale(scale float64) bool { | |||
| 1428 | + | if wv == nil { | |||
| 1429 | + | return false | |||
| 1430 | + | } | |||
| 1431 | + | normalized := normalizeScale(scale) | |||
| 1432 | + | bits := wv.lastAppliedOSRBackingScaleBits.Load() | |||
| 1433 | + | if bits == 0 { | |||
| 1434 | + | return true | |||
| 1435 | + | } | |||
| 1436 | + | return math.Float64frombits(bits) != normalized | |||
| 1437 | + | } | |||
| 1438 | + | ||||
| 1405 | func (wv *WebView) handleMiddleClickFromBridge() bool { | 1439 | func (wv *WebView) handleMiddleClickFromBridge() bool { | ||
| 1406 | if wv == nil { | 1440 | if wv == nil { | ||
| 1407 | return false | 1441 | return false | ||
| @@ -1688,7 +1722,7 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |||||
| 1688 | factor := wv.GetZoomLevel() | 1722 | factor := wv.GetZoomLevel() | ||
| 1689 | backingScale := wv.osrBackingScaleFactor() | 1723 | backingScale := wv.osrBackingScaleFactor() | ||
| 1690 | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | 1724 | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | ||
| 1691 | - | wv.applyCEFZoomLevel(host, factor, cefLevel) | 1725 | + | wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale) |
| 1692 | logging.FromContext(wv.ctx).Debug(). | 1726 | logging.FromContext(wv.ctx).Debug(). | ||
| 1693 | Str("reason", reason). | 1727 | Str("reason", reason). | ||
| 1694 | Float64("factor", factor). | 1728 | Float64("factor", factor). | ||
| @@ -1736,6 +1770,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 1736 | return false | 1770 | return false | ||
| 1737 | } | 1771 | } | ||
| 1738 | host.SendExternalBeginFrame() | 1772 | host.SendExternalBeginFrame() | ||
| 1773 | + | if wv.viewBridge != nil { | |||
| 1774 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |||
| 1775 | + | } | |||
| 1739 | return true | 1776 | return true | ||
| 1740 | } | 1777 | } | ||
| 1741 | wv.beginFrameTick = cb | 1778 | wv.beginFrameTick = cb | ||
| @@ -1745,6 +1782,9 @@ func (wv *WebView) startBeginFrameLoop() { | |||||
| 1745 | 1782 | ||||
| 1746 | if host != nil { | 1783 | if host != nil { | ||
| 1747 | host.SendExternalBeginFrame() | 1784 | host.SendExternalBeginFrame() | ||
| 1785 | + | if wv.viewBridge != nil { | |||
| 1786 | + | wv.viewBridge.RecordExternalBeginFrameSent() | |||
| 1787 | + | } | |||
| 1748 | } | 1788 | } | ||
| 1749 | } | 1789 | } | ||
| 1750 | 1790 | ||||
| @@ -1847,6 +1887,24 @@ func (wv *WebView) runNavigationCallbacks(uri string) { | |||||
| 1847 | }) | 1887 | }) | ||
| 1848 | } | 1888 | } | ||
| 1849 | 1889 | ||||
| 1890 | + | func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool { | |||
| 1891 | + | if wv == nil { | |||
| 1892 | + | return false | |||
| 1893 | + | } | |||
| 1894 | + | wv.mu.RLock() | |||
| 1895 | + | defer wv.mu.RUnlock() | |||
| 1896 | + | return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled | |||
| 1897 | + | } | |||
| 1898 | + | ||||
| 1899 | + | func (wv *WebView) markInitialBrowserCreateResizeHandled() { | |||
| 1900 | + | if wv == nil { | |||
| 1901 | + | return | |||
| 1902 | + | } | |||
| 1903 | + | wv.mu.Lock() | |||
| 1904 | + | defer wv.mu.Unlock() | |||
| 1905 | + | wv.initialBrowserCreateResizeHandled = true | |||
| 1906 | + | } | |||
| 1907 | + | ||||
| 1850 | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | 1908 | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | ||
| 1851 | wv.mu.Lock() | 1909 | wv.mu.Lock() | ||
| 1852 | defer wv.mu.Unlock() | 1910 | defer wv.mu.Unlock() | ||
M internal/infrastructure/cef/webview_handlers.go +9 -0
| @@ -617,6 +617,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 617 | 617 | ||
| 618 | 618 | h.wv.browser = browser | |
| 619 | 619 | h.wv.host = host | |
| 620 | + | h.wv.lastAppliedOSRBackingScaleBits.Store(0) | |
| 620 | 621 | h.wv.pendingCreate = nil | |
| 621 | 622 | bridge := h.wv.viewBridge | |
| 622 | 623 | h.wv.inputAttached = bridge == nil | |
| @@ -667,6 +668,11 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 667 | 668 | // painting/caret updates until explicitly told the browser is shown. | |
| 668 | 669 | host.WasHidden(0) | |
| 669 | 670 | if h.wv != nil { | |
| 671 | + | if h.wv.ctx != nil { | |
| 672 | + | logging.FromContext(h.wv.ctx).Info(). | |
| 673 | + | Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()). | |
| 674 | + | Msg("cef: browser initial/configured windowless frame rate") | |
| 675 | + | } | |
| 670 | 676 | h.wv.SyncViewport(h.wv.ctx, "after-created") | |
| 671 | 677 | } | |
| 672 | 678 | return state | |
| @@ -709,6 +715,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 709 | 715 | if state.nativePopupParent != nil && state.nativePopupID != 0 { | |
| 710 | 716 | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | |
| 711 | 717 | } | |
| 718 | + | h.wv.scheduleStartAdaptiveFrameRatePolling() | |
| 712 | 719 | h.wv.scheduleStartBeginFrameLoop() | |
| 713 | 720 | h.wv.fireReadyToShow() | |
| 714 | 721 | } | |
| @@ -739,6 +746,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 739 | 746 | h.wv.mu.Lock() | |
| 740 | 747 | h.wv.browser = nil | |
| 741 | 748 | h.wv.host = nil | |
| 749 | + | h.wv.lastAppliedOSRBackingScaleBits.Store(0) | |
| 742 | 750 | h.wv.inputAttached = false | |
| 743 | 751 | if h.wv.findCtrl != nil { | |
| 744 | 752 | h.wv.findCtrl.setHost(nil) | |
| @@ -757,6 +765,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 757 | 765 | } | |
| 758 | 766 | }) | |
| 759 | 767 | } | |
| 768 | + | h.wv.scheduleStopAdaptiveFrameRatePolling() | |
| 760 | 769 | h.wv.scheduleStopBeginFrameLoop() | |
| 761 | 770 | ||
| 762 | 771 | h.wv.mu.RLock() | |
| @@ -617,6 +617,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 617 | 617 | ||||
| 618 | h.wv.browser = browser | 618 | h.wv.browser = browser | ||
| 619 | h.wv.host = host | 619 | h.wv.host = host | ||
| 620 | + | h.wv.lastAppliedOSRBackingScaleBits.Store(0) | |||
| 620 | h.wv.pendingCreate = nil | 621 | h.wv.pendingCreate = nil | ||
| 621 | bridge := h.wv.viewBridge | 622 | bridge := h.wv.viewBridge | ||
| 622 | h.wv.inputAttached = bridge == nil | 623 | h.wv.inputAttached = bridge == nil | ||
| @@ -667,6 +668,11 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 667 | // painting/caret updates until explicitly told the browser is shown. | 668 | // painting/caret updates until explicitly told the browser is shown. | ||
| 668 | host.WasHidden(0) | 669 | host.WasHidden(0) | ||
| 669 | if h.wv != nil { | 670 | if h.wv != nil { | ||
| 671 | + | if h.wv.ctx != nil { | |||
| 672 | + | logging.FromContext(h.wv.ctx).Info(). | |||
| 673 | + | Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()). | |||
| 674 | + | Msg("cef: browser initial/configured windowless frame rate") | |||
| 675 | + | } | |||
| 670 | h.wv.SyncViewport(h.wv.ctx, "after-created") | 676 | h.wv.SyncViewport(h.wv.ctx, "after-created") | ||
| 671 | } | 677 | } | ||
| 672 | return state | 678 | return state | ||
| @@ -709,6 +715,7 @@ func (h *handlerSet) finishAfterCreated( | |||||
| 709 | if state.nativePopupParent != nil && state.nativePopupID != 0 { | 715 | if state.nativePopupParent != nil && state.nativePopupID != 0 { | ||
| 710 | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | 716 | state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv) | ||
| 711 | } | 717 | } | ||
| 718 | + | h.wv.scheduleStartAdaptiveFrameRatePolling() | |||
| 712 | h.wv.scheduleStartBeginFrameLoop() | 719 | h.wv.scheduleStartBeginFrameLoop() | ||
| 713 | h.wv.fireReadyToShow() | 720 | h.wv.fireReadyToShow() | ||
| 714 | } | 721 | } | ||
| @@ -739,6 +746,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||||
| 739 | h.wv.mu.Lock() | 746 | h.wv.mu.Lock() | ||
| 740 | h.wv.browser = nil | 747 | h.wv.browser = nil | ||
| 741 | h.wv.host = nil | 748 | h.wv.host = nil | ||
| 749 | + | h.wv.lastAppliedOSRBackingScaleBits.Store(0) | |||
| 742 | h.wv.inputAttached = false | 750 | h.wv.inputAttached = false | ||
| 743 | if h.wv.findCtrl != nil { | 751 | if h.wv.findCtrl != nil { | ||
| 744 | h.wv.findCtrl.setHost(nil) | 752 | h.wv.findCtrl.setHost(nil) | ||
| @@ -757,6 +765,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||||
| 757 | } | 765 | } | ||
| 758 | }) | 766 | }) | ||
| 759 | } | 767 | } | ||
| 768 | + | h.wv.scheduleStopAdaptiveFrameRatePolling() | |||
| 760 | h.wv.scheduleStopBeginFrameLoop() | 769 | h.wv.scheduleStopBeginFrameLoop() | ||
| 761 | 770 | ||||
| 762 | h.wv.mu.RLock() | 771 | h.wv.mu.RLock() | ||
M internal/infrastructure/cef/webview_viewport.go +228 -1
| @@ -3,6 +3,7 @@ package cef | |||
| 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 | |
| @@ -102,6 +126,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool | |||
| 102 | 126 | widget := wv.viewBridge.Widget() | |
| 103 | 127 | visible := widget != nil && widget.IsVisible() | |
| 104 | 128 | wv.notifyViewportSyncOnCEFUIThread(host, visible) | |
| 129 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, reason) | |
| 105 | 130 | logging.FromContext(ctx).Debug(). | |
| 106 | 131 | Uint64("webview_id", uint64(wv.id)). | |
| 107 | 132 | Bool("visible", visible). | |
| @@ -182,6 +207,64 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||
| 182 | 207 | Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet") | |
| 183 | 208 | return false | |
| 184 | 209 | } | |
| 210 | + | ||
| 211 | + | observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height) | |
| 212 | + | if !observedReady { | |
| 213 | + | widget.QueueAllocate() | |
| 214 | + | widget.QueueResize() | |
| 215 | + | attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason) | |
| 216 | + | switch action { | |
| 217 | + | case pendingBrowserCreateObservedSizeRetryUnavailable: | |
| 218 | + | return false | |
| 219 | + | case pendingBrowserCreateObservedSizeRetryAlreadyScheduled: | |
| 220 | + | logging.FromContext(ctx).Debug(). | |
| 221 | + | Uint64("webview_id", uint64(wv.id)). | |
| 222 | + | Int32("observed_width", observedWidth). | |
| 223 | + | Int32("observed_height", observedHeight). | |
| 224 | + | Int32("width", width). | |
| 225 | + | Int32("height", height). | |
| 226 | + | Int("attempt", attempt). | |
| 227 | + | Str("reason", reason). | |
| 228 | + | Msg("cef: pending browser creation retry already scheduled while awaiting bridge size") | |
| 229 | + | return false | |
| 230 | + | case pendingBrowserCreateObservedSizeRetryScheduled: | |
| 231 | + | logging.FromContext(ctx).Debug(). | |
| 232 | + | Uint64("webview_id", uint64(wv.id)). | |
| 233 | + | Int32("observed_width", observedWidth). | |
| 234 | + | Int32("observed_height", observedHeight). | |
| 235 | + | Int32("width", width). | |
| 236 | + | Int32("height", height). | |
| 237 | + | Int("attempt", attempt). | |
| 238 | + | Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries). | |
| 239 | + | Str("reason", reason). | |
| 240 | + | Msg("cef: delaying pending browser creation until bridge observes widget size") | |
| 241 | + | return false | |
| 242 | + | case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay: | |
| 243 | + | // Creating the browser here is intentional: after bounded retries we | |
| 244 | + | // fall back to the current GTK allocation rather than stalling popup | |
| 245 | + | // creation. | |
| 246 | + | logging.FromContext(ctx).Debug(). | |
| 247 | + | Uint64("webview_id", uint64(wv.id)). | |
| 248 | + | Int32("observed_width", observedWidth). | |
| 249 | + | Int32("observed_height", observedHeight). | |
| 250 | + | Int32("width", width). | |
| 251 | + | Int32("height", height). | |
| 252 | + | Int("attempt", attempt). | |
| 253 | + | Str("reason", reason). | |
| 254 | + | Msg("cef: bridge size observation did not settle before browser creation; continuing") | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | if wv.shouldDeferPendingBrowserCreateFromViewportSync() { | |
| 259 | + | logging.FromContext(ctx).Debug(). | |
| 260 | + | Uint64("webview_id", uint64(wv.id)). | |
| 261 | + | Int32("width", width). | |
| 262 | + | Int32("height", height). | |
| 263 | + | Str("reason", reason). | |
| 264 | + | Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach") | |
| 265 | + | return false | |
| 266 | + | } | |
| 267 | + | ||
| 185 | 268 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |
| 186 | 269 | logging.FromContext(ctx).Debug(). | |
| 187 | 270 | Err(err). | |
| @@ -193,6 +276,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||
| 193 | 276 | return false | |
| 194 | 277 | } | |
| 195 | 278 | ||
| 279 | + | wv.markInitialBrowserCreateResizeHandled() | |
| 196 | 280 | wv.factory.postPendingBrowserCreate(ctx, wv, width, height) | |
| 197 | 281 | logging.FromContext(ctx).Debug(). | |
| 198 | 282 | Uint64("webview_id", uint64(wv.id)). | |
| @@ -203,6 +287,121 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||
| 203 | 287 | return true | |
| 204 | 288 | } | |
| 205 | 289 | ||
| 290 | + | func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool { | |
| 291 | + | return wv != nil && wv.awaitsNativePopupAttachment() | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func pendingBrowserCreateObservedSize( | |
| 295 | + | bridge pendingBrowserCreateObservedSizeBridge, | |
| 296 | + | allocatedWidth, allocatedHeight int32, | |
| 297 | + | ) (observedWidth, observedHeight int32, ready bool) { | |
| 298 | + | if bridge == nil { | |
| 299 | + | return 0, 0, false | |
| 300 | + | } | |
| 301 | + | observedWidth, observedHeight = bridge.Size() | |
| 302 | + | if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) { | |
| 303 | + | return observedWidth, observedHeight, true | |
| 304 | + | } | |
| 305 | + | observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread() | |
| 306 | + | return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // observedViewportSizeReady reports whether observedWidth/observedHeight match | |
| 310 | + | // allocatedWidth/allocatedHeight within a ±1px tolerance. | |
| 311 | + | // | |
| 312 | + | // Fallback observed sizes (<=1) and non-positive allocated dimensions are | |
| 313 | + | // treated as not ready. | |
| 314 | + | func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool { | |
| 315 | + | if allocatedWidth <= 0 || allocatedHeight <= 0 { | |
| 316 | + | return false | |
| 317 | + | } | |
| 318 | + | if observedWidth <= 1 || observedHeight <= 1 { | |
| 319 | + | return false | |
| 320 | + | } | |
| 321 | + | return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1 | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // abs32 returns the absolute value of v. | |
| 325 | + | func abs32(v int32) int32 { | |
| 326 | + | if v < 0 { | |
| 327 | + | return -v | |
| 328 | + | } | |
| 329 | + | return v | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry | |
| 333 | + | // state machine for a pending browser create. | |
| 334 | + | // | |
| 335 | + | // It returns the current attempt count and the resulting action. Scheduling is | |
| 336 | + | // coalesced so only one delayed retry is pending at a time, while the max-retry | |
| 337 | + | // path proceeds without leaving the scheduled flag set. | |
| 338 | + | func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry( | |
| 339 | + | ctx context.Context, | |
| 340 | + | reason string, | |
| 341 | + | ) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) { | |
| 342 | + | if wv == nil { | |
| 343 | + | return 0, pendingBrowserCreateObservedSizeRetryUnavailable | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | shouldSchedule := false | |
| 347 | + | wv.mu.Lock() | |
| 348 | + | if wv.pendingCreate == nil { | |
| 349 | + | wv.mu.Unlock() | |
| 350 | + | return 0, pendingBrowserCreateObservedSizeRetryUnavailable | |
| 351 | + | } | |
| 352 | + | if wv.pendingCreate.observedSizeRetryScheduled { | |
| 353 | + | attempt = wv.pendingCreate.observedSizeRetries | |
| 354 | + | wv.mu.Unlock() | |
| 355 | + | return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled | |
| 356 | + | } | |
| 357 | + | wv.pendingCreate.observedSizeRetries++ | |
| 358 | + | attempt = wv.pendingCreate.observedSizeRetries | |
| 359 | + | if attempt <= pendingBrowserCreateObservedSizeMaxRetries { | |
| 360 | + | wv.pendingCreate.observedSizeRetryScheduled = true | |
| 361 | + | shouldSchedule = true | |
| 362 | + | } | |
| 363 | + | wv.mu.Unlock() | |
| 364 | + | ||
| 365 | + | if !shouldSchedule { | |
| 366 | + | return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay | |
| 367 | + | } | |
| 368 | + | wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason) | |
| 369 | + | return attempt, pendingBrowserCreateObservedSizeRetryScheduled | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag | |
| 373 | + | // so a future observed-size reservation can schedule another attempt. | |
| 374 | + | func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() { | |
| 375 | + | if wv == nil { | |
| 376 | + | return | |
| 377 | + | } | |
| 378 | + | wv.mu.Lock() | |
| 379 | + | defer wv.mu.Unlock() | |
| 380 | + | if wv.pendingCreate != nil { | |
| 381 | + | wv.pendingCreate.observedSizeRetryScheduled = false | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport | |
| 386 | + | // sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay. | |
| 387 | + | // | |
| 388 | + | // It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a | |
| 389 | + | // retry-specific suffix appended to reason. | |
| 390 | + | func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) { | |
| 391 | + | if wv == nil || wv.destroyed.Load() { | |
| 392 | + | return | |
| 393 | + | } | |
| 394 | + | cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() { | |
| 395 | + | if wv == nil || wv.destroyed.Load() { | |
| 396 | + | return | |
| 397 | + | } | |
| 398 | + | wv.clearPendingBrowserCreateObservedSizeRetry() | |
| 399 | + | wv.runOnGTK(func() { | |
| 400 | + | wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size") | |
| 401 | + | }) | |
| 402 | + | }) | |
| 403 | + | } | |
| 404 | + | ||
| 206 | 405 | func (wv *WebView) installViewportSyncHooks() { | |
| 207 | 406 | if wv == nil || wv.viewBridge == nil { | |
| 208 | 407 | return | |
| @@ -279,7 +478,35 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, | |||
| 279 | 478 | return | |
| 280 | 479 | } | |
| 281 | 480 | notifyBrowserViewportSync(host, visible) | |
| 282 | - | wv.reapplyCurrentZoomForBackingScale("viewport-sync") | |
| 481 | + | })) | |
| 482 | + | if task == nil { | |
| 483 | + | return | |
| 484 | + | } | |
| 485 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0) | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost, reason string) { | |
| 489 | + | if host == nil || wv == nil { | |
| 490 | + | return | |
| 491 | + | } | |
| 492 | + | if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) { | |
| 493 | + | return | |
| 494 | + | } | |
| 495 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 496 | + | if wv == nil || wv.destroyed.Load() { | |
| 497 | + | return | |
| 498 | + | } | |
| 499 | + | wv.mu.RLock() | |
| 500 | + | currentHost := wv.host | |
| 501 | + | wv.mu.RUnlock() | |
| 502 | + | if currentHost != host { | |
| 503 | + | return | |
| 504 | + | } | |
| 505 | + | backingScale := wv.osrBackingScaleFactor() | |
| 506 | + | if !wv.shouldReapplyZoomForBackingScale(backingScale) { | |
| 507 | + | return | |
| 508 | + | } | |
| 509 | + | wv.reapplyCurrentZoomForBackingScale(reason) | |
| 283 | 510 | })) | |
| 284 | 511 | if task == nil { | |
| 285 | 512 | 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 | ||
| @@ -102,6 +126,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool | |||||
| 102 | widget := wv.viewBridge.Widget() | 126 | widget := wv.viewBridge.Widget() | ||
| 103 | visible := widget != nil && widget.IsVisible() | 127 | visible := widget != nil && widget.IsVisible() | ||
| 104 | wv.notifyViewportSyncOnCEFUIThread(host, visible) | 128 | wv.notifyViewportSyncOnCEFUIThread(host, visible) | ||
| 129 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, reason) | |||
| 105 | logging.FromContext(ctx).Debug(). | 130 | logging.FromContext(ctx).Debug(). | ||
| 106 | Uint64("webview_id", uint64(wv.id)). | 131 | Uint64("webview_id", uint64(wv.id)). | ||
| 107 | Bool("visible", visible). | 132 | Bool("visible", visible). | ||
| @@ -182,6 +207,64 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||||
| 182 | Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet") | 207 | Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet") | ||
| 183 | return false | 208 | return false | ||
| 184 | } | 209 | } | ||
| 210 | + | ||||
| 211 | + | observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height) | |||
| 212 | + | if !observedReady { | |||
| 213 | + | widget.QueueAllocate() | |||
| 214 | + | widget.QueueResize() | |||
| 215 | + | attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason) | |||
| 216 | + | switch action { | |||
| 217 | + | case pendingBrowserCreateObservedSizeRetryUnavailable: | |||
| 218 | + | return false | |||
| 219 | + | case pendingBrowserCreateObservedSizeRetryAlreadyScheduled: | |||
| 220 | + | logging.FromContext(ctx).Debug(). | |||
| 221 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 222 | + | Int32("observed_width", observedWidth). | |||
| 223 | + | Int32("observed_height", observedHeight). | |||
| 224 | + | Int32("width", width). | |||
| 225 | + | Int32("height", height). | |||
| 226 | + | Int("attempt", attempt). | |||
| 227 | + | Str("reason", reason). | |||
| 228 | + | Msg("cef: pending browser creation retry already scheduled while awaiting bridge size") | |||
| 229 | + | return false | |||
| 230 | + | case pendingBrowserCreateObservedSizeRetryScheduled: | |||
| 231 | + | logging.FromContext(ctx).Debug(). | |||
| 232 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 233 | + | Int32("observed_width", observedWidth). | |||
| 234 | + | Int32("observed_height", observedHeight). | |||
| 235 | + | Int32("width", width). | |||
| 236 | + | Int32("height", height). | |||
| 237 | + | Int("attempt", attempt). | |||
| 238 | + | Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries). | |||
| 239 | + | Str("reason", reason). | |||
| 240 | + | Msg("cef: delaying pending browser creation until bridge observes widget size") | |||
| 241 | + | return false | |||
| 242 | + | case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay: | |||
| 243 | + | // Creating the browser here is intentional: after bounded retries we | |||
| 244 | + | // fall back to the current GTK allocation rather than stalling popup | |||
| 245 | + | // creation. | |||
| 246 | + | logging.FromContext(ctx).Debug(). | |||
| 247 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 248 | + | Int32("observed_width", observedWidth). | |||
| 249 | + | Int32("observed_height", observedHeight). | |||
| 250 | + | Int32("width", width). | |||
| 251 | + | Int32("height", height). | |||
| 252 | + | Int("attempt", attempt). | |||
| 253 | + | Str("reason", reason). | |||
| 254 | + | Msg("cef: bridge size observation did not settle before browser creation; continuing") | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | if wv.shouldDeferPendingBrowserCreateFromViewportSync() { | |||
| 259 | + | logging.FromContext(ctx).Debug(). | |||
| 260 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 261 | + | Int32("width", width). | |||
| 262 | + | Int32("height", height). | |||
| 263 | + | Str("reason", reason). | |||
| 264 | + | Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach") | |||
| 265 | + | return false | |||
| 266 | + | } | |||
| 267 | + | ||||
| 185 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | 268 | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | ||
| 186 | logging.FromContext(ctx).Debug(). | 269 | logging.FromContext(ctx).Debug(). | ||
| 187 | Err(err). | 270 | Err(err). | ||
| @@ -193,6 +276,7 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||||
| 193 | return false | 276 | return false | ||
| 194 | } | 277 | } | ||
| 195 | 278 | ||||
| 279 | + | wv.markInitialBrowserCreateResizeHandled() | |||
| 196 | wv.factory.postPendingBrowserCreate(ctx, wv, width, height) | 280 | wv.factory.postPendingBrowserCreate(ctx, wv, width, height) | ||
| 197 | logging.FromContext(ctx).Debug(). | 281 | logging.FromContext(ctx).Debug(). | ||
| 198 | Uint64("webview_id", uint64(wv.id)). | 282 | Uint64("webview_id", uint64(wv.id)). | ||
| @@ -203,6 +287,121 @@ func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, | |||||
| 203 | return true | 287 | return true | ||
| 204 | } | 288 | } | ||
| 205 | 289 | ||||
| 290 | + | func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool { | |||
| 291 | + | return wv != nil && wv.awaitsNativePopupAttachment() | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func pendingBrowserCreateObservedSize( | |||
| 295 | + | bridge pendingBrowserCreateObservedSizeBridge, | |||
| 296 | + | allocatedWidth, allocatedHeight int32, | |||
| 297 | + | ) (observedWidth, observedHeight int32, ready bool) { | |||
| 298 | + | if bridge == nil { | |||
| 299 | + | return 0, 0, false | |||
| 300 | + | } | |||
| 301 | + | observedWidth, observedHeight = bridge.Size() | |||
| 302 | + | if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) { | |||
| 303 | + | return observedWidth, observedHeight, true | |||
| 304 | + | } | |||
| 305 | + | observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread() | |||
| 306 | + | return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // observedViewportSizeReady reports whether observedWidth/observedHeight match | |||
| 310 | + | // allocatedWidth/allocatedHeight within a ±1px tolerance. | |||
| 311 | + | // | |||
| 312 | + | // Fallback observed sizes (<=1) and non-positive allocated dimensions are | |||
| 313 | + | // treated as not ready. | |||
| 314 | + | func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool { | |||
| 315 | + | if allocatedWidth <= 0 || allocatedHeight <= 0 { | |||
| 316 | + | return false | |||
| 317 | + | } | |||
| 318 | + | if observedWidth <= 1 || observedHeight <= 1 { | |||
| 319 | + | return false | |||
| 320 | + | } | |||
| 321 | + | return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1 | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // abs32 returns the absolute value of v. | |||
| 325 | + | func abs32(v int32) int32 { | |||
| 326 | + | if v < 0 { | |||
| 327 | + | return -v | |||
| 328 | + | } | |||
| 329 | + | return v | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry | |||
| 333 | + | // state machine for a pending browser create. | |||
| 334 | + | // | |||
| 335 | + | // It returns the current attempt count and the resulting action. Scheduling is | |||
| 336 | + | // coalesced so only one delayed retry is pending at a time, while the max-retry | |||
| 337 | + | // path proceeds without leaving the scheduled flag set. | |||
| 338 | + | func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry( | |||
| 339 | + | ctx context.Context, | |||
| 340 | + | reason string, | |||
| 341 | + | ) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) { | |||
| 342 | + | if wv == nil { | |||
| 343 | + | return 0, pendingBrowserCreateObservedSizeRetryUnavailable | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | shouldSchedule := false | |||
| 347 | + | wv.mu.Lock() | |||
| 348 | + | if wv.pendingCreate == nil { | |||
| 349 | + | wv.mu.Unlock() | |||
| 350 | + | return 0, pendingBrowserCreateObservedSizeRetryUnavailable | |||
| 351 | + | } | |||
| 352 | + | if wv.pendingCreate.observedSizeRetryScheduled { | |||
| 353 | + | attempt = wv.pendingCreate.observedSizeRetries | |||
| 354 | + | wv.mu.Unlock() | |||
| 355 | + | return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled | |||
| 356 | + | } | |||
| 357 | + | wv.pendingCreate.observedSizeRetries++ | |||
| 358 | + | attempt = wv.pendingCreate.observedSizeRetries | |||
| 359 | + | if attempt <= pendingBrowserCreateObservedSizeMaxRetries { | |||
| 360 | + | wv.pendingCreate.observedSizeRetryScheduled = true | |||
| 361 | + | shouldSchedule = true | |||
| 362 | + | } | |||
| 363 | + | wv.mu.Unlock() | |||
| 364 | + | ||||
| 365 | + | if !shouldSchedule { | |||
| 366 | + | return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay | |||
| 367 | + | } | |||
| 368 | + | wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason) | |||
| 369 | + | return attempt, pendingBrowserCreateObservedSizeRetryScheduled | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag | |||
| 373 | + | // so a future observed-size reservation can schedule another attempt. | |||
| 374 | + | func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() { | |||
| 375 | + | if wv == nil { | |||
| 376 | + | return | |||
| 377 | + | } | |||
| 378 | + | wv.mu.Lock() | |||
| 379 | + | defer wv.mu.Unlock() | |||
| 380 | + | if wv.pendingCreate != nil { | |||
| 381 | + | wv.pendingCreate.observedSizeRetryScheduled = false | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport | |||
| 386 | + | // sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay. | |||
| 387 | + | // | |||
| 388 | + | // It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a | |||
| 389 | + | // retry-specific suffix appended to reason. | |||
| 390 | + | func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) { | |||
| 391 | + | if wv == nil || wv.destroyed.Load() { | |||
| 392 | + | return | |||
| 393 | + | } | |||
| 394 | + | cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() { | |||
| 395 | + | if wv == nil || wv.destroyed.Load() { | |||
| 396 | + | return | |||
| 397 | + | } | |||
| 398 | + | wv.clearPendingBrowserCreateObservedSizeRetry() | |||
| 399 | + | wv.runOnGTK(func() { | |||
| 400 | + | wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size") | |||
| 401 | + | }) | |||
| 402 | + | }) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 206 | func (wv *WebView) installViewportSyncHooks() { | 405 | func (wv *WebView) installViewportSyncHooks() { | ||
| 207 | if wv == nil || wv.viewBridge == nil { | 406 | if wv == nil || wv.viewBridge == nil { | ||
| 208 | return | 407 | return | ||
| @@ -279,7 +478,35 @@ func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, | |||||
| 279 | return | 478 | return | ||
| 280 | } | 479 | } | ||
| 281 | notifyBrowserViewportSync(host, visible) | 480 | notifyBrowserViewportSync(host, visible) | ||
| 282 | - | wv.reapplyCurrentZoomForBackingScale("viewport-sync") | 481 | + | })) |
| 482 | + | if task == nil { | |||
| 483 | + | return | |||
| 484 | + | } | |||
| 485 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0) | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost, reason string) { | |||
| 489 | + | if host == nil || wv == nil { | |||
| 490 | + | return | |||
| 491 | + | } | |||
| 492 | + | if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) { | |||
| 493 | + | return | |||
| 494 | + | } | |||
| 495 | + | task := cefNewTask(cefTaskFunc(func() { | |||
| 496 | + | if wv == nil || wv.destroyed.Load() { | |||
| 497 | + | return | |||
| 498 | + | } | |||
| 499 | + | wv.mu.RLock() | |||
| 500 | + | currentHost := wv.host | |||
| 501 | + | wv.mu.RUnlock() | |||
| 502 | + | if currentHost != host { | |||
| 503 | + | return | |||
| 504 | + | } | |||
| 505 | + | backingScale := wv.osrBackingScaleFactor() | |||
| 506 | + | if !wv.shouldReapplyZoomForBackingScale(backingScale) { | |||
| 507 | + | return | |||
| 508 | + | } | |||
| 509 | + | wv.reapplyCurrentZoomForBackingScale(reason) | |||
| 283 | })) | 510 | })) | ||
| 284 | if task == nil { | 511 | if task == nil { | ||
| 285 | return | 512 | return | ||
M internal/infrastructure/cef/webview_viewport_test.go +267 -1
| @@ -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 { | |
| @@ -58,7 +80,7 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { | |||
| 58 | 80 | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | |
| 59 | 81 | } | |
| 60 | 82 | ||
| 61 | - | func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) { | |
| 83 | + | func TestNotifyViewportSyncOnCEFUIThread_PostsViewportWorkOnly(t *testing.T) { | |
| 62 | 84 | oldNewTask := cefNewTask | |
| 63 | 85 | oldPostDelayedTask := cefPostDelayedTask | |
| 64 | 86 | defer func() { | |
| @@ -91,11 +113,116 @@ func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) { | |||
| 91 | 113 | "NotifyScreenInfoChanged", | |
| 92 | 114 | "WasResized", | |
| 93 | 115 | "Invalidate", | |
| 116 | + | }, host.calls) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResizes(t *testing.T) { | |
| 120 | + | oldNewTask := cefNewTask | |
| 121 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 122 | + | defer func() { | |
| 123 | + | cefNewTask = oldNewTask | |
| 124 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 125 | + | }() | |
| 126 | + | ||
| 127 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 128 | + | ||
| 129 | + | var scheduled purecef.Task | |
| 130 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 131 | + | if delayMs == 0 && scheduled == nil { | |
| 132 | + | scheduled = task | |
| 133 | + | } | |
| 134 | + | return 1 | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | host := &viewportSyncOrderHost{} | |
| 138 | + | wv := &WebView{ctx: context.Background(), host: host} | |
| 139 | + | ||
| 140 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |
| 141 | + | require.NotNil(t, scheduled) | |
| 142 | + | scheduled.Execute() | |
| 143 | + | ||
| 144 | + | host.calls = nil | |
| 145 | + | scheduled = nil | |
| 146 | + | ||
| 147 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |
| 148 | + | require.NotNil(t, scheduled) | |
| 149 | + | scheduled.Execute() | |
| 150 | + | ||
| 151 | + | require.Equal(t, []string{ | |
| 152 | + | "WasHidden", | |
| 153 | + | "NotifyScreenInfoChanged", | |
| 154 | + | "WasResized", | |
| 155 | + | "Invalidate", | |
| 156 | + | }, host.calls) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_PostsZoomWork(t *testing.T) { | |
| 160 | + | oldNewTask := cefNewTask | |
| 161 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 162 | + | defer func() { | |
| 163 | + | cefNewTask = oldNewTask | |
| 164 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 165 | + | }() | |
| 166 | + | ||
| 167 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 168 | + | ||
| 169 | + | var scheduled purecef.Task | |
| 170 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 171 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 172 | + | require.NotNil(t, task) | |
| 173 | + | if delayMs == 0 && scheduled == nil { | |
| 174 | + | scheduled = task | |
| 175 | + | } | |
| 176 | + | return 1 | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | host := &viewportSyncOrderHost{} | |
| 180 | + | wv := &WebView{ctx: context.Background(), host: host} | |
| 181 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |
| 182 | + | ||
| 183 | + | require.Empty(t, host.calls) | |
| 184 | + | require.NotNil(t, scheduled) | |
| 185 | + | ||
| 186 | + | scheduled.Execute() | |
| 187 | + | require.Equal(t, []string{ | |
| 94 | 188 | "SetZoomLevel", | |
| 95 | 189 | "NotifyScreenInfoChanged", | |
| 96 | 190 | }, host.calls) | |
| 97 | 191 | } | |
| 98 | 192 | ||
| 193 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingScaleUnchanged(t *testing.T) { | |
| 194 | + | oldNewTask := cefNewTask | |
| 195 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 196 | + | defer func() { | |
| 197 | + | cefNewTask = oldNewTask | |
| 198 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 199 | + | }() | |
| 200 | + | ||
| 201 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 202 | + | ||
| 203 | + | var scheduled purecef.Task | |
| 204 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 205 | + | if delayMs == 0 && scheduled == nil { | |
| 206 | + | scheduled = task | |
| 207 | + | } | |
| 208 | + | return 1 | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | host := &viewportSyncOrderHost{} | |
| 212 | + | wv := &WebView{ctx: context.Background(), host: host} | |
| 213 | + | ||
| 214 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |
| 215 | + | require.NotNil(t, scheduled) | |
| 216 | + | scheduled.Execute() | |
| 217 | + | ||
| 218 | + | host.calls = nil | |
| 219 | + | scheduled = nil | |
| 220 | + | ||
| 221 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |
| 222 | + | require.Nil(t, scheduled) | |
| 223 | + | require.Empty(t, host.calls) | |
| 224 | + | } | |
| 225 | + | ||
| 99 | 226 | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | |
| 100 | 227 | oldNewTask := cefNewTask | |
| 101 | 228 | oldPostDelayedTask := cefPostDelayedTask | |
| @@ -149,6 +276,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) { | |||
| 149 | 276 | require.Empty(t, host.calls) | |
| 150 | 277 | } | |
| 151 | 278 | ||
| 279 | + | func TestObservedViewportSizeReady(t *testing.T) { | |
| 280 | + | tests := []struct { | |
| 281 | + | name string | |
| 282 | + | observedWidth int32 | |
| 283 | + | observedHeight int32 | |
| 284 | + | allocatedWidth int32 | |
| 285 | + | allocatedHeight int32 | |
| 286 | + | want bool | |
| 287 | + | }{ | |
| 288 | + | { | |
| 289 | + | name: "exact match", | |
| 290 | + | observedWidth: 793, | |
| 291 | + | observedHeight: 1753, | |
| 292 | + | allocatedWidth: 793, | |
| 293 | + | allocatedHeight: 1753, | |
| 294 | + | want: true, | |
| 295 | + | }, | |
| 296 | + | { | |
| 297 | + | name: "one pixel drift is accepted", | |
| 298 | + | observedWidth: 792, | |
| 299 | + | observedHeight: 1754, | |
| 300 | + | allocatedWidth: 793, | |
| 301 | + | allocatedHeight: 1753, | |
| 302 | + | want: true, | |
| 303 | + | }, | |
| 304 | + | { | |
| 305 | + | name: "fallback observed size is rejected", | |
| 306 | + | observedWidth: 1, | |
| 307 | + | observedHeight: 1, | |
| 308 | + | allocatedWidth: 793, | |
| 309 | + | allocatedHeight: 1753, | |
| 310 | + | want: false, | |
| 311 | + | }, | |
| 312 | + | { | |
| 313 | + | name: "missing allocation is rejected", | |
| 314 | + | observedWidth: 793, | |
| 315 | + | observedHeight: 1753, | |
| 316 | + | allocatedWidth: 0, | |
| 317 | + | allocatedHeight: 1753, | |
| 318 | + | want: false, | |
| 319 | + | }, | |
| 320 | + | { | |
| 321 | + | name: "large mismatch is rejected", | |
| 322 | + | observedWidth: 1200, | |
| 323 | + | observedHeight: 1400, | |
| 324 | + | allocatedWidth: 1587, | |
| 325 | + | allocatedHeight: 1679, | |
| 326 | + | want: false, | |
| 327 | + | }, | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | for _, tt := range tests { | |
| 331 | + | t.Run(tt.name, func(t *testing.T) { | |
| 332 | + | require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight)) | |
| 333 | + | }) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) { | |
| 338 | + | bridge := &pendingBrowserCreateObservedSizeBridgeStub{ | |
| 339 | + | sizeWidth: 1, | |
| 340 | + | sizeHeight: 1, | |
| 341 | + | refreshWidth: 1055, | |
| 342 | + | refreshHeight: 1719, | |
| 343 | + | refreshProvided: true, | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719) | |
| 347 | + | ||
| 348 | + | require.True(t, ready) | |
| 349 | + | require.Equal(t, int32(1055), observedWidth) | |
| 350 | + | require.Equal(t, int32(1719), observedHeight) | |
| 351 | + | require.Equal(t, 1, bridge.refreshCalls) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) { | |
| 355 | + | bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719} | |
| 356 | + | ||
| 357 | + | observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719) | |
| 358 | + | ||
| 359 | + | require.True(t, ready) | |
| 360 | + | require.Equal(t, int32(1055), observedWidth) | |
| 361 | + | require.Equal(t, int32(1719), observedHeight) | |
| 362 | + | require.Equal(t, 0, bridge.refreshCalls) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) { | |
| 366 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true} | |
| 367 | + | ||
| 368 | + | require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync()) | |
| 369 | + | ||
| 370 | + | wv.nativePopupFallbackStarted = true | |
| 371 | + | require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync()) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) { | |
| 375 | + | oldScheduleAfter := cefScheduleAfter | |
| 376 | + | defer func() { cefScheduleAfter = oldScheduleAfter }() | |
| 377 | + | ||
| 378 | + | scheduleCalls := 0 | |
| 379 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |
| 380 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay) | |
| 381 | + | require.NotNil(t, fn) | |
| 382 | + | scheduleCalls++ | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{}} | |
| 386 | + | ||
| 387 | + | attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") | |
| 388 | + | require.Equal(t, 1, attempt) | |
| 389 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action) | |
| 390 | + | require.Equal(t, 1, scheduleCalls) | |
| 391 | + | ||
| 392 | + | attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") | |
| 393 | + | require.Equal(t, 1, attempt) | |
| 394 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action) | |
| 395 | + | require.Equal(t, 1, scheduleCalls) | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) { | |
| 399 | + | oldScheduleAfter := cefScheduleAfter | |
| 400 | + | defer func() { cefScheduleAfter = oldScheduleAfter }() | |
| 401 | + | ||
| 402 | + | scheduleCalls := 0 | |
| 403 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |
| 404 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay) | |
| 405 | + | require.NotNil(t, fn) | |
| 406 | + | scheduleCalls++ | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}} | |
| 410 | + | ||
| 411 | + | attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") | |
| 412 | + | require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt) | |
| 413 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action) | |
| 414 | + | require.Equal(t, 0, scheduleCalls) | |
| 415 | + | require.False(t, wv.pendingCreate.observedSizeRetryScheduled) | |
| 416 | + | } | |
| 417 | + | ||
| 152 | 418 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | |
| 153 | 419 | oldNewTask := cefNewTask | |
| 154 | 420 | 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 { | ||
| @@ -58,7 +80,7 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { | |||||
| 58 | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | 80 | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | ||
| 59 | } | 81 | } | ||
| 60 | 82 | ||||
| 61 | - | func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) { | 83 | + | func TestNotifyViewportSyncOnCEFUIThread_PostsViewportWorkOnly(t *testing.T) { |
| 62 | oldNewTask := cefNewTask | 84 | oldNewTask := cefNewTask | ||
| 63 | oldPostDelayedTask := cefPostDelayedTask | 85 | oldPostDelayedTask := cefPostDelayedTask | ||
| 64 | defer func() { | 86 | defer func() { | ||
| @@ -91,11 +113,116 @@ func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) { | |||||
| 91 | "NotifyScreenInfoChanged", | 113 | "NotifyScreenInfoChanged", | ||
| 92 | "WasResized", | 114 | "WasResized", | ||
| 93 | "Invalidate", | 115 | "Invalidate", | ||
| 116 | + | }, host.calls) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResizes(t *testing.T) { | |||
| 120 | + | oldNewTask := cefNewTask | |||
| 121 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 122 | + | defer func() { | |||
| 123 | + | cefNewTask = oldNewTask | |||
| 124 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 125 | + | }() | |||
| 126 | + | ||||
| 127 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 128 | + | ||||
| 129 | + | var scheduled purecef.Task | |||
| 130 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 131 | + | if delayMs == 0 && scheduled == nil { | |||
| 132 | + | scheduled = task | |||
| 133 | + | } | |||
| 134 | + | return 1 | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | host := &viewportSyncOrderHost{} | |||
| 138 | + | wv := &WebView{ctx: context.Background(), host: host} | |||
| 139 | + | ||||
| 140 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |||
| 141 | + | require.NotNil(t, scheduled) | |||
| 142 | + | scheduled.Execute() | |||
| 143 | + | ||||
| 144 | + | host.calls = nil | |||
| 145 | + | scheduled = nil | |||
| 146 | + | ||||
| 147 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |||
| 148 | + | require.NotNil(t, scheduled) | |||
| 149 | + | scheduled.Execute() | |||
| 150 | + | ||||
| 151 | + | require.Equal(t, []string{ | |||
| 152 | + | "WasHidden", | |||
| 153 | + | "NotifyScreenInfoChanged", | |||
| 154 | + | "WasResized", | |||
| 155 | + | "Invalidate", | |||
| 156 | + | }, host.calls) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_PostsZoomWork(t *testing.T) { | |||
| 160 | + | oldNewTask := cefNewTask | |||
| 161 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 162 | + | defer func() { | |||
| 163 | + | cefNewTask = oldNewTask | |||
| 164 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 165 | + | }() | |||
| 166 | + | ||||
| 167 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 168 | + | ||||
| 169 | + | var scheduled purecef.Task | |||
| 170 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 171 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 172 | + | require.NotNil(t, task) | |||
| 173 | + | if delayMs == 0 && scheduled == nil { | |||
| 174 | + | scheduled = task | |||
| 175 | + | } | |||
| 176 | + | return 1 | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | host := &viewportSyncOrderHost{} | |||
| 180 | + | wv := &WebView{ctx: context.Background(), host: host} | |||
| 181 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |||
| 182 | + | ||||
| 183 | + | require.Empty(t, host.calls) | |||
| 184 | + | require.NotNil(t, scheduled) | |||
| 185 | + | ||||
| 186 | + | scheduled.Execute() | |||
| 187 | + | require.Equal(t, []string{ | |||
| 94 | "SetZoomLevel", | 188 | "SetZoomLevel", | ||
| 95 | "NotifyScreenInfoChanged", | 189 | "NotifyScreenInfoChanged", | ||
| 96 | }, host.calls) | 190 | }, host.calls) | ||
| 97 | } | 191 | } | ||
| 98 | 192 | ||||
| 193 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingScaleUnchanged(t *testing.T) { | |||
| 194 | + | oldNewTask := cefNewTask | |||
| 195 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 196 | + | defer func() { | |||
| 197 | + | cefNewTask = oldNewTask | |||
| 198 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 199 | + | }() | |||
| 200 | + | ||||
| 201 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 202 | + | ||||
| 203 | + | var scheduled purecef.Task | |||
| 204 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 205 | + | if delayMs == 0 && scheduled == nil { | |||
| 206 | + | scheduled = task | |||
| 207 | + | } | |||
| 208 | + | return 1 | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | host := &viewportSyncOrderHost{} | |||
| 212 | + | wv := &WebView{ctx: context.Background(), host: host} | |||
| 213 | + | ||||
| 214 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |||
| 215 | + | require.NotNil(t, scheduled) | |||
| 216 | + | scheduled.Execute() | |||
| 217 | + | ||||
| 218 | + | host.calls = nil | |||
| 219 | + | scheduled = nil | |||
| 220 | + | ||||
| 221 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |||
| 222 | + | require.Nil(t, scheduled) | |||
| 223 | + | require.Empty(t, host.calls) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 99 | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | 226 | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | ||
| 100 | oldNewTask := cefNewTask | 227 | oldNewTask := cefNewTask | ||
| 101 | oldPostDelayedTask := cefPostDelayedTask | 228 | oldPostDelayedTask := cefPostDelayedTask | ||
| @@ -149,6 +276,145 @@ func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) { | |||||
| 149 | require.Empty(t, host.calls) | 276 | require.Empty(t, host.calls) | ||
| 150 | } | 277 | } | ||
| 151 | 278 | ||||
| 279 | + | func TestObservedViewportSizeReady(t *testing.T) { | |||
| 280 | + | tests := []struct { | |||
| 281 | + | name string | |||
| 282 | + | observedWidth int32 | |||
| 283 | + | observedHeight int32 | |||
| 284 | + | allocatedWidth int32 | |||
| 285 | + | allocatedHeight int32 | |||
| 286 | + | want bool | |||
| 287 | + | }{ | |||
| 288 | + | { | |||
| 289 | + | name: "exact match", | |||
| 290 | + | observedWidth: 793, | |||
| 291 | + | observedHeight: 1753, | |||
| 292 | + | allocatedWidth: 793, | |||
| 293 | + | allocatedHeight: 1753, | |||
| 294 | + | want: true, | |||
| 295 | + | }, | |||
| 296 | + | { | |||
| 297 | + | name: "one pixel drift is accepted", | |||
| 298 | + | observedWidth: 792, | |||
| 299 | + | observedHeight: 1754, | |||
| 300 | + | allocatedWidth: 793, | |||
| 301 | + | allocatedHeight: 1753, | |||
| 302 | + | want: true, | |||
| 303 | + | }, | |||
| 304 | + | { | |||
| 305 | + | name: "fallback observed size is rejected", | |||
| 306 | + | observedWidth: 1, | |||
| 307 | + | observedHeight: 1, | |||
| 308 | + | allocatedWidth: 793, | |||
| 309 | + | allocatedHeight: 1753, | |||
| 310 | + | want: false, | |||
| 311 | + | }, | |||
| 312 | + | { | |||
| 313 | + | name: "missing allocation is rejected", | |||
| 314 | + | observedWidth: 793, | |||
| 315 | + | observedHeight: 1753, | |||
| 316 | + | allocatedWidth: 0, | |||
| 317 | + | allocatedHeight: 1753, | |||
| 318 | + | want: false, | |||
| 319 | + | }, | |||
| 320 | + | { | |||
| 321 | + | name: "large mismatch is rejected", | |||
| 322 | + | observedWidth: 1200, | |||
| 323 | + | observedHeight: 1400, | |||
| 324 | + | allocatedWidth: 1587, | |||
| 325 | + | allocatedHeight: 1679, | |||
| 326 | + | want: false, | |||
| 327 | + | }, | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | for _, tt := range tests { | |||
| 331 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 332 | + | require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight)) | |||
| 333 | + | }) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) { | |||
| 338 | + | bridge := &pendingBrowserCreateObservedSizeBridgeStub{ | |||
| 339 | + | sizeWidth: 1, | |||
| 340 | + | sizeHeight: 1, | |||
| 341 | + | refreshWidth: 1055, | |||
| 342 | + | refreshHeight: 1719, | |||
| 343 | + | refreshProvided: true, | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719) | |||
| 347 | + | ||||
| 348 | + | require.True(t, ready) | |||
| 349 | + | require.Equal(t, int32(1055), observedWidth) | |||
| 350 | + | require.Equal(t, int32(1719), observedHeight) | |||
| 351 | + | require.Equal(t, 1, bridge.refreshCalls) | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) { | |||
| 355 | + | bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719} | |||
| 356 | + | ||||
| 357 | + | observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719) | |||
| 358 | + | ||||
| 359 | + | require.True(t, ready) | |||
| 360 | + | require.Equal(t, int32(1055), observedWidth) | |||
| 361 | + | require.Equal(t, int32(1719), observedHeight) | |||
| 362 | + | require.Equal(t, 0, bridge.refreshCalls) | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) { | |||
| 366 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true} | |||
| 367 | + | ||||
| 368 | + | require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync()) | |||
| 369 | + | ||||
| 370 | + | wv.nativePopupFallbackStarted = true | |||
| 371 | + | require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync()) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) { | |||
| 375 | + | oldScheduleAfter := cefScheduleAfter | |||
| 376 | + | defer func() { cefScheduleAfter = oldScheduleAfter }() | |||
| 377 | + | ||||
| 378 | + | scheduleCalls := 0 | |||
| 379 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |||
| 380 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay) | |||
| 381 | + | require.NotNil(t, fn) | |||
| 382 | + | scheduleCalls++ | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{}} | |||
| 386 | + | ||||
| 387 | + | attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") | |||
| 388 | + | require.Equal(t, 1, attempt) | |||
| 389 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action) | |||
| 390 | + | require.Equal(t, 1, scheduleCalls) | |||
| 391 | + | ||||
| 392 | + | attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") | |||
| 393 | + | require.Equal(t, 1, attempt) | |||
| 394 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action) | |||
| 395 | + | require.Equal(t, 1, scheduleCalls) | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) { | |||
| 399 | + | oldScheduleAfter := cefScheduleAfter | |||
| 400 | + | defer func() { cefScheduleAfter = oldScheduleAfter }() | |||
| 401 | + | ||||
| 402 | + | scheduleCalls := 0 | |||
| 403 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |||
| 404 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay) | |||
| 405 | + | require.NotNil(t, fn) | |||
| 406 | + | scheduleCalls++ | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}} | |||
| 410 | + | ||||
| 411 | + | attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test") | |||
| 412 | + | require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt) | |||
| 413 | + | require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action) | |||
| 414 | + | require.Equal(t, 0, scheduleCalls) | |||
| 415 | + | require.False(t, wv.pendingCreate.observedSizeRetryScheduled) | |||
| 416 | + | } | |||
| 417 | + | ||||
| 152 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | 418 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | ||
| 153 | oldNewTask := cefNewTask | 419 | oldNewTask := cefNewTask | ||
| 154 | oldPostDelayedTask := cefPostDelayedTask | 420 | oldPostDelayedTask := cefPostDelayedTask | ||
A internal/infrastructure/config/cef_frame_rate_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) { | |
| 6 | + | cfg := DefaultConfig().Engine.CEF | |
| 7 | + | ||
| 8 | + | if !cfg.AdaptiveWindowlessFrameRate { | |
| 9 | + | t.Fatal("adaptive windowless frame-rate should be enabled by default") | |
| 10 | + | } | |
| 11 | + | if cfg.WindowlessFrameRate != 0 { | |
| 12 | + | t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate) | |
| 13 | + | } | |
| 14 | + | if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) { | |
| 15 | + | t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate) | |
| 16 | + | } | |
| 17 | + | if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) { | |
| 18 | + | t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax) | |
| 19 | + | } | |
| 20 | + | if !cfg.CEFAdaptiveWindowlessFrameRate() { | |
| 21 | + | t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set") | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) { | |
| 26 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144} | |
| 27 | + | ||
| 28 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |
| 29 | + | t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate") | |
| 30 | + | } | |
| 31 | + | if got := cfg.CEFWindowlessFrameRate(); got != 144 { | |
| 32 | + | t.Fatalf("explicit frame-rate = %d, want 144", got) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestCEFWindowlessFrameRateFallbackWhenAdaptiveDisabled(t *testing.T) { | |
| 37 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 0} | |
| 38 | + | ||
| 39 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |
| 40 | + | t.Fatal("adaptive should be disabled when adaptive flag is false") | |
| 41 | + | } | |
| 42 | + | if got := cfg.CEFWindowlessFrameRate(); got != defaultCEFWindowlessFrameRate { | |
| 43 | + | t.Fatalf("frame-rate = %d, want default %d", got, defaultCEFWindowlessFrameRate) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestCEFWindowlessFrameRateUsesExplicitValueWhenAdaptiveDisabled(t *testing.T) { | |
| 48 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 75} | |
| 49 | + | ||
| 50 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |
| 51 | + | t.Fatal("adaptive should remain disabled with explicit frame-rate") | |
| 52 | + | } | |
| 53 | + | if got := cfg.CEFWindowlessFrameRate(); got != 75 { | |
| 54 | + | t.Fatalf("frame-rate = %d, want explicit 75", got) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestCEFWindowlessFrameRateBoundaries(t *testing.T) { | |
| 59 | + | tests := []struct { | |
| 60 | + | name string | |
| 61 | + | cfg CEFEngineConfig | |
| 62 | + | want int32 | |
| 63 | + | }{ | |
| 64 | + | {name: "negative falls back", cfg: CEFEngineConfig{WindowlessFrameRate: -1}, want: defaultCEFWindowlessFrameRate}, | |
| 65 | + | {name: "above max remains explicit", cfg: CEFEngineConfig{WindowlessFrameRate: 300, WindowlessFrameRateMax: 240}, want: 300}, | |
| 66 | + | } | |
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | if got := tt.cfg.CEFWindowlessFrameRate(); got != tt.want { | |
| 70 | + | t.Fatalf("CEFWindowlessFrameRate() = %d, want %d", got, tt.want) | |
| 71 | + | } | |
| 72 | + | }) | |
| 73 | + | } | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) { | |||
| 6 | + | cfg := DefaultConfig().Engine.CEF | |||
| 7 | + | ||||
| 8 | + | if !cfg.AdaptiveWindowlessFrameRate { | |||
| 9 | + | t.Fatal("adaptive windowless frame-rate should be enabled by default") | |||
| 10 | + | } | |||
| 11 | + | if cfg.WindowlessFrameRate != 0 { | |||
| 12 | + | t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate) | |||
| 13 | + | } | |||
| 14 | + | if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) { | |||
| 15 | + | t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate) | |||
| 16 | + | } | |||
| 17 | + | if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) { | |||
| 18 | + | t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax) | |||
| 19 | + | } | |||
| 20 | + | if !cfg.CEFAdaptiveWindowlessFrameRate() { | |||
| 21 | + | t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set") | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) { | |||
| 26 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144} | |||
| 27 | + | ||||
| 28 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |||
| 29 | + | t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate") | |||
| 30 | + | } | |||
| 31 | + | if got := cfg.CEFWindowlessFrameRate(); got != 144 { | |||
| 32 | + | t.Fatalf("explicit frame-rate = %d, want 144", got) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestCEFWindowlessFrameRateFallbackWhenAdaptiveDisabled(t *testing.T) { | |||
| 37 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 0} | |||
| 38 | + | ||||
| 39 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |||
| 40 | + | t.Fatal("adaptive should be disabled when adaptive flag is false") | |||
| 41 | + | } | |||
| 42 | + | if got := cfg.CEFWindowlessFrameRate(); got != defaultCEFWindowlessFrameRate { | |||
| 43 | + | t.Fatalf("frame-rate = %d, want default %d", got, defaultCEFWindowlessFrameRate) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestCEFWindowlessFrameRateUsesExplicitValueWhenAdaptiveDisabled(t *testing.T) { | |||
| 48 | + | cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: false, WindowlessFrameRate: 75} | |||
| 49 | + | ||||
| 50 | + | if cfg.CEFAdaptiveWindowlessFrameRate() { | |||
| 51 | + | t.Fatal("adaptive should remain disabled with explicit frame-rate") | |||
| 52 | + | } | |||
| 53 | + | if got := cfg.CEFWindowlessFrameRate(); got != 75 { | |||
| 54 | + | t.Fatalf("frame-rate = %d, want explicit 75", got) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestCEFWindowlessFrameRateBoundaries(t *testing.T) { | |||
| 59 | + | tests := []struct { | |||
| 60 | + | name string | |||
| 61 | + | cfg CEFEngineConfig | |||
| 62 | + | want int32 | |||
| 63 | + | }{ | |||
| 64 | + | {name: "negative falls back", cfg: CEFEngineConfig{WindowlessFrameRate: -1}, want: defaultCEFWindowlessFrameRate}, | |||
| 65 | + | {name: "above max remains explicit", cfg: CEFEngineConfig{WindowlessFrameRate: 300, WindowlessFrameRateMax: 240}, want: 300}, | |||
| 66 | + | } | |||
| 67 | + | for _, tt := range tests { | |||
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 69 | + | if got := tt.cfg.CEFWindowlessFrameRate(); got != tt.want { | |||
| 70 | + | t.Fatalf("CEFWindowlessFrameRate() = %d, want %d", got, tt.want) | |||
| 71 | + | } | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
M internal/infrastructure/config/defaults.go +10 -7
| @@ -67,9 +67,10 @@ const ( | |||
| 67 | 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| 68 | 68 | ||
| 69 | 69 | // Performance defaults | |
| 70 | - | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 71 | - | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | - | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | |
| 70 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 71 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 72 | + | defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled | |
| 73 | + | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | |
| 73 | 74 | ||
| 74 | 75 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 75 | 76 | defaultSkiaCPUPaintingThreads = 0 | |
| @@ -171,10 +172,12 @@ func DefaultConfig() *Config { | |||
| 171 | 172 | // matches Epiphany's model and avoids a misleading setting. | |
| 172 | 173 | CookiePolicy: CookiePolicyAlways, | |
| 173 | 174 | CEF: CEFEngineConfig{ | |
| 174 | - | RenderStack: CEFRenderStackVulkan, | |
| 175 | - | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | |
| 176 | - | LogFile: "", | |
| 177 | - | EnableAudioHandler: true, | |
| 175 | + | RenderStack: CEFRenderStackVulkan, | |
| 176 | + | AdaptiveWindowlessFrameRate: true, | |
| 177 | + | WindowlessFrameRate: 0, | |
| 178 | + | WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax, | |
| 179 | + | LogFile: "", | |
| 180 | + | EnableAudioHandler: true, | |
| 178 | 181 | }, | |
| 179 | 182 | WebKit: WebKitEngineConfig{ | |
| 180 | 183 | ITPEnabled: true, | |
| @@ -67,9 +67,10 @@ const ( | |||||
| 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | 67 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | ||
| 68 | 68 | ||||
| 69 | // Performance defaults | 69 | // Performance defaults | ||
| 70 | - | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | 70 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) |
| 71 | - | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | 71 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup |
| 72 | - | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | 72 | + | defaultCEFWindowlessFrameRate = 60 // static OSR frame rate when adaptive CEF pacing is disabled |
| 73 | + | defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap | |||
| 73 | 74 | ||||
| 74 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | 75 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | ||
| 75 | defaultSkiaCPUPaintingThreads = 0 | 76 | defaultSkiaCPUPaintingThreads = 0 | ||
| @@ -171,10 +172,12 @@ func DefaultConfig() *Config { | |||||
| 171 | // matches Epiphany's model and avoids a misleading setting. | 172 | // matches Epiphany's model and avoids a misleading setting. | ||
| 172 | CookiePolicy: CookiePolicyAlways, | 173 | CookiePolicy: CookiePolicyAlways, | ||
| 173 | CEF: CEFEngineConfig{ | 174 | CEF: CEFEngineConfig{ | ||
| 174 | - | RenderStack: CEFRenderStackVulkan, | 175 | + | RenderStack: CEFRenderStackVulkan, |
| 175 | - | WindowlessFrameRate: defaultCEFWindowlessFrameRate, | 176 | + | AdaptiveWindowlessFrameRate: true, |
| 176 | - | LogFile: "", | 177 | + | WindowlessFrameRate: 0, |
| 177 | - | EnableAudioHandler: true, | 178 | + | WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax, |
| 179 | + | LogFile: "", | |||
| 180 | + | EnableAudioHandler: true, | |||
| 178 | }, | 181 | }, | ||
| 179 | WebKit: WebKitEngineConfig{ | 182 | WebKit: WebKitEngineConfig{ | ||
| 180 | ITPEnabled: true, | 183 | ITPEnabled: true, | ||
M internal/infrastructure/config/defaults_test.go +2 -0
| @@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 19 | 19 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | |
| 20 | 20 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | |
| 21 | 21 | assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack()) | |
| 22 | + | assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate()) | |
| 22 | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | |
| 24 | + | assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax()) | |
| 23 | 25 | assert.Empty(t, cfg.Engine.CEF.LogFile) | |
| 24 | 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | |
| 25 | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | |
| @@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 19 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | 19 | assert.Equal(t, ProfileDefault, cfg.Engine.Profile) | ||
| 20 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | 20 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | ||
| 21 | assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack()) | 21 | assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack()) | ||
| 22 | + | assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate()) | |||
| 22 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | 23 | assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate()) | ||
| 24 | + | assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax()) | |||
| 23 | assert.Empty(t, cfg.Engine.CEF.LogFile) | 25 | assert.Empty(t, cfg.Engine.CEF.LogFile) | ||
| 24 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | 26 | assert.True(t, cfg.Engine.CEF.EnableAudioHandler) | ||
| 25 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | ||
M internal/infrastructure/config/engine.go +20 -2
| @@ -100,9 +100,16 @@ type CEFEngineConfig struct { | |||
| 100 | 100 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | |
| 101 | 101 | // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan. | |
| 102 | 102 | RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"` | |
| 103 | + | // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate | |
| 104 | + | // and updates CEF's OSR frame-rate while the browser moves across monitors. | |
| 105 | + | // It is ignored when WindowlessFrameRate is explicitly set. | |
| 106 | + | AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit | |
| 103 | 107 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | |
| 104 | - | // Default: 60. Higher values increase CPU usage. | |
| 105 | - | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | |
| 108 | + | // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the | |
| 109 | + | // active Wayland monitor refresh rate. | |
| 110 | + | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit | |
| 111 | + | // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates. | |
| 112 | + | WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit | |
| 106 | 113 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | |
| 107 | 114 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | |
| 108 | 115 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | |
| @@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 { | |||
| 127 | 134 | return defaultCEFWindowlessFrameRate | |
| 128 | 135 | } | |
| 129 | 136 | ||
| 137 | + | func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 { | |
| 138 | + | if c.WindowlessFrameRateMax > 0 { | |
| 139 | + | return c.WindowlessFrameRateMax | |
| 140 | + | } | |
| 141 | + | return defaultCEFWindowlessFrameRateMax | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool { | |
| 145 | + | return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0 | |
| 146 | + | } | |
| 147 | + | ||
| 130 | 148 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | |
| 131 | 149 | // new [engine] config section, for use with ResolvePerformanceProfile. | |
| 132 | 150 | func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig { | |
| @@ -100,9 +100,16 @@ type CEFEngineConfig struct { | |||||
| 100 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | 100 | LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"` | ||
| 101 | // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan. | 101 | // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan. | ||
| 102 | RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"` | 102 | RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"` | ||
| 103 | + | // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate | |||
| 104 | + | // and updates CEF's OSR frame-rate while the browser moves across monitors. | |||
| 105 | + | // It is ignored when WindowlessFrameRate is explicitly set. | |||
| 106 | + | AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit | |||
| 103 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | 107 | // WindowlessFrameRate is the maximum frame rate for off-screen rendering. | ||
| 104 | - | // Default: 60. Higher values increase CPU usage. | 108 | + | // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the |
| 105 | - | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` | 109 | + | // active Wayland monitor refresh rate. |
| 110 | + | WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"` //nolint:lll // struct tags exceed lll limit | |||
| 111 | + | // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates. | |||
| 112 | + | WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"` //nolint:lll // struct tags exceed lll limit | |||
| 106 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | 113 | // EnableAudioHandler opts into the experimental CEF AudioHandler bridge. | ||
| 107 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | 114 | EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"` | ||
| 108 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | 115 | // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics. | ||
| @@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 { | |||||
| 127 | return defaultCEFWindowlessFrameRate | 134 | return defaultCEFWindowlessFrameRate | ||
| 128 | } | 135 | } | ||
| 129 | 136 | ||||
| 137 | + | func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 { | |||
| 138 | + | if c.WindowlessFrameRateMax > 0 { | |||
| 139 | + | return c.WindowlessFrameRateMax | |||
| 140 | + | } | |||
| 141 | + | return defaultCEFWindowlessFrameRateMax | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool { | |||
| 145 | + | return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0 | |||
| 146 | + | } | |||
| 147 | + | ||||
| 130 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | 148 | // PerformanceConfigFromEngine constructs a PerformanceConfig from the | ||
| 131 | // new [engine] config section, for use with ResolvePerformanceProfile. | 149 | // new [engine] config section, for use with ResolvePerformanceProfile. | ||
| 132 | func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig { | 150 | func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig { | ||
M internal/infrastructure/config/loader.go +58 -9
| @@ -19,15 +19,26 @@ import ( | |||
| 19 | 19 | type Manager struct { | |
| 20 | 20 | config *Config | |
| 21 | 21 | viper *viper.Viper | |
| 22 | + | automaticEnv automaticEnvConfig | |
| 22 | 23 | mu sync.RWMutex | |
| 23 | 24 | callbacks []func(*Config) | |
| 24 | 25 | watching bool | |
| 25 | 26 | skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 29 | + | type automaticEnvConfig struct { | |
| 30 | + | enabled bool | |
| 31 | + | prefix string | |
| 32 | + | keyReplacer *strings.Replacer | |
| 33 | + | } | |
| 34 | + | ||
| 28 | 35 | // NewManager creates a new configuration manager. | |
| 29 | 36 | func NewManager() (*Manager, error) { | |
| 30 | 37 | v := viper.New() | |
| 38 | + | m := &Manager{ | |
| 39 | + | viper: v, | |
| 40 | + | callbacks: make([]func(*Config), 0), | |
| 41 | + | } | |
| 31 | 42 | ||
| 32 | 43 | // Configure Viper for TOML as default format | |
| 33 | 44 | v.SetConfigName("config") // Name without extension | |
| @@ -42,9 +53,7 @@ func NewManager() (*Manager, error) { | |||
| 42 | 53 | v.AddConfigPath(".") // Current directory for development | |
| 43 | 54 | ||
| 44 | 55 | // Set up environment variable support | |
| 45 | - | v.SetEnvPrefix("DUMBER") | |
| 46 | - | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) | |
| 47 | - | v.AutomaticEnv() | |
| 56 | + | m.configureAutomaticEnv() | |
| 48 | 57 | ||
| 49 | 58 | // Note: Most environment variables are handled automatically via AutomaticEnv() | |
| 50 | 59 | // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL). | |
| @@ -69,10 +78,7 @@ func NewManager() (*Manager, error) { | |||
| 69 | 78 | return nil, err | |
| 70 | 79 | } | |
| 71 | 80 | ||
| 72 | - | return &Manager{ | |
| 73 | - | viper: v, | |
| 74 | - | callbacks: make([]func(*Config), 0), | |
| 75 | - | }, nil | |
| 81 | + | return m, nil | |
| 76 | 82 | } | |
| 77 | 83 | ||
| 78 | 84 | // Load loads the configuration from file and environment variables. | |
| @@ -153,7 +159,11 @@ func (m *Manager) transformLegacyConfig() { | |||
| 153 | 159 | rawConfig := m.viper.AllSettings() | |
| 154 | 160 | transformer := NewLegacyConfigTransformer() | |
| 155 | 161 | transformer.TransformLegacyActions(rawConfig) | |
| 156 | - | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 162 | + | transformer.TransformLegacyEngineConfigWithExplicitAdaptive( | |
| 163 | + | rawConfig, | |
| 164 | + | m.hasExplicitAdaptiveWindowlessFrameRateSetting(), | |
| 165 | + | m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"), | |
| 166 | + | ) | |
| 157 | 167 | ||
| 158 | 168 | // AllSettings includes defaults, so workspace.browsing_contexts may already be | |
| 159 | 169 | // present even when the user only configured legacy workspace.popups. Always | |
| @@ -171,6 +181,43 @@ func (m *Manager) transformLegacyConfig() { | |||
| 171 | 181 | } | |
| 172 | 182 | } | |
| 173 | 183 | ||
| 184 | + | func (m *Manager) configureAutomaticEnv() { | |
| 185 | + | if m == nil || m.viper == nil { | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | replacer := strings.NewReplacer(".", "_") | |
| 189 | + | m.viper.SetEnvPrefix("DUMBER") | |
| 190 | + | m.viper.SetEnvKeyReplacer(replacer) | |
| 191 | + | m.viper.AutomaticEnv() | |
| 192 | + | m.automaticEnv = automaticEnvConfig{ | |
| 193 | + | enabled: true, | |
| 194 | + | prefix: "DUMBER", | |
| 195 | + | keyReplacer: replacer, | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (m *Manager) hasExplicitAdaptiveWindowlessFrameRateSetting() bool { | |
| 200 | + | if m == nil || m.viper == nil { | |
| 201 | + | return false | |
| 202 | + | } | |
| 203 | + | if m.viper.InConfig("engine.cef.adaptive_windowless_frame_rate") { | |
| 204 | + | return true | |
| 205 | + | } | |
| 206 | + | if !m.automaticEnv.enabled { | |
| 207 | + | return false | |
| 208 | + | } | |
| 209 | + | envKey := "engine.cef.adaptive_windowless_frame_rate" | |
| 210 | + | if m.automaticEnv.keyReplacer != nil { | |
| 211 | + | envKey = m.automaticEnv.keyReplacer.Replace(envKey) | |
| 212 | + | } | |
| 213 | + | envKey = strings.ToUpper(envKey) | |
| 214 | + | if prefix := strings.TrimSpace(m.automaticEnv.prefix); prefix != "" { | |
| 215 | + | envKey = prefix + "_" + envKey | |
| 216 | + | } | |
| 217 | + | value, ok := os.LookupEnv(envKey) | |
| 218 | + | return ok && value != "" | |
| 219 | + | } | |
| 220 | + | ||
| 174 | 221 | func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) { | |
| 175 | 222 | workspace, ok := rawConfig["workspace"].(map[string]any) | |
| 176 | 223 | if !ok { | |
| @@ -694,7 +741,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 694 | 741 | m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack())) | |
| 695 | 742 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | |
| 696 | 743 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | |
| 697 | - | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | |
| 744 | + | m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate) | |
| 745 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate) | |
| 746 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax()) | |
| 698 | 747 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | |
| 699 | 748 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | |
| 700 | 749 | ||
| @@ -19,15 +19,26 @@ import ( | |||||
| 19 | type Manager struct { | 19 | type Manager struct { | ||
| 20 | config *Config | 20 | config *Config | ||
| 21 | viper *viper.Viper | 21 | viper *viper.Viper | ||
| 22 | + | automaticEnv automaticEnvConfig | |||
| 22 | mu sync.RWMutex | 23 | mu sync.RWMutex | ||
| 23 | callbacks []func(*Config) | 24 | callbacks []func(*Config) | ||
| 24 | watching bool | 25 | watching bool | ||
| 25 | skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config | 26 | skipNextReload bool // Set by Save() to prevent fsnotify reload from overwriting in-memory config | ||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 29 | + | type automaticEnvConfig struct { | |||
| 30 | + | enabled bool | |||
| 31 | + | prefix string | |||
| 32 | + | keyReplacer *strings.Replacer | |||
| 33 | + | } | |||
| 34 | + | ||||
| 28 | // NewManager creates a new configuration manager. | 35 | // NewManager creates a new configuration manager. | ||
| 29 | func NewManager() (*Manager, error) { | 36 | func NewManager() (*Manager, error) { | ||
| 30 | v := viper.New() | 37 | v := viper.New() | ||
| 38 | + | m := &Manager{ | |||
| 39 | + | viper: v, | |||
| 40 | + | callbacks: make([]func(*Config), 0), | |||
| 41 | + | } | |||
| 31 | 42 | ||||
| 32 | // Configure Viper for TOML as default format | 43 | // Configure Viper for TOML as default format | ||
| 33 | v.SetConfigName("config") // Name without extension | 44 | v.SetConfigName("config") // Name without extension | ||
| @@ -42,9 +53,7 @@ func NewManager() (*Manager, error) { | |||||
| 42 | v.AddConfigPath(".") // Current directory for development | 53 | v.AddConfigPath(".") // Current directory for development | ||
| 43 | 54 | ||||
| 44 | // Set up environment variable support | 55 | // Set up environment variable support | ||
| 45 | - | v.SetEnvPrefix("DUMBER") | 56 | + | m.configureAutomaticEnv() |
| 46 | - | v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) | |||
| 47 | - | v.AutomaticEnv() | |||
| 48 | 57 | ||||
| 49 | // Note: Most environment variables are handled automatically via AutomaticEnv() | 58 | // Note: Most environment variables are handled automatically via AutomaticEnv() | ||
| 50 | // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL). | 59 | // with the DUMBER_ prefix (e.g., DUMBER_DATABASE_PATH, DUMBER_LOGGING_LEVEL). | ||
| @@ -69,10 +78,7 @@ func NewManager() (*Manager, error) { | |||||
| 69 | return nil, err | 78 | return nil, err | ||
| 70 | } | 79 | } | ||
| 71 | 80 | ||||
| 72 | - | return &Manager{ | 81 | + | return m, nil |
| 73 | - | viper: v, | |||
| 74 | - | callbacks: make([]func(*Config), 0), | |||
| 75 | - | }, nil | |||
| 76 | } | 82 | } | ||
| 77 | 83 | ||||
| 78 | // Load loads the configuration from file and environment variables. | 84 | // Load loads the configuration from file and environment variables. | ||
| @@ -153,7 +159,11 @@ func (m *Manager) transformLegacyConfig() { | |||||
| 153 | rawConfig := m.viper.AllSettings() | 159 | rawConfig := m.viper.AllSettings() | ||
| 154 | transformer := NewLegacyConfigTransformer() | 160 | transformer := NewLegacyConfigTransformer() | ||
| 155 | transformer.TransformLegacyActions(rawConfig) | 161 | transformer.TransformLegacyActions(rawConfig) | ||
| 156 | - | transformer.TransformLegacyEngineConfig(rawConfig) | 162 | + | transformer.TransformLegacyEngineConfigWithExplicitAdaptive( |
| 163 | + | rawConfig, | |||
| 164 | + | m.hasExplicitAdaptiveWindowlessFrameRateSetting(), | |||
| 165 | + | m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"), | |||
| 166 | + | ) | |||
| 157 | 167 | ||||
| 158 | // AllSettings includes defaults, so workspace.browsing_contexts may already be | 168 | // AllSettings includes defaults, so workspace.browsing_contexts may already be | ||
| 159 | // present even when the user only configured legacy workspace.popups. Always | 169 | // present even when the user only configured legacy workspace.popups. Always | ||
| @@ -171,6 +181,43 @@ func (m *Manager) transformLegacyConfig() { | |||||
| 171 | } | 181 | } | ||
| 172 | } | 182 | } | ||
| 173 | 183 | ||||
| 184 | + | func (m *Manager) configureAutomaticEnv() { | |||
| 185 | + | if m == nil || m.viper == nil { | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | replacer := strings.NewReplacer(".", "_") | |||
| 189 | + | m.viper.SetEnvPrefix("DUMBER") | |||
| 190 | + | m.viper.SetEnvKeyReplacer(replacer) | |||
| 191 | + | m.viper.AutomaticEnv() | |||
| 192 | + | m.automaticEnv = automaticEnvConfig{ | |||
| 193 | + | enabled: true, | |||
| 194 | + | prefix: "DUMBER", | |||
| 195 | + | keyReplacer: replacer, | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (m *Manager) hasExplicitAdaptiveWindowlessFrameRateSetting() bool { | |||
| 200 | + | if m == nil || m.viper == nil { | |||
| 201 | + | return false | |||
| 202 | + | } | |||
| 203 | + | if m.viper.InConfig("engine.cef.adaptive_windowless_frame_rate") { | |||
| 204 | + | return true | |||
| 205 | + | } | |||
| 206 | + | if !m.automaticEnv.enabled { | |||
| 207 | + | return false | |||
| 208 | + | } | |||
| 209 | + | envKey := "engine.cef.adaptive_windowless_frame_rate" | |||
| 210 | + | if m.automaticEnv.keyReplacer != nil { | |||
| 211 | + | envKey = m.automaticEnv.keyReplacer.Replace(envKey) | |||
| 212 | + | } | |||
| 213 | + | envKey = strings.ToUpper(envKey) | |||
| 214 | + | if prefix := strings.TrimSpace(m.automaticEnv.prefix); prefix != "" { | |||
| 215 | + | envKey = prefix + "_" + envKey | |||
| 216 | + | } | |||
| 217 | + | value, ok := os.LookupEnv(envKey) | |||
| 218 | + | return ok && value != "" | |||
| 219 | + | } | |||
| 220 | + | ||||
| 174 | func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) { | 221 | func (m *Manager) mergeLegacyPopupsIntoCanonicalBrowsingContexts(rawConfig map[string]any) { | ||
| 175 | workspace, ok := rawConfig["workspace"].(map[string]any) | 222 | workspace, ok := rawConfig["workspace"].(map[string]any) | ||
| 176 | if !ok { | 223 | if !ok { | ||
| @@ -694,7 +741,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 694 | m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack())) | 741 | m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack())) | ||
| 695 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | 742 | m.viper.SetDefault("engine.cef.log_file", ce.LogFile) | ||
| 696 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | 743 | m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity) | ||
| 697 | - | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate()) | 744 | + | m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate) |
| 745 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate) | |||
| 746 | + | m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax()) | |||
| 698 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | 747 | m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler) | ||
| 699 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | 748 | m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers) | ||
| 700 | 749 | ||||
M internal/infrastructure/config/loader_legacy_test.go +77 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/spf13/viper" | |
| @@ -75,3 +77,78 @@ func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) { | |||
| 75 | 77 | require.Error(t, err) | |
| 76 | 78 | require.Contains(t, err.Error(), "dumber config migrate") | |
| 77 | 79 | } | |
| 80 | + | ||
| 81 | + | func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) { | |
| 82 | + | dir := t.TempDir() | |
| 83 | + | configPath := filepath.Join(dir, "config.toml") | |
| 84 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |
| 85 | + | ||
| 86 | + | m := &Manager{viper: viper.New()} | |
| 87 | + | m.viper.SetConfigFile(configPath) | |
| 88 | + | m.viper.SetConfigType("toml") | |
| 89 | + | m.setDefaults() | |
| 90 | + | require.NoError(t, m.viper.ReadInConfig()) | |
| 91 | + | ||
| 92 | + | m.transformLegacyConfig() | |
| 93 | + | ||
| 94 | + | require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |
| 95 | + | require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |
| 96 | + | require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max")) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) { | |
| 100 | + | dir := t.TempDir() | |
| 101 | + | configPath := filepath.Join(dir, "config.toml") | |
| 102 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |
| 103 | + | t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false") | |
| 104 | + | ||
| 105 | + | m := &Manager{viper: viper.New()} | |
| 106 | + | m.viper.SetConfigFile(configPath) | |
| 107 | + | m.viper.SetConfigType("toml") | |
| 108 | + | m.configureAutomaticEnv() | |
| 109 | + | m.setDefaults() | |
| 110 | + | require.NoError(t, m.viper.ReadInConfig()) | |
| 111 | + | ||
| 112 | + | m.transformLegacyConfig() | |
| 113 | + | ||
| 114 | + | require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |
| 115 | + | require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) { | |
| 119 | + | dir := t.TempDir() | |
| 120 | + | configPath := filepath.Join(dir, "config.toml") | |
| 121 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |
| 122 | + | t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false") | |
| 123 | + | ||
| 124 | + | m := &Manager{viper: viper.New()} | |
| 125 | + | m.viper.SetConfigFile(configPath) | |
| 126 | + | m.viper.SetConfigType("toml") | |
| 127 | + | m.setDefaults() | |
| 128 | + | require.NoError(t, m.viper.ReadInConfig()) | |
| 129 | + | ||
| 130 | + | m.transformLegacyConfig() | |
| 131 | + | ||
| 132 | + | require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |
| 133 | + | require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) { | |
| 137 | + | dir := t.TempDir() | |
| 138 | + | configPath := filepath.Join(dir, "config.toml") | |
| 139 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |
| 140 | + | t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "true") | |
| 141 | + | ||
| 142 | + | m := &Manager{viper: viper.New()} | |
| 143 | + | m.viper.SetConfigFile(configPath) | |
| 144 | + | m.viper.SetConfigType("toml") | |
| 145 | + | m.configureAutomaticEnv() | |
| 146 | + | m.setDefaults() | |
| 147 | + | require.NoError(t, m.viper.ReadInConfig()) | |
| 148 | + | ||
| 149 | + | m.transformLegacyConfig() | |
| 150 | + | ||
| 151 | + | require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |
| 152 | + | require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |
| 153 | + | require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max")) | |
| 154 | + | } | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/spf13/viper" | 8 | "github.com/spf13/viper" | ||
| @@ -75,3 +77,78 @@ func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) { | |||||
| 75 | require.Error(t, err) | 77 | require.Error(t, err) | ||
| 76 | require.Contains(t, err.Error(), "dumber config migrate") | 78 | require.Contains(t, err.Error(), "dumber config migrate") | ||
| 77 | } | 79 | } | ||
| 80 | + | ||||
| 81 | + | func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *testing.T) { | |||
| 82 | + | dir := t.TempDir() | |||
| 83 | + | configPath := filepath.Join(dir, "config.toml") | |||
| 84 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |||
| 85 | + | ||||
| 86 | + | m := &Manager{viper: viper.New()} | |||
| 87 | + | m.viper.SetConfigFile(configPath) | |||
| 88 | + | m.viper.SetConfigType("toml") | |||
| 89 | + | m.setDefaults() | |||
| 90 | + | require.NoError(t, m.viper.ReadInConfig()) | |||
| 91 | + | ||||
| 92 | + | m.transformLegacyConfig() | |||
| 93 | + | ||||
| 94 | + | require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |||
| 95 | + | require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |||
| 96 | + | require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max")) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) { | |||
| 100 | + | dir := t.TempDir() | |||
| 101 | + | configPath := filepath.Join(dir, "config.toml") | |||
| 102 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |||
| 103 | + | t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false") | |||
| 104 | + | ||||
| 105 | + | m := &Manager{viper: viper.New()} | |||
| 106 | + | m.viper.SetConfigFile(configPath) | |||
| 107 | + | m.viper.SetConfigType("toml") | |||
| 108 | + | m.configureAutomaticEnv() | |||
| 109 | + | m.setDefaults() | |||
| 110 | + | require.NoError(t, m.viper.ReadInConfig()) | |||
| 111 | + | ||||
| 112 | + | m.transformLegacyConfig() | |||
| 113 | + | ||||
| 114 | + | require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |||
| 115 | + | require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) { | |||
| 119 | + | dir := t.TempDir() | |||
| 120 | + | configPath := filepath.Join(dir, "config.toml") | |||
| 121 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |||
| 122 | + | t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "false") | |||
| 123 | + | ||||
| 124 | + | m := &Manager{viper: viper.New()} | |||
| 125 | + | m.viper.SetConfigFile(configPath) | |||
| 126 | + | m.viper.SetConfigType("toml") | |||
| 127 | + | m.setDefaults() | |||
| 128 | + | require.NoError(t, m.viper.ReadInConfig()) | |||
| 129 | + | ||||
| 130 | + | m.transformLegacyConfig() | |||
| 131 | + | ||||
| 132 | + | require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |||
| 133 | + | require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) { | |||
| 137 | + | dir := t.TempDir() | |||
| 138 | + | configPath := filepath.Join(dir, "config.toml") | |||
| 139 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine.cef]\nwindowless_frame_rate = 60\n"), 0o600)) | |||
| 140 | + | t.Setenv("DUMBER_ENGINE_CEF_ADAPTIVE_WINDOWLESS_FRAME_RATE", "true") | |||
| 141 | + | ||||
| 142 | + | m := &Manager{viper: viper.New()} | |||
| 143 | + | m.viper.SetConfigFile(configPath) | |||
| 144 | + | m.viper.SetConfigType("toml") | |||
| 145 | + | m.configureAutomaticEnv() | |||
| 146 | + | m.setDefaults() | |||
| 147 | + | require.NoError(t, m.viper.ReadInConfig()) | |||
| 148 | + | ||||
| 149 | + | m.transformLegacyConfig() | |||
| 150 | + | ||||
| 151 | + | require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate")) | |||
| 152 | + | require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate")) | |||
| 153 | + | require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max")) | |||
| 154 | + | } | |||
M internal/infrastructure/config/schema_provider.go +17 -2
| @@ -939,11 +939,26 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 939 | 939 | Values: []string{"vulkan", "egl"}, | |
| 940 | 940 | Section: SectionPerformance, | |
| 941 | 941 | }, | |
| 942 | + | { | |
| 943 | + | Key: "engine.cef.adaptive_windowless_frame_rate", | |
| 944 | + | Type: "bool", | |
| 945 | + | Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate), | |
| 946 | + | Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set", | |
| 947 | + | Section: SectionPerformance, | |
| 948 | + | }, | |
| 942 | 949 | { | |
| 943 | 950 | Key: "engine.cef.windowless_frame_rate", | |
| 944 | 951 | Type: "int32", | |
| 945 | - | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | |
| 946 | - | Description: "Maximum off-screen frame rate for the CEF engine", | |
| 952 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate), | |
| 953 | + | Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured", | |
| 954 | + | Range: ">=0", | |
| 955 | + | Section: SectionPerformance, | |
| 956 | + | }, | |
| 957 | + | { | |
| 958 | + | Key: "engine.cef.windowless_frame_rate_max", | |
| 959 | + | Type: "int32", | |
| 960 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()), | |
| 961 | + | Description: "Hard cap for adaptive CEF OSR frame rate; 0 uses the built-in default cap", | |
| 947 | 962 | Range: ">=0", | |
| 948 | 963 | Section: SectionPerformance, | |
| 949 | 964 | }, | |
| @@ -939,11 +939,26 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 939 | Values: []string{"vulkan", "egl"}, | 939 | Values: []string{"vulkan", "egl"}, | ||
| 940 | Section: SectionPerformance, | 940 | Section: SectionPerformance, | ||
| 941 | }, | 941 | }, | ||
| 942 | + | { | |||
| 943 | + | Key: "engine.cef.adaptive_windowless_frame_rate", | |||
| 944 | + | Type: "bool", | |||
| 945 | + | Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate), | |||
| 946 | + | Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set", | |||
| 947 | + | Section: SectionPerformance, | |||
| 948 | + | }, | |||
| 942 | { | 949 | { | ||
| 943 | Key: "engine.cef.windowless_frame_rate", | 950 | Key: "engine.cef.windowless_frame_rate", | ||
| 944 | Type: "int32", | 951 | Type: "int32", | ||
| 945 | - | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | 952 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate), |
| 946 | - | Description: "Maximum off-screen frame rate for the CEF engine", | 953 | + | Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured", |
| 954 | + | Range: ">=0", | |||
| 955 | + | Section: SectionPerformance, | |||
| 956 | + | }, | |||
| 957 | + | { | |||
| 958 | + | Key: "engine.cef.windowless_frame_rate_max", | |||
| 959 | + | Type: "int32", | |||
| 960 | + | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()), | |||
| 961 | + | Description: "Hard cap for adaptive CEF OSR frame rate; 0 uses the built-in default cap", | |||
| 947 | Range: ">=0", | 962 | Range: ">=0", | ||
| 948 | Section: SectionPerformance, | 963 | Section: SectionPerformance, | ||
| 949 | }, | 964 | }, | ||
M internal/infrastructure/config/transform_legacy.go +81 -1
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | + | import "math" | |
| 4 | + | ||
| 3 | 5 | const ( | |
| 4 | 6 | sectionWorkspace = "workspace" | |
| 5 | 7 | sectionSession = "session" | |
| @@ -42,7 +44,19 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an | |||
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | // TransformLegacyEngineConfig removes deprecated engine config keys. | |
| 45 | - | func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 47 | + | func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 48 | + | adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate") | |
| 49 | + | t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive( | |
| 53 | + | rawConfig map[string]any, | |
| 54 | + | hasAdaptive, adaptiveEnabled bool, | |
| 55 | + | ) { | |
| 56 | + | t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) { | |
| 46 | 60 | engine, ok := rawConfig["engine"].(map[string]any) | |
| 47 | 61 | if !ok { | |
| 48 | 62 | return | |
| @@ -54,6 +68,72 @@ func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string | |||
| 54 | 68 | } | |
| 55 | 69 | ||
| 56 | 70 | delete(cef, "enable_context_menu_handler") | |
| 71 | + | migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) { | |
| 75 | + | if hasAdaptive && !adaptiveEnabled { | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"]) | |
| 79 | + | if !ok || frameRate != defaultCEFWindowlessFrameRate { | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | cef["adaptive_windowless_frame_rate"] = true | |
| 83 | + | cef["windowless_frame_rate"] = 0 | |
| 84 | + | if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax { | |
| 85 | + | cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) { | |
| 90 | + | current := rawConfig | |
| 91 | + | for i, key := range path { | |
| 92 | + | value, ok := current[key] | |
| 93 | + | if !ok { | |
| 94 | + | return false, false | |
| 95 | + | } | |
| 96 | + | if i == len(path)-1 { | |
| 97 | + | parsed, parsedOK := value.(bool) | |
| 98 | + | return parsed, parsedOK | |
| 99 | + | } | |
| 100 | + | next, ok := value.(map[string]any) | |
| 101 | + | if !ok { | |
| 102 | + | return false, false | |
| 103 | + | } | |
| 104 | + | current = next | |
| 105 | + | } | |
| 106 | + | return false, false | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func int64ConfigValue(value any) (int64, bool) { | |
| 110 | + | switch v := value.(type) { | |
| 111 | + | case int: | |
| 112 | + | return int64(v), true | |
| 113 | + | case int8: | |
| 114 | + | return int64(v), true | |
| 115 | + | case int16: | |
| 116 | + | return int64(v), true | |
| 117 | + | case int32: | |
| 118 | + | return int64(v), true | |
| 119 | + | case int64: | |
| 120 | + | return v, true | |
| 121 | + | case uint: | |
| 122 | + | return int64(v), true | |
| 123 | + | case uint8: | |
| 124 | + | return int64(v), true | |
| 125 | + | case uint16: | |
| 126 | + | return int64(v), true | |
| 127 | + | case uint32: | |
| 128 | + | return int64(v), true | |
| 129 | + | case uint64: | |
| 130 | + | if v > math.MaxInt64 { | |
| 131 | + | return 0, false | |
| 132 | + | } | |
| 133 | + | return int64(v), true | |
| 134 | + | default: | |
| 135 | + | return 0, false | |
| 136 | + | } | |
| 57 | 137 | } | |
| 58 | 138 | ||
| 59 | 139 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |
| @@ -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" | ||
| @@ -42,7 +44,19 @@ func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]an | |||||
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| 44 | // TransformLegacyEngineConfig removes deprecated engine config keys. | 46 | // TransformLegacyEngineConfig removes deprecated engine config keys. | ||
| 45 | - | func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | 47 | + | func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { |
| 48 | + | adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate") | |||
| 49 | + | t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (t *LegacyConfigTransformer) TransformLegacyEngineConfigWithExplicitAdaptive( | |||
| 53 | + | rawConfig map[string]any, | |||
| 54 | + | hasAdaptive, adaptiveEnabled bool, | |||
| 55 | + | ) { | |||
| 56 | + | t.transformLegacyEngineConfig(rawConfig, hasAdaptive, adaptiveEnabled) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (*LegacyConfigTransformer) transformLegacyEngineConfig(rawConfig map[string]any, hasAdaptive, adaptiveEnabled bool) { | |||
| 46 | engine, ok := rawConfig["engine"].(map[string]any) | 60 | engine, ok := rawConfig["engine"].(map[string]any) | ||
| 47 | if !ok { | 61 | if !ok { | ||
| 48 | return | 62 | return | ||
| @@ -54,6 +68,72 @@ func (*LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string | |||||
| 54 | } | 68 | } | ||
| 55 | 69 | ||||
| 56 | delete(cef, "enable_context_menu_handler") | 70 | delete(cef, "enable_context_menu_handler") | ||
| 71 | + | migrateLegacyCEFWindowlessFrameRateDefault(cef, hasAdaptive, adaptiveEnabled) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func migrateLegacyCEFWindowlessFrameRateDefault(cef map[string]any, hasAdaptive, adaptiveEnabled bool) { | |||
| 75 | + | if hasAdaptive && !adaptiveEnabled { | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | frameRate, ok := int64ConfigValue(cef["windowless_frame_rate"]) | |||
| 79 | + | if !ok || frameRate != defaultCEFWindowlessFrameRate { | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | cef["adaptive_windowless_frame_rate"] = true | |||
| 83 | + | cef["windowless_frame_rate"] = 0 | |||
| 84 | + | if _, hasMax := cef["windowless_frame_rate_max"]; !hasMax { | |||
| 85 | + | cef["windowless_frame_rate_max"] = defaultCEFWindowlessFrameRateMax | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func nestedConfigBoolValue(rawConfig map[string]any, path ...string) (bool, bool) { | |||
| 90 | + | current := rawConfig | |||
| 91 | + | for i, key := range path { | |||
| 92 | + | value, ok := current[key] | |||
| 93 | + | if !ok { | |||
| 94 | + | return false, false | |||
| 95 | + | } | |||
| 96 | + | if i == len(path)-1 { | |||
| 97 | + | parsed, parsedOK := value.(bool) | |||
| 98 | + | return parsed, parsedOK | |||
| 99 | + | } | |||
| 100 | + | next, ok := value.(map[string]any) | |||
| 101 | + | if !ok { | |||
| 102 | + | return false, false | |||
| 103 | + | } | |||
| 104 | + | current = next | |||
| 105 | + | } | |||
| 106 | + | return false, false | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func int64ConfigValue(value any) (int64, bool) { | |||
| 110 | + | switch v := value.(type) { | |||
| 111 | + | case int: | |||
| 112 | + | return int64(v), true | |||
| 113 | + | case int8: | |||
| 114 | + | return int64(v), true | |||
| 115 | + | case int16: | |||
| 116 | + | return int64(v), true | |||
| 117 | + | case int32: | |||
| 118 | + | return int64(v), true | |||
| 119 | + | case int64: | |||
| 120 | + | return v, true | |||
| 121 | + | case uint: | |||
| 122 | + | return int64(v), true | |||
| 123 | + | case uint8: | |||
| 124 | + | return int64(v), true | |||
| 125 | + | case uint16: | |||
| 126 | + | return int64(v), true | |||
| 127 | + | case uint32: | |||
| 128 | + | return int64(v), true | |||
| 129 | + | case uint64: | |||
| 130 | + | if v > math.MaxInt64 { | |||
| 131 | + | return 0, false | |||
| 132 | + | } | |||
| 133 | + | return int64(v), true | |||
| 134 | + | default: | |||
| 135 | + | return 0, false | |||
| 136 | + | } | |||
| 57 | } | 137 | } | ||
| 58 | 138 | ||||
| 59 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | 139 | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | ||
M internal/infrastructure/config/transform_legacy_test.go +56 -0
| @@ -188,6 +188,62 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test | |||
| 188 | 188 | assert.False(t, exists) | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | + | func TestTransformLegacyEngineConfig_MigratesLegacyCEFWindowlessFrameRateDefault(t *testing.T) { | |
| 192 | + | transformer := NewLegacyConfigTransformer() | |
| 193 | + | rawConfig := map[string]any{ | |
| 194 | + | "engine": map[string]any{ | |
| 195 | + | "cef": map[string]any{ | |
| 196 | + | "windowless_frame_rate": 60, | |
| 197 | + | }, | |
| 198 | + | }, | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 202 | + | ||
| 203 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 204 | + | assert.Equal(t, true, cef["adaptive_windowless_frame_rate"]) | |
| 205 | + | assert.Equal(t, 0, cef["windowless_frame_rate"]) | |
| 206 | + | assert.Equal(t, defaultCEFWindowlessFrameRateMax, cef["windowless_frame_rate_max"]) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestTransformLegacyEngineConfig_PreservesExplicitCEFWindowlessFrameRate(t *testing.T) { | |
| 210 | + | transformer := NewLegacyConfigTransformer() | |
| 211 | + | rawConfig := map[string]any{ | |
| 212 | + | "engine": map[string]any{ | |
| 213 | + | "cef": map[string]any{ | |
| 214 | + | "adaptive_windowless_frame_rate": false, | |
| 215 | + | "windowless_frame_rate": 60, | |
| 216 | + | }, | |
| 217 | + | }, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 221 | + | ||
| 222 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 223 | + | assert.Equal(t, false, cef["adaptive_windowless_frame_rate"]) | |
| 224 | + | assert.Equal(t, 60, cef["windowless_frame_rate"]) | |
| 225 | + | _, hasMax := cef["windowless_frame_rate_max"] | |
| 226 | + | assert.False(t, hasMax) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestTransformLegacyEngineConfig_PreservesNonDefaultCEFWindowlessFrameRate(t *testing.T) { | |
| 230 | + | transformer := NewLegacyConfigTransformer() | |
| 231 | + | rawConfig := map[string]any{ | |
| 232 | + | "engine": map[string]any{ | |
| 233 | + | "cef": map[string]any{ | |
| 234 | + | "windowless_frame_rate": 144, | |
| 235 | + | }, | |
| 236 | + | }, | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |
| 240 | + | ||
| 241 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |
| 242 | + | _, hasAdaptive := cef["adaptive_windowless_frame_rate"] | |
| 243 | + | assert.False(t, hasAdaptive) | |
| 244 | + | assert.Equal(t, 144, cef["windowless_frame_rate"]) | |
| 245 | + | } | |
| 246 | + | ||
| 191 | 247 | func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) { | |
| 192 | 248 | transformer := NewLegacyConfigTransformer() | |
| 193 | 249 | rawConfig := map[string]any{ | |
| @@ -188,6 +188,62 @@ func TestTransformLegacyEngineConfig_RemovesDeprecatedContextMenuHandler(t *test | |||||
| 188 | assert.False(t, exists) | 188 | assert.False(t, exists) | ||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | + | func TestTransformLegacyEngineConfig_MigratesLegacyCEFWindowlessFrameRateDefault(t *testing.T) { | |||
| 192 | + | transformer := NewLegacyConfigTransformer() | |||
| 193 | + | rawConfig := map[string]any{ | |||
| 194 | + | "engine": map[string]any{ | |||
| 195 | + | "cef": map[string]any{ | |||
| 196 | + | "windowless_frame_rate": 60, | |||
| 197 | + | }, | |||
| 198 | + | }, | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 202 | + | ||||
| 203 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |||
| 204 | + | assert.Equal(t, true, cef["adaptive_windowless_frame_rate"]) | |||
| 205 | + | assert.Equal(t, 0, cef["windowless_frame_rate"]) | |||
| 206 | + | assert.Equal(t, defaultCEFWindowlessFrameRateMax, cef["windowless_frame_rate_max"]) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestTransformLegacyEngineConfig_PreservesExplicitCEFWindowlessFrameRate(t *testing.T) { | |||
| 210 | + | transformer := NewLegacyConfigTransformer() | |||
| 211 | + | rawConfig := map[string]any{ | |||
| 212 | + | "engine": map[string]any{ | |||
| 213 | + | "cef": map[string]any{ | |||
| 214 | + | "adaptive_windowless_frame_rate": false, | |||
| 215 | + | "windowless_frame_rate": 60, | |||
| 216 | + | }, | |||
| 217 | + | }, | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 221 | + | ||||
| 222 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |||
| 223 | + | assert.Equal(t, false, cef["adaptive_windowless_frame_rate"]) | |||
| 224 | + | assert.Equal(t, 60, cef["windowless_frame_rate"]) | |||
| 225 | + | _, hasMax := cef["windowless_frame_rate_max"] | |||
| 226 | + | assert.False(t, hasMax) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestTransformLegacyEngineConfig_PreservesNonDefaultCEFWindowlessFrameRate(t *testing.T) { | |||
| 230 | + | transformer := NewLegacyConfigTransformer() | |||
| 231 | + | rawConfig := map[string]any{ | |||
| 232 | + | "engine": map[string]any{ | |||
| 233 | + | "cef": map[string]any{ | |||
| 234 | + | "windowless_frame_rate": 144, | |||
| 235 | + | }, | |||
| 236 | + | }, | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | transformer.TransformLegacyEngineConfig(rawConfig) | |||
| 240 | + | ||||
| 241 | + | cef := rawConfig["engine"].(map[string]any)["cef"].(map[string]any) | |||
| 242 | + | _, hasAdaptive := cef["adaptive_windowless_frame_rate"] | |||
| 243 | + | assert.False(t, hasAdaptive) | |||
| 244 | + | assert.Equal(t, 144, cef["windowless_frame_rate"]) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 191 | func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) { | 247 | func TestTransformLegacyEngineConfig_MissingCefSection(t *testing.T) { | ||
| 192 | transformer := NewLegacyConfigTransformer() | 248 | transformer := NewLegacyConfigTransformer() | ||
| 193 | rawConfig := map[string]any{ | 249 | rawConfig := map[string]any{ | ||
M internal/infrastructure/config/validation.go +3 -0
| @@ -358,6 +358,9 @@ func validateCEF(config *Config) []string { | |||
| 358 | 358 | if config.Engine.CEF.WindowlessFrameRate < 0 { | |
| 359 | 359 | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") | |
| 360 | 360 | } | |
| 361 | + | if config.Engine.CEF.WindowlessFrameRateMax < 0 { | |
| 362 | + | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0") | |
| 363 | + | } | |
| 361 | 364 | ||
| 362 | 365 | return validationErrors | |
| 363 | 366 | } | |
| @@ -358,6 +358,9 @@ func validateCEF(config *Config) []string { | |||||
| 358 | if config.Engine.CEF.WindowlessFrameRate < 0 { | 358 | if config.Engine.CEF.WindowlessFrameRate < 0 { | ||
| 359 | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") | 359 | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0") | ||
| 360 | } | 360 | } | ||
| 361 | + | if config.Engine.CEF.WindowlessFrameRateMax < 0 { | |||
| 362 | + | validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0") | |||
| 363 | + | } | |||
| 361 | 364 | ||||
| 362 | return validationErrors | 365 | return validationErrors | ||
| 363 | } | 366 | } | ||
M internal/ui/app.go +25 -7
| @@ -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") | |
| @@ -2357,13 +2361,27 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow | |||
| 2357 | 2361 | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window") | |
| 2358 | 2362 | return nil | |
| 2359 | 2363 | } | |
| 2360 | - | a.activateBrowserWindow(bw) | |
| 2361 | - | target := a.ensureTabTargetForBrowserWindow(bw) | |
| 2362 | - | initialURL := "" | |
| 2363 | - | if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" { | |
| 2364 | - | initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL) | |
| 2364 | + | if index < 0 { | |
| 2365 | + | logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index") | |
| 2366 | + | return nil | |
| 2367 | + | } | |
| 2368 | + | target := a.tabTargetForBrowserWindow(bw) | |
| 2369 | + | tabCount := 0 | |
| 2370 | + | if target.Tabs != nil { | |
| 2371 | + | tabCount = target.Tabs.Count() | |
| 2372 | + | } | |
| 2373 | + | if index < tabCount { | |
| 2374 | + | a.activateBrowserWindow(bw) | |
| 2375 | + | return a.tabCoord.SwitchByIndex(ctx, target, index) | |
| 2365 | 2376 | } | |
| 2366 | - | return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL) | |
| 2377 | + | if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" { | |
| 2378 | + | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured") | |
| 2379 | + | return fmt.Errorf("newPaneURL is not configured") | |
| 2380 | + | } | |
| 2381 | + | a.activateBrowserWindow(bw) | |
| 2382 | + | ensureTarget := a.ensureTabTargetForBrowserWindow(bw) | |
| 2383 | + | _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)) | |
| 2384 | + | return err | |
| 2367 | 2385 | } | |
| 2368 | 2386 | ||
| 2369 | 2387 | // 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") | ||
| @@ -2357,13 +2361,27 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow | |||||
| 2357 | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window") | 2361 | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window") | ||
| 2358 | return nil | 2362 | return nil | ||
| 2359 | } | 2363 | } | ||
| 2360 | - | a.activateBrowserWindow(bw) | 2364 | + | if index < 0 { |
| 2361 | - | target := a.ensureTabTargetForBrowserWindow(bw) | 2365 | + | logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index ignored: invalid negative index") |
| 2362 | - | initialURL := "" | 2366 | + | return nil |
| 2363 | - | if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" { | 2367 | + | } |
| 2364 | - | initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL) | 2368 | + | target := a.tabTargetForBrowserWindow(bw) |
| 2369 | + | tabCount := 0 | |||
| 2370 | + | if target.Tabs != nil { | |||
| 2371 | + | tabCount = target.Tabs.Count() | |||
| 2372 | + | } | |||
| 2373 | + | if index < tabCount { | |||
| 2374 | + | a.activateBrowserWindow(bw) | |||
| 2375 | + | return a.tabCoord.SwitchByIndex(ctx, target, index) | |||
| 2365 | } | 2376 | } | ||
| 2366 | - | return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL) | 2377 | + | if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" { |
| 2378 | + | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: new pane URL is not configured") | |||
| 2379 | + | return fmt.Errorf("newPaneURL is not configured") | |||
| 2380 | + | } | |||
| 2381 | + | a.activateBrowserWindow(bw) | |||
| 2382 | + | ensureTarget := a.ensureTabTargetForBrowserWindow(bw) | |||
| 2383 | + | _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)) | |||
| 2384 | + | return err | |||
| 2367 | } | 2385 | } | ||
| 2368 | 2386 | ||||
| 2369 | // windowOrder returns window IDs in registration order. | 2387 | // windowOrder returns window IDs in registration order. | ||
M internal/ui/app_browser_launch_test.go +168 -13
| @@ -2243,10 +2243,54 @@ func counterIDGen() func() string { | |||
| 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,135 @@ 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_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) { | |
| 2379 | + | ctx := context.Background() | |
| 2380 | + | ||
| 2381 | + | second := &browserWindow{id: "window-2"} | |
| 2382 | + | ||
| 2383 | + | app := &App{ | |
| 2384 | + | deps: &Dependencies{ | |
| 2385 | + | Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}, | |
| 2386 | + | }, | |
| 2387 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |
| 2388 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |
| 2389 | + | lastFocusedWindowID: "window-1", | |
| 2390 | + | } | |
| 2391 | + | app.initTabCoordinator(ctx) | |
| 2392 | + | ||
| 2393 | + | err := app.switchBrowserWindowTabIndex(ctx, second, -1) | |
| 2394 | + | if err != nil { | |
| 2395 | + | t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err) | |
| 2396 | + | } | |
| 2397 | + | if got := app.lastFocusedWindowID; got != "window-1" { | |
| 2398 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1") | |
| 2399 | + | } | |
| 2400 | + | if second.tabs != nil { | |
| 2401 | + | t.Fatalf("second tabs should stay nil for invalid index") | |
| 2402 | + | } | |
| 2403 | + | } | |
| 2404 | + | ||
| 2405 | + | func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) { | |
| 2406 | + | ctx := context.Background() | |
| 2407 | + | ||
| 2408 | + | secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))) | |
| 2409 | + | secondTabs := entity.NewTabList() | |
| 2410 | + | secondTabs.Add(secondTab) | |
| 2411 | + | secondTabs.SetActive(secondTab.ID) | |
| 2412 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |
| 2413 | + | ||
| 2414 | + | app := &App{ | |
| 2415 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 2416 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |
| 2417 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |
| 2418 | + | windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second}, | |
| 2419 | + | } | |
| 2420 | + | app.initTabCoordinator(ctx) | |
| 2421 | + | ||
| 2422 | + | err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4) | |
| 2423 | + | if err == nil { | |
| 2424 | + | t.Fatal("dispatchBrowserWindowAction returned nil error, want config error") | |
| 2425 | + | } | |
| 2426 | + | if got := secondTabs.Count(); got != 1 { | |
| 2427 | + | t.Fatalf("second target tab count = %d, want 1", got) | |
| 2428 | + | } | |
| 2429 | + | if got := secondTabs.ActiveTabID; got != secondTab.ID { | |
| 2430 | + | t.Fatalf("active tab = %q, want %q", got, secondTab.ID) | |
| 2431 | + | } | |
| 2432 | + | } | |
| 2433 | + | ||
| 2434 | + | func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) { | |
| 2435 | + | ctx := context.Background() | |
| 2436 | + | ||
| 2437 | + | second := &browserWindow{id: "window-2"} | |
| 2438 | + | ||
| 2439 | + | app := &App{ | |
| 2440 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 2441 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |
| 2442 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |
| 2443 | + | lastFocusedWindowID: "window-1", | |
| 2444 | + | } | |
| 2445 | + | app.initTabCoordinator(ctx) | |
| 2446 | + | ||
| 2447 | + | err := app.switchBrowserWindowTabIndex(ctx, second, 3) | |
| 2448 | + | if err == nil { | |
| 2449 | + | t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error") | |
| 2450 | + | } | |
| 2451 | + | if got := app.lastFocusedWindowID; got != "window-1" { | |
| 2452 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1") | |
| 2453 | + | } | |
| 2454 | + | if second.tabs != nil { | |
| 2455 | + | t.Fatalf("second tabs should stay nil when new pane URL is missing") | |
| 2301 | 2456 | } | |
| 2302 | 2457 | } | |
| 2303 | 2458 | ||
| @@ -2243,10 +2243,54 @@ func counterIDGen() func() string { | |||||
| 2243 | } | 2243 | } | ||
| 2244 | } | 2244 | } | ||
| 2245 | 2245 | ||||
| 2246 | - | func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) { | 2246 | + | func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testing.T) { |
| 2247 | + | ctx := context.Background() | |||
| 2248 | + | ||||
| 2249 | + | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |||
| 2250 | + | firstTabs := entity.NewTabList() | |||
| 2251 | + | firstTabs.Add(firstTab) | |||
| 2252 | + | firstTabs.SetActive(firstTab.ID) | |||
| 2253 | + | first := &browserWindow{id: "window-1", tabs: firstTabs} | |||
| 2254 | + | ||||
| 2255 | + | secondTab1 := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))) | |||
| 2256 | + | secondTab2 := entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))) | |||
| 2257 | + | secondTabs := entity.NewTabList() | |||
| 2258 | + | secondTabs.Add(secondTab1) | |||
| 2259 | + | secondTabs.Add(secondTab2) | |||
| 2260 | + | secondTabs.SetActive(secondTab1.ID) | |||
| 2261 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |||
| 2262 | + | ||||
| 2263 | + | app := &App{ | |||
| 2264 | + | deps: &Dependencies{ | |||
| 2265 | + | Config: &config.Config{ | |||
| 2266 | + | Workspace: config.WorkspaceConfig{ | |||
| 2267 | + | NewPaneURL: "about:blank", | |||
| 2268 | + | }, | |||
| 2269 | + | }, | |||
| 2270 | + | }, | |||
| 2271 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |||
| 2272 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |||
| 2273 | + | windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second}, | |||
| 2274 | + | lastFocusedWindowID: first.id, // stale — should NOT be used | |||
| 2275 | + | } | |||
| 2276 | + | app.initTabCoordinator(ctx) | |||
| 2277 | + | ||||
| 2278 | + | err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex2) | |||
| 2279 | + | if err != nil { | |||
| 2280 | + | t.Fatalf("dispatchBrowserWindowAction returned error: %v", err) | |||
| 2281 | + | } | |||
| 2282 | + | ||||
| 2283 | + | if got := secondTabs.ActiveTabID; got != secondTab2.ID { | |||
| 2284 | + | t.Fatalf("second active tab = %q, want %q", got, secondTab2.ID) | |||
| 2285 | + | } | |||
| 2286 | + | if got := firstTabs.ActiveTabID; got != firstTab.ID { | |||
| 2287 | + | t.Fatalf("first active tab = %q, want %q", got, firstTab.ID) | |||
| 2288 | + | } | |||
| 2289 | + | } | |||
| 2290 | + | ||||
| 2291 | + | func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t *testing.T) { | |||
| 2247 | ctx := context.Background() | 2292 | ctx := context.Background() | ||
| 2248 | 2293 | ||||
| 2249 | - | // Two windows, each with one tab. | |||
| 2250 | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | 2294 | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | ||
| 2251 | firstTabs := entity.NewTabList() | 2295 | firstTabs := entity.NewTabList() | ||
| 2252 | firstTabs.Add(firstTab) | 2296 | firstTabs.Add(firstTab) | ||
| @@ -2280,24 +2324,135 @@ func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *t | |||||
| 2280 | t.Fatalf("dispatchBrowserWindowAction returned error: %v", err) | 2324 | t.Fatalf("dispatchBrowserWindowAction returned error: %v", err) | ||
| 2281 | } | 2325 | } | ||
| 2282 | 2326 | ||||
| 2283 | - | // secondTabs should have 4 tabs (1 existing + 3 new). | 2327 | + | // Out-of-range switches should create a single new tab in the source window. |
| 2284 | - | if got := secondTabs.Count(); got != 4 { | 2328 | + | if got := secondTabs.Count(); got != 2 { |
| 2285 | - | t.Fatalf("second target tab count = %d, want 4", got) | 2329 | + | t.Fatalf("second target tab count = %d, want 2", got) |
| 2286 | } | 2330 | } | ||
| 2287 | - | if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID { | 2331 | + | if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[1].ID { |
| 2288 | - | t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID) | 2332 | + | t.Fatalf("active tab = %q, want %q", got, secondTabs.Tabs[1].ID) |
| 2289 | } | 2333 | } | ||
| 2290 | 2334 | ||||
| 2291 | - | // firstTabs must be completely unchanged. | |||
| 2292 | if got := firstTabs.Count(); got != 1 { | 2335 | if got := firstTabs.Count(); got != 1 { | ||
| 2293 | t.Fatalf("first target tab count = %d, want 1", got) | 2336 | t.Fatalf("first target tab count = %d, want 1", got) | ||
| 2294 | } | 2337 | } | ||
| 2338 | + | if got := firstTabs.ActiveTabID; got != firstTab.ID { | |||
| 2339 | + | t.Fatalf("first active tab = %q, want %q", got, firstTab.ID) | |||
| 2340 | + | } | |||
| 2295 | 2341 | ||||
| 2296 | - | // windowForTab should map all second window tabs to second. | 2342 | + | if got := app.windowForTab[secondTabs.Tabs[1].ID]; got != second { |
| 2297 | - | for _, tab := range secondTabs.Tabs { | 2343 | + | t.Fatalf("windowForTab[%s] = %p, want second window %p", secondTabs.Tabs[1].ID, got, second) |
| 2298 | - | if got := app.windowForTab[tab.ID]; got != second { | 2344 | + | } |
| 2299 | - | t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second) | 2345 | + | } |
| 2300 | - | } | 2346 | + | |
| 2347 | + | func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) { | |||
| 2348 | + | ctx := context.Background() | |||
| 2349 | + | ||||
| 2350 | + | secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))) | |||
| 2351 | + | secondTabs := entity.NewTabList() | |||
| 2352 | + | secondTabs.Add(secondTab) | |||
| 2353 | + | secondTabs.SetActive(secondTab.ID) | |||
| 2354 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |||
| 2355 | + | ||||
| 2356 | + | app := &App{ | |||
| 2357 | + | deps: &Dependencies{ | |||
| 2358 | + | Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}, | |||
| 2359 | + | }, | |||
| 2360 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |||
| 2361 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |||
| 2362 | + | windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second}, | |||
| 2363 | + | } | |||
| 2364 | + | app.initTabCoordinator(ctx) | |||
| 2365 | + | ||||
| 2366 | + | err := app.switchBrowserWindowTabIndex(ctx, second, -1) | |||
| 2367 | + | if err != nil { | |||
| 2368 | + | t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err) | |||
| 2369 | + | } | |||
| 2370 | + | if got := secondTabs.Count(); got != 1 { | |||
| 2371 | + | t.Fatalf("second target tab count = %d, want 1", got) | |||
| 2372 | + | } | |||
| 2373 | + | if got := secondTabs.ActiveTabID; got != secondTab.ID { | |||
| 2374 | + | t.Fatalf("active tab = %q, want %q", got, secondTab.ID) | |||
| 2375 | + | } | |||
| 2376 | + | } | |||
| 2377 | + | ||||
| 2378 | + | func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) { | |||
| 2379 | + | ctx := context.Background() | |||
| 2380 | + | ||||
| 2381 | + | second := &browserWindow{id: "window-2"} | |||
| 2382 | + | ||||
| 2383 | + | app := &App{ | |||
| 2384 | + | deps: &Dependencies{ | |||
| 2385 | + | Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}, | |||
| 2386 | + | }, | |||
| 2387 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |||
| 2388 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |||
| 2389 | + | lastFocusedWindowID: "window-1", | |||
| 2390 | + | } | |||
| 2391 | + | app.initTabCoordinator(ctx) | |||
| 2392 | + | ||||
| 2393 | + | err := app.switchBrowserWindowTabIndex(ctx, second, -1) | |||
| 2394 | + | if err != nil { | |||
| 2395 | + | t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err) | |||
| 2396 | + | } | |||
| 2397 | + | if got := app.lastFocusedWindowID; got != "window-1" { | |||
| 2398 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1") | |||
| 2399 | + | } | |||
| 2400 | + | if second.tabs != nil { | |||
| 2401 | + | t.Fatalf("second tabs should stay nil for invalid index") | |||
| 2402 | + | } | |||
| 2403 | + | } | |||
| 2404 | + | ||||
| 2405 | + | func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) { | |||
| 2406 | + | ctx := context.Background() | |||
| 2407 | + | ||||
| 2408 | + | secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))) | |||
| 2409 | + | secondTabs := entity.NewTabList() | |||
| 2410 | + | secondTabs.Add(secondTab) | |||
| 2411 | + | secondTabs.SetActive(secondTab.ID) | |||
| 2412 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |||
| 2413 | + | ||||
| 2414 | + | app := &App{ | |||
| 2415 | + | deps: &Dependencies{Config: &config.Config{}}, | |||
| 2416 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |||
| 2417 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |||
| 2418 | + | windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second}, | |||
| 2419 | + | } | |||
| 2420 | + | app.initTabCoordinator(ctx) | |||
| 2421 | + | ||||
| 2422 | + | err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4) | |||
| 2423 | + | if err == nil { | |||
| 2424 | + | t.Fatal("dispatchBrowserWindowAction returned nil error, want config error") | |||
| 2425 | + | } | |||
| 2426 | + | if got := secondTabs.Count(); got != 1 { | |||
| 2427 | + | t.Fatalf("second target tab count = %d, want 1", got) | |||
| 2428 | + | } | |||
| 2429 | + | if got := secondTabs.ActiveTabID; got != secondTab.ID { | |||
| 2430 | + | t.Fatalf("active tab = %q, want %q", got, secondTab.ID) | |||
| 2431 | + | } | |||
| 2432 | + | } | |||
| 2433 | + | ||||
| 2434 | + | func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) { | |||
| 2435 | + | ctx := context.Background() | |||
| 2436 | + | ||||
| 2437 | + | second := &browserWindow{id: "window-2"} | |||
| 2438 | + | ||||
| 2439 | + | app := &App{ | |||
| 2440 | + | deps: &Dependencies{Config: &config.Config{}}, | |||
| 2441 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |||
| 2442 | + | browserWindows: map[string]*browserWindow{second.id: second}, | |||
| 2443 | + | lastFocusedWindowID: "window-1", | |||
| 2444 | + | } | |||
| 2445 | + | app.initTabCoordinator(ctx) | |||
| 2446 | + | ||||
| 2447 | + | err := app.switchBrowserWindowTabIndex(ctx, second, 3) | |||
| 2448 | + | if err == nil { | |||
| 2449 | + | t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error") | |||
| 2450 | + | } | |||
| 2451 | + | if got := app.lastFocusedWindowID; got != "window-1" { | |||
| 2452 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1") | |||
| 2453 | + | } | |||
| 2454 | + | if second.tabs != nil { | |||
| 2455 | + | t.Fatalf("second tabs should stay nil when new pane URL is missing") | |||
| 2301 | } | 2456 | } | ||
| 2302 | } | 2457 | } | ||
| 2303 | 2458 | ||||
M internal/ui/input/global_shortcuts.go +2 -0
| @@ -429,6 +429,8 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |||
| 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, | ||