fix(cef): apply the bridge page-zoom compensation once (#426)
* fix(domain): round the displayed zoom percentage Accumulated floating-point error made the default page zoom display as 99% for factors such as 0.9999999999999997. Round the product before integer conversion so displayed values match the factor a user set. Persisted factors, step size, limits, and default zoom are unchanged. * fix(cef): apply the bridge page-zoom compensation once Dumber derived its internal CEF zoom factor from the GTK surface scale divided by the bridge OSR backing scale, which cancels to 1 while the bridge serves its device-sized backing contract. With a 1.25 output at UI scale 1.4 the bridge reports effective scale 1.75 while CEF is told the screen is 1x, so the page's CSS viewport spanned 1.75x the logical view size and a 100 CSS-pixel square was drawn 57 logical pixels wide instead of 100. Read the compensation from the bridge contract instead of deriving it here and apply it through a single zoom path: SetZoomLevel and output-scale reapplication share one calculation, the compensation actually applied is recorded for the reapply guard, CEF zoom readback is decoded with the compensation of the application it diagnoses and skipped once superseded, and the scale probe derives its internal CEF factor from the same value. User zoom stays a user-facing value that no output transition or density change mutates. The bridge dependency is pinned to the commit that publishes View.PageZoomCompensation; the released version replaces that pin at the release gate. * fix(cef): harden zoom compensation integration * fix(cef): satisfy zoom compensation lint checks
- commit
- e04d7eb835445b2f72dd03ad20325839598c3925
- author
- bnema · · 2026-09-11 04:40 UTC
- committer
- GitHub · · 2026-09-11 04:40 UTC
- parent
- 6d3ac34
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
19 files · +534 -118
M docs/config/index.md +2 -0
| @@ -299,6 +299,8 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via | |||
| 299 | 299 | ||
| 300 | 300 | `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed. | |
| 301 | 301 | ||
| 302 | + | With the CEF engine, `default_ui_scale` sizes Dumber's own interface—toolbars and modals—and controls the density at which the off-screen bridge renders. It does not resize web content, so the page keeps one CSS pixel per GTK logical pixel on every output scale. Use `default_webpage_zoom` or per-site zoom to make pages larger or smaller. | |
| 303 | + | ||
| 302 | 304 | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `320.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | |
| 303 | 305 | ||
| 304 | 306 | ### Legacy key migration | |
| @@ -299,6 +299,8 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via | |||||
| 299 | 299 | ||||
| 300 | `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed. | 300 | `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed. | ||
| 301 | 301 | ||||
| 302 | + | With the CEF engine, `default_ui_scale` sizes Dumber's own interface—toolbars and modals—and controls the density at which the off-screen bridge renders. It does not resize web content, so the page keeps one CSS pixel per GTK logical pixel on every output scale. Use `default_webpage_zoom` or per-site zoom to make pages larger or smaller. | |||
| 303 | + | ||||
| 302 | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `320.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | 304 | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `320.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | ||
| 303 | 305 | ||||
| 304 | ### Legacy key migration | 306 | ### Legacy key migration | ||
M go.mod +1 -1
| @@ -9,7 +9,7 @@ require ( | |||
| 9 | 9 | github.com/andybalholm/brotli v1.2.3 | |
| 10 | 10 | github.com/bnema/purego v0.11.0-bnema.4 | |
| 11 | 11 | github.com/bnema/purego-cef v0.14.2 | |
| 12 | - | github.com/bnema/purego-cef2gtk v0.10.1 | |
| 12 | + | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8 | |
| 13 | 13 | github.com/bnema/purego-pipewire v0.1.6 | |
| 14 | 14 | github.com/bnema/purego-sqlite v0.1.5 | |
| 15 | 15 | github.com/bnema/purego-webp v0.2.1 | |
| @@ -9,7 +9,7 @@ require ( | |||||
| 9 | github.com/andybalholm/brotli v1.2.3 | 9 | github.com/andybalholm/brotli v1.2.3 | ||
| 10 | github.com/bnema/purego v0.11.0-bnema.4 | 10 | github.com/bnema/purego v0.11.0-bnema.4 | ||
| 11 | github.com/bnema/purego-cef v0.14.2 | 11 | github.com/bnema/purego-cef v0.14.2 | ||
| 12 | - | github.com/bnema/purego-cef2gtk v0.10.1 | 12 | + | github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8 |
| 13 | github.com/bnema/purego-pipewire v0.1.6 | 13 | github.com/bnema/purego-pipewire v0.1.6 | ||
| 14 | github.com/bnema/purego-sqlite v0.1.5 | 14 | github.com/bnema/purego-sqlite v0.1.5 | ||
| 15 | github.com/bnema/purego-webp v0.2.1 | 15 | github.com/bnema/purego-webp v0.2.1 | ||
M go.sum +6 -2 generated file: collapsed
M internal/domain/entity/zoom.go +13 -3
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package entity | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // ZoomLevel represents the zoom factor for a specific domain. | |
| 6 | 9 | // Allows users to set persistent zoom levels per-site. | |
| @@ -53,9 +56,16 @@ func (z *ZoomLevel) IsDefault() bool { | |||
| 53 | 56 | return z.ZoomFactor == ZoomDefault | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | - | // Percentage returns the zoom factor as a percentage (e.g., 150 for 1.5). | |
| 59 | + | // ZoomPercentage returns a zoom factor as a whole percentage (e.g., 150 for | |
| 60 | + | // 1.5). Accumulated floating-point error is rounded away so a factor that equals | |
| 61 | + | // the default zoom in intent (0.9999999999999997) displays as 100, not 99. | |
| 62 | + | func ZoomPercentage(factor float64) int { | |
| 63 | + | return int(math.Round(factor * 100)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Percentage returns the zoom factor as a whole percentage. | |
| 57 | 67 | func (z *ZoomLevel) Percentage() int { | |
| 58 | - | return int(z.ZoomFactor * 100) | |
| 68 | + | return ZoomPercentage(z.ZoomFactor) | |
| 59 | 69 | } | |
| 60 | 70 | ||
| 61 | 71 | // clampZoom constrains a zoom factor to the valid range. | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package entity | 1 | package entity | ||
| 2 | 2 | ||||
| 3 | - | import "time" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // ZoomLevel represents the zoom factor for a specific domain. | 8 | // ZoomLevel represents the zoom factor for a specific domain. | ||
| 6 | // Allows users to set persistent zoom levels per-site. | 9 | // Allows users to set persistent zoom levels per-site. | ||
| @@ -53,9 +56,16 @@ func (z *ZoomLevel) IsDefault() bool { | |||||
| 53 | return z.ZoomFactor == ZoomDefault | 56 | return z.ZoomFactor == ZoomDefault | ||
| 54 | } | 57 | } | ||
| 55 | 58 | ||||
| 56 | - | // Percentage returns the zoom factor as a percentage (e.g., 150 for 1.5). | 59 | + | // ZoomPercentage returns a zoom factor as a whole percentage (e.g., 150 for |
| 60 | + | // 1.5). Accumulated floating-point error is rounded away so a factor that equals | |||
| 61 | + | // the default zoom in intent (0.9999999999999997) displays as 100, not 99. | |||
| 62 | + | func ZoomPercentage(factor float64) int { | |||
| 63 | + | return int(math.Round(factor * 100)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Percentage returns the zoom factor as a whole percentage. | |||
| 57 | func (z *ZoomLevel) Percentage() int { | 67 | func (z *ZoomLevel) Percentage() int { | ||
| 58 | - | return int(z.ZoomFactor * 100) | 68 | + | return ZoomPercentage(z.ZoomFactor) |
| 59 | } | 69 | } | ||
| 60 | 70 | ||||
| 61 | // clampZoom constrains a zoom factor to the valid range. | 71 | // clampZoom constrains a zoom factor to the valid range. | ||
A internal/domain/entity/zoom_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestZoomPercentageRoundsDisplayedValue(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | factor float64 | |
| 12 | + | want int | |
| 13 | + | }{ | |
| 14 | + | {name: "default_zoom", factor: 1.0, want: 100}, | |
| 15 | + | {name: "accumulated_default_error", factor: 0.9999999999999997, want: 100}, | |
| 16 | + | {name: "accumulated_above_default", factor: 1.0000000000000002, want: 100}, | |
| 17 | + | {name: "ordinary_percent", factor: 1.5, want: 150}, | |
| 18 | + | {name: "minimum_zoom", factor: ZoomMin, want: 25}, | |
| 19 | + | {name: "maximum_zoom", factor: ZoomMax, want: 500}, | |
| 20 | + | {name: "rounds_up", factor: 1.006, want: 101}, | |
| 21 | + | {name: "rounds_down", factor: 1.004, want: 100}, | |
| 22 | + | {name: "three_digit_percent_below_half", factor: 2.344, want: 234}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | zoom := NewZoomLevel("example.com", tt.factor) | |
| 28 | + | if got := zoom.Percentage(); got != tt.want { | |
| 29 | + | t.Fatalf("Percentage()=%d, want %d for factor %.16f", got, tt.want, tt.factor) | |
| 30 | + | } | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestZoomPercentageSurvivesRepeatedSteps(t *testing.T) { | |
| 36 | + | zoom := NewZoomLevel("example.com", ZoomDefault) | |
| 37 | + | ||
| 38 | + | for range 10 { | |
| 39 | + | zoom.ZoomIn() | |
| 40 | + | } | |
| 41 | + | if got := zoom.Percentage(); got != 200 { | |
| 42 | + | t.Fatalf("after 10 zoom-in steps Percentage()=%d, want 200", got) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | for range 10 { | |
| 46 | + | zoom.ZoomOut() | |
| 47 | + | } | |
| 48 | + | if got := zoom.Percentage(); got != 100 { | |
| 49 | + | t.Fatalf("after 10 zoom-out steps Percentage()=%d, want 100", got) | |
| 50 | + | } | |
| 51 | + | if math.Abs(zoom.ZoomFactor-ZoomDefault) > 1e-9 { | |
| 52 | + | t.Fatalf("persisted factor drifted to %.16f, want default", zoom.ZoomFactor) | |
| 53 | + | } | |
| 54 | + | if !zoom.IsDefault() { | |
| 55 | + | t.Fatal("factor behind a 100%% display must still compare equal to the default zoom") | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestZoomPercentageRoundsDisplayedValue(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | factor float64 | |||
| 12 | + | want int | |||
| 13 | + | }{ | |||
| 14 | + | {name: "default_zoom", factor: 1.0, want: 100}, | |||
| 15 | + | {name: "accumulated_default_error", factor: 0.9999999999999997, want: 100}, | |||
| 16 | + | {name: "accumulated_above_default", factor: 1.0000000000000002, want: 100}, | |||
| 17 | + | {name: "ordinary_percent", factor: 1.5, want: 150}, | |||
| 18 | + | {name: "minimum_zoom", factor: ZoomMin, want: 25}, | |||
| 19 | + | {name: "maximum_zoom", factor: ZoomMax, want: 500}, | |||
| 20 | + | {name: "rounds_up", factor: 1.006, want: 101}, | |||
| 21 | + | {name: "rounds_down", factor: 1.004, want: 100}, | |||
| 22 | + | {name: "three_digit_percent_below_half", factor: 2.344, want: 234}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | zoom := NewZoomLevel("example.com", tt.factor) | |||
| 28 | + | if got := zoom.Percentage(); got != tt.want { | |||
| 29 | + | t.Fatalf("Percentage()=%d, want %d for factor %.16f", got, tt.want, tt.factor) | |||
| 30 | + | } | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestZoomPercentageSurvivesRepeatedSteps(t *testing.T) { | |||
| 36 | + | zoom := NewZoomLevel("example.com", ZoomDefault) | |||
| 37 | + | ||||
| 38 | + | for range 10 { | |||
| 39 | + | zoom.ZoomIn() | |||
| 40 | + | } | |||
| 41 | + | if got := zoom.Percentage(); got != 200 { | |||
| 42 | + | t.Fatalf("after 10 zoom-in steps Percentage()=%d, want 200", got) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | for range 10 { | |||
| 46 | + | zoom.ZoomOut() | |||
| 47 | + | } | |||
| 48 | + | if got := zoom.Percentage(); got != 100 { | |||
| 49 | + | t.Fatalf("after 10 zoom-out steps Percentage()=%d, want 100", got) | |||
| 50 | + | } | |||
| 51 | + | if math.Abs(zoom.ZoomFactor-ZoomDefault) > 1e-9 { | |||
| 52 | + | t.Fatalf("persisted factor drifted to %.16f, want default", zoom.ZoomFactor) | |||
| 53 | + | } | |||
| 54 | + | if !zoom.IsDefault() { | |||
| 55 | + | t.Fatal("factor behind a 100%% display must still compare equal to the default zoom") | |||
| 56 | + | } | |||
| 57 | + | } | |||
M internal/infrastructure/cef/cef2gtk_adapter.go +17 -0
| @@ -123,6 +123,23 @@ func (a *Cef2gtkAdapter) OSRBackingScaleFactor() float64 { | |||
| 123 | 123 | return cef2gtk.OSRBackingScaleFactorForScale(float64(a.view.DeviceScaleFactor())) | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | + | // PageZoomCompensation returns the factor Dumber must multiply user page zoom by | |
| 127 | + | // before applying it through CEF, so the page's CSS viewport keeps matching the | |
| 128 | + | // GTK logical view size under the bridge's active OSR geometry contract. It | |
| 129 | + | // returns 1 for a nil or destroyed adapter and whenever CEF's normal logical OSR | |
| 130 | + | // contract is in effect. | |
| 131 | + | func (a *Cef2gtkAdapter) PageZoomCompensation() float64 { | |
| 132 | + | if a == nil || a.destroyed.Load() { | |
| 133 | + | return 1 | |
| 134 | + | } | |
| 135 | + | a.viewMu.RLock() | |
| 136 | + | defer a.viewMu.RUnlock() | |
| 137 | + | if a.view == nil { | |
| 138 | + | return 1 | |
| 139 | + | } | |
| 140 | + | return a.view.PageZoomCompensation() | |
| 141 | + | } | |
| 142 | + | ||
| 126 | 143 | // AddSizeObserver registers a positive-size change callback. Register and | |
| 127 | 144 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | |
| 128 | 145 | // GTK size notifications. | |
| @@ -123,6 +123,23 @@ func (a *Cef2gtkAdapter) OSRBackingScaleFactor() float64 { | |||||
| 123 | return cef2gtk.OSRBackingScaleFactorForScale(float64(a.view.DeviceScaleFactor())) | 123 | return cef2gtk.OSRBackingScaleFactorForScale(float64(a.view.DeviceScaleFactor())) | ||
| 124 | } | 124 | } | ||
| 125 | 125 | ||||
| 126 | + | // PageZoomCompensation returns the factor Dumber must multiply user page zoom by | |||
| 127 | + | // before applying it through CEF, so the page's CSS viewport keeps matching the | |||
| 128 | + | // GTK logical view size under the bridge's active OSR geometry contract. It | |||
| 129 | + | // returns 1 for a nil or destroyed adapter and whenever CEF's normal logical OSR | |||
| 130 | + | // contract is in effect. | |||
| 131 | + | func (a *Cef2gtkAdapter) PageZoomCompensation() float64 { | |||
| 132 | + | if a == nil || a.destroyed.Load() { | |||
| 133 | + | return 1 | |||
| 134 | + | } | |||
| 135 | + | a.viewMu.RLock() | |||
| 136 | + | defer a.viewMu.RUnlock() | |||
| 137 | + | if a.view == nil { | |||
| 138 | + | return 1 | |||
| 139 | + | } | |||
| 140 | + | return a.view.PageZoomCompensation() | |||
| 141 | + | } | |||
| 142 | + | ||||
| 126 | // AddSizeObserver registers a positive-size change callback. Register and | 143 | // AddSizeObserver registers a positive-size change callback. Register and | ||
| 127 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | 144 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | ||
| 128 | // GTK size notifications. | 145 | // GTK size notifications. | ||
M internal/infrastructure/cef/factory.go +1 -0
| @@ -127,6 +127,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||
| 127 | 127 | engine: f.engine, | |
| 128 | 128 | factory: f, | |
| 129 | 129 | viewBridge: viewBridge, | |
| 130 | + | zoomCompensation: viewBridge, | |
| 130 | 131 | audioOutputFactory: f.audioOutputFactory, | |
| 131 | 132 | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, | |
| 132 | 133 | windowlessFrameRate: f.windowlessFrameRate, | |
| @@ -127,6 +127,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 127 | engine: f.engine, | 127 | engine: f.engine, | ||
| 128 | factory: f, | 128 | factory: f, | ||
| 129 | viewBridge: viewBridge, | 129 | viewBridge: viewBridge, | ||
| 130 | + | zoomCompensation: viewBridge, | |||
| 130 | audioOutputFactory: f.audioOutputFactory, | 131 | audioOutputFactory: f.audioOutputFactory, | ||
| 131 | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, | 132 | adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate, | ||
| 132 | windowlessFrameRate: f.windowlessFrameRate, | 133 | windowlessFrameRate: f.windowlessFrameRate, | ||
M internal/infrastructure/cef/scale_probe.go +20 -20
| @@ -8,12 +8,12 @@ import ( | |||
| 8 | 8 | const scaleProbeRoundFactor = 1_000_000 | |
| 9 | 9 | ||
| 10 | 10 | type cefScaleProbeMetrics struct { | |
| 11 | - | SurfaceWidth int32 | |
| 12 | - | SurfaceHeight int32 | |
| 13 | - | SurfaceScale float64 | |
| 14 | - | OSRBackingScale float64 | |
| 15 | - | UserZoom float64 | |
| 16 | - | InternalCEFFactor float64 | |
| 11 | + | SurfaceWidth int32 | |
| 12 | + | SurfaceHeight int32 | |
| 13 | + | SurfaceScale float64 | |
| 14 | + | PageZoomCompensation float64 | |
| 15 | + | UserZoom float64 | |
| 16 | + | InternalCEFFactor float64 | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool { | |
| @@ -29,20 +29,20 @@ func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool { | |||
| 29 | 29 | ||
| 30 | 30 | func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics { | |
| 31 | 31 | m := cefScaleProbeMetrics{ | |
| 32 | - | SurfaceWidth: 1, | |
| 33 | - | SurfaceHeight: 1, | |
| 34 | - | SurfaceScale: 1, | |
| 35 | - | OSRBackingScale: 1, | |
| 36 | - | UserZoom: 1, | |
| 37 | - | InternalCEFFactor: 1, | |
| 32 | + | SurfaceWidth: 1, | |
| 33 | + | SurfaceHeight: 1, | |
| 34 | + | SurfaceScale: 1, | |
| 35 | + | PageZoomCompensation: 1, | |
| 36 | + | UserZoom: 1, | |
| 37 | + | InternalCEFFactor: 1, | |
| 38 | 38 | } | |
| 39 | 39 | if wv == nil { | |
| 40 | 40 | return m | |
| 41 | 41 | } | |
| 42 | 42 | m.UserZoom = normalizeScale(wv.GetZoomLevel()) | |
| 43 | - | m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor()) | |
| 43 | + | m.PageZoomCompensation = wv.pageZoomCompensation() | |
| 44 | 44 | m.SurfaceScale = normalizeScale(wv.viewBridgeScale()) | |
| 45 | - | m.InternalCEFFactor = m.UserZoom * zoomScaleRatio(m.SurfaceScale, m.OSRBackingScale) | |
| 45 | + | m.InternalCEFFactor = m.UserZoom * m.PageZoomCompensation | |
| 46 | 46 | if wv.viewBridge != nil { | |
| 47 | 47 | m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size() | |
| 48 | 48 | } | |
| @@ -51,12 +51,12 @@ func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics { | |||
| 51 | 51 | ||
| 52 | 52 | func (m cefScaleProbeMetrics) logFields() map[string]any { | |
| 53 | 53 | return map[string]any{ | |
| 54 | - | "surface_width": m.SurfaceWidth, | |
| 55 | - | "surface_height": m.SurfaceHeight, | |
| 56 | - | "surface_scale": roundedScale(m.SurfaceScale), | |
| 57 | - | "osr_backing_scale": roundedScale(m.OSRBackingScale), | |
| 58 | - | "user_zoom": roundedScale(m.UserZoom), | |
| 59 | - | "internal_cef_factor": roundedScale(m.InternalCEFFactor), | |
| 54 | + | "surface_width": m.SurfaceWidth, | |
| 55 | + | "surface_height": m.SurfaceHeight, | |
| 56 | + | "surface_scale": roundedScale(m.SurfaceScale), | |
| 57 | + | "page_zoom_compensation": roundedScale(m.PageZoomCompensation), | |
| 58 | + | "user_zoom": roundedScale(m.UserZoom), | |
| 59 | + | "internal_cef_factor": roundedScale(m.InternalCEFFactor), | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | 62 | ||
| @@ -8,12 +8,12 @@ import ( | |||||
| 8 | const scaleProbeRoundFactor = 1_000_000 | 8 | const scaleProbeRoundFactor = 1_000_000 | ||
| 9 | 9 | ||||
| 10 | type cefScaleProbeMetrics struct { | 10 | type cefScaleProbeMetrics struct { | ||
| 11 | - | SurfaceWidth int32 | 11 | + | SurfaceWidth int32 |
| 12 | - | SurfaceHeight int32 | 12 | + | SurfaceHeight int32 |
| 13 | - | SurfaceScale float64 | 13 | + | SurfaceScale float64 |
| 14 | - | OSRBackingScale float64 | 14 | + | PageZoomCompensation float64 |
| 15 | - | UserZoom float64 | 15 | + | UserZoom float64 |
| 16 | - | InternalCEFFactor float64 | 16 | + | InternalCEFFactor float64 |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool { | 19 | func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool { | ||
| @@ -29,20 +29,20 @@ func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool { | |||||
| 29 | 29 | ||||
| 30 | func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics { | 30 | func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics { | ||
| 31 | m := cefScaleProbeMetrics{ | 31 | m := cefScaleProbeMetrics{ | ||
| 32 | - | SurfaceWidth: 1, | 32 | + | SurfaceWidth: 1, |
| 33 | - | SurfaceHeight: 1, | 33 | + | SurfaceHeight: 1, |
| 34 | - | SurfaceScale: 1, | 34 | + | SurfaceScale: 1, |
| 35 | - | OSRBackingScale: 1, | 35 | + | PageZoomCompensation: 1, |
| 36 | - | UserZoom: 1, | 36 | + | UserZoom: 1, |
| 37 | - | InternalCEFFactor: 1, | 37 | + | InternalCEFFactor: 1, |
| 38 | } | 38 | } | ||
| 39 | if wv == nil { | 39 | if wv == nil { | ||
| 40 | return m | 40 | return m | ||
| 41 | } | 41 | } | ||
| 42 | m.UserZoom = normalizeScale(wv.GetZoomLevel()) | 42 | m.UserZoom = normalizeScale(wv.GetZoomLevel()) | ||
| 43 | - | m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor()) | 43 | + | m.PageZoomCompensation = wv.pageZoomCompensation() |
| 44 | m.SurfaceScale = normalizeScale(wv.viewBridgeScale()) | 44 | m.SurfaceScale = normalizeScale(wv.viewBridgeScale()) | ||
| 45 | - | m.InternalCEFFactor = m.UserZoom * zoomScaleRatio(m.SurfaceScale, m.OSRBackingScale) | 45 | + | m.InternalCEFFactor = m.UserZoom * m.PageZoomCompensation |
| 46 | if wv.viewBridge != nil { | 46 | if wv.viewBridge != nil { | ||
| 47 | m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size() | 47 | m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size() | ||
| 48 | } | 48 | } | ||
| @@ -51,12 +51,12 @@ func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics { | |||||
| 51 | 51 | ||||
| 52 | func (m cefScaleProbeMetrics) logFields() map[string]any { | 52 | func (m cefScaleProbeMetrics) logFields() map[string]any { | ||
| 53 | return map[string]any{ | 53 | return map[string]any{ | ||
| 54 | - | "surface_width": m.SurfaceWidth, | 54 | + | "surface_width": m.SurfaceWidth, |
| 55 | - | "surface_height": m.SurfaceHeight, | 55 | + | "surface_height": m.SurfaceHeight, |
| 56 | - | "surface_scale": roundedScale(m.SurfaceScale), | 56 | + | "surface_scale": roundedScale(m.SurfaceScale), |
| 57 | - | "osr_backing_scale": roundedScale(m.OSRBackingScale), | 57 | + | "page_zoom_compensation": roundedScale(m.PageZoomCompensation), |
| 58 | - | "user_zoom": roundedScale(m.UserZoom), | 58 | + | "user_zoom": roundedScale(m.UserZoom), |
| 59 | - | "internal_cef_factor": roundedScale(m.InternalCEFFactor), | 59 | + | "internal_cef_factor": roundedScale(m.InternalCEFFactor), |
| 60 | } | 60 | } | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
M internal/infrastructure/cef/scale_probe_test.go +26 -2
| @@ -40,10 +40,34 @@ func TestCEFScaleProbeSnapshotNormalizesMissingBridge(t *testing.T) { | |||
| 40 | 40 | if s.SurfaceWidth != 1 || s.SurfaceHeight != 1 { | |
| 41 | 41 | t.Fatalf("surface size = %dx%d, want 1x1", s.SurfaceWidth, s.SurfaceHeight) | |
| 42 | 42 | } | |
| 43 | - | if s.SurfaceScale != 1 || s.OSRBackingScale != 1 { | |
| 44 | - | t.Fatalf("scales = surface %v backing %v, want 1/1", s.SurfaceScale, s.OSRBackingScale) | |
| 43 | + | if s.SurfaceScale != 1 || s.PageZoomCompensation != 1 { | |
| 44 | + | t.Fatalf("scales = surface %v compensation %v, want 1/1", s.SurfaceScale, s.PageZoomCompensation) | |
| 45 | 45 | } | |
| 46 | 46 | if s.UserZoom != 1 || s.InternalCEFFactor != 1 { | |
| 47 | 47 | t.Fatalf("zoom = user %v internal %v, want 1/1", s.UserZoom, s.InternalCEFFactor) | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | + | ||
| 51 | + | func TestCEFScaleProbeSnapshotDerivesInternalFactorFromContract(t *testing.T) { | |
| 52 | + | wv := &WebView{zoomCompensation: zoomCompensationStub{compensation: 1.75}} | |
| 53 | + | wv.zoomFactor.Store(1.3) | |
| 54 | + | ||
| 55 | + | s := cefScaleProbeSnapshot(wv) | |
| 56 | + | ||
| 57 | + | if s.UserZoom != 1.3 { | |
| 58 | + | t.Fatalf("user zoom = %v, want 1.3", s.UserZoom) | |
| 59 | + | } | |
| 60 | + | if s.PageZoomCompensation != 1.75 { | |
| 61 | + | t.Fatalf("compensation = %v, want 1.75", s.PageZoomCompensation) | |
| 62 | + | } | |
| 63 | + | if s.InternalCEFFactor != 2.275 { | |
| 64 | + | t.Fatalf("internal CEF factor = %v, want user zoom x compensation = 2.275", s.InternalCEFFactor) | |
| 65 | + | } | |
| 66 | + | fields := s.logFields() | |
| 67 | + | if _, ok := fields["page_zoom_compensation"]; !ok { | |
| 68 | + | t.Fatalf("probe fields %v missing page_zoom_compensation", fields) | |
| 69 | + | } | |
| 70 | + | if _, ok := fields["osr_backing_scale"]; ok { | |
| 71 | + | t.Fatalf("probe fields %v must not carry a separate backing-scale formula", fields) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -40,10 +40,34 @@ func TestCEFScaleProbeSnapshotNormalizesMissingBridge(t *testing.T) { | |||||
| 40 | if s.SurfaceWidth != 1 || s.SurfaceHeight != 1 { | 40 | if s.SurfaceWidth != 1 || s.SurfaceHeight != 1 { | ||
| 41 | t.Fatalf("surface size = %dx%d, want 1x1", s.SurfaceWidth, s.SurfaceHeight) | 41 | t.Fatalf("surface size = %dx%d, want 1x1", s.SurfaceWidth, s.SurfaceHeight) | ||
| 42 | } | 42 | } | ||
| 43 | - | if s.SurfaceScale != 1 || s.OSRBackingScale != 1 { | 43 | + | if s.SurfaceScale != 1 || s.PageZoomCompensation != 1 { |
| 44 | - | t.Fatalf("scales = surface %v backing %v, want 1/1", s.SurfaceScale, s.OSRBackingScale) | 44 | + | t.Fatalf("scales = surface %v compensation %v, want 1/1", s.SurfaceScale, s.PageZoomCompensation) |
| 45 | } | 45 | } | ||
| 46 | if s.UserZoom != 1 || s.InternalCEFFactor != 1 { | 46 | if s.UserZoom != 1 || s.InternalCEFFactor != 1 { | ||
| 47 | t.Fatalf("zoom = user %v internal %v, want 1/1", s.UserZoom, s.InternalCEFFactor) | 47 | t.Fatalf("zoom = user %v internal %v, want 1/1", s.UserZoom, s.InternalCEFFactor) | ||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
| 50 | + | ||||
| 51 | + | func TestCEFScaleProbeSnapshotDerivesInternalFactorFromContract(t *testing.T) { | |||
| 52 | + | wv := &WebView{zoomCompensation: zoomCompensationStub{compensation: 1.75}} | |||
| 53 | + | wv.zoomFactor.Store(1.3) | |||
| 54 | + | ||||
| 55 | + | s := cefScaleProbeSnapshot(wv) | |||
| 56 | + | ||||
| 57 | + | if s.UserZoom != 1.3 { | |||
| 58 | + | t.Fatalf("user zoom = %v, want 1.3", s.UserZoom) | |||
| 59 | + | } | |||
| 60 | + | if s.PageZoomCompensation != 1.75 { | |||
| 61 | + | t.Fatalf("compensation = %v, want 1.75", s.PageZoomCompensation) | |||
| 62 | + | } | |||
| 63 | + | if s.InternalCEFFactor != 2.275 { | |||
| 64 | + | t.Fatalf("internal CEF factor = %v, want user zoom x compensation = 2.275", s.InternalCEFFactor) | |||
| 65 | + | } | |||
| 66 | + | fields := s.logFields() | |||
| 67 | + | if _, ok := fields["page_zoom_compensation"]; !ok { | |||
| 68 | + | t.Fatalf("probe fields %v missing page_zoom_compensation", fields) | |||
| 69 | + | } | |||
| 70 | + | if _, ok := fields["osr_backing_scale"]; ok { | |||
| 71 | + | t.Fatalf("probe fields %v must not carry a separate backing-scale formula", fields) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/infrastructure/cef/testutil_test.go +11 -0
| @@ -94,3 +94,14 @@ func (stubMenuModel) GetColorAt(_ int32, _ purecef.MenuColorType, _ uintptr) int | |||
| 94 | 94 | } | |
| 95 | 95 | func (stubMenuModel) SetFontList(_ int32, _ string) int32 { panic("unexpected call") } | |
| 96 | 96 | func (stubMenuModel) SetFontListAt(_ int32, _ string) int32 { panic("unexpected call") } | |
| 97 | + | ||
| 98 | + | // zoomCompensationStub supplies the OSR page-zoom compensation contract owned by | |
| 99 | + | // the render bridge, so tests can exercise zoom conversion, reapplication, and | |
| 100 | + | // diagnostics without a live GTK/CEF view. | |
| 101 | + | type zoomCompensationStub struct { | |
| 102 | + | compensation float64 | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (s zoomCompensationStub) PageZoomCompensation() float64 { | |
| 106 | + | return s.compensation | |
| 107 | + | } | |
| @@ -94,3 +94,14 @@ func (stubMenuModel) GetColorAt(_ int32, _ purecef.MenuColorType, _ uintptr) int | |||||
| 94 | } | 94 | } | ||
| 95 | func (stubMenuModel) SetFontList(_ int32, _ string) int32 { panic("unexpected call") } | 95 | func (stubMenuModel) SetFontList(_ int32, _ string) int32 { panic("unexpected call") } | ||
| 96 | func (stubMenuModel) SetFontListAt(_ int32, _ string) int32 { panic("unexpected call") } | 96 | func (stubMenuModel) SetFontListAt(_ int32, _ string) int32 { panic("unexpected call") } | ||
| 97 | + | ||||
| 98 | + | // zoomCompensationStub supplies the OSR page-zoom compensation contract owned by | |||
| 99 | + | // the render bridge, so tests can exercise zoom conversion, reapplication, and | |||
| 100 | + | // diagnostics without a live GTK/CEF view. | |||
| 101 | + | type zoomCompensationStub struct { | |||
| 102 | + | compensation float64 | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (s zoomCompensationStub) PageZoomCompensation() float64 { | |||
| 106 | + | return s.compensation | |||
| 107 | + | } | |||
M internal/infrastructure/cef/webview.go +114 -47
| @@ -99,6 +99,14 @@ var ( | |||
| 99 | 99 | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |
| 100 | 100 | ) | |
| 101 | 101 | ||
| 102 | + | // pageZoomCompensationBridge is the narrow bridge surface Dumber reads the OSR | |
| 103 | + | // page-zoom compensation from. The compensation is owned by purego-cef2gtk's OSR | |
| 104 | + | // geometry contract, so Dumber must never derive it from GTK surface or backing | |
| 105 | + | // geometry itself. | |
| 106 | + | type pageZoomCompensationBridge interface { | |
| 107 | + | PageZoomCompensation() float64 | |
| 108 | + | } | |
| 109 | + | ||
| 102 | 110 | // WebView implements port.WebView using a CEF off-screen browser rendered and | |
| 103 | 111 | // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the | |
| 104 | 112 | // bridge owns GTK rendering and input forwarding. | |
| @@ -111,6 +119,7 @@ type WebView struct { | |||
| 111 | 119 | host purecef.BrowserHost | |
| 112 | 120 | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| 113 | 121 | viewBridge *Cef2gtkAdapter | |
| 122 | + | zoomCompensation pageZoomCompensationBridge | |
| 114 | 123 | popupSurface *popupBridgeSurface | |
| 115 | 124 | nativeWidget *gtk.Widget | |
| 116 | 125 | handlers *handlerSet | |
| @@ -283,11 +292,12 @@ type WebView struct { | |||
| 283 | 292 | // bridgeTeardownNoted gates quiescence accounting exactly once per | |
| 284 | 293 | // view: every bridge teardown is scheduled through the Sync/Async | |
| 285 | 294 | // wrappers below and completes in destroyViewBridgeOnGTKThread. | |
| 286 | - | bridgeTeardownNoted atomic.Bool | |
| 287 | - | generation atomic.Uint64 | |
| 288 | - | audioPlaying atomic.Bool | |
| 289 | - | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| 290 | - | lastAppliedZoomScaleRatioBits atomic.Uint64 | |
| 295 | + | bridgeTeardownNoted atomic.Bool | |
| 296 | + | generation atomic.Uint64 | |
| 297 | + | audioPlaying atomic.Bool | |
| 298 | + | zoomFactor atomic.Value // float64, initialized to 1.0 | |
| 299 | + | lastAppliedZoomCompensationBits atomic.Uint64 | |
| 300 | + | zoomApplicationEpoch atomic.Uint64 | |
| 291 | 301 | ||
| 292 | 302 | // Browser creation defaults copied from the factory so native popup shells | |
| 293 | 303 | // can apply the same settings in OnBeforePopup. | |
| @@ -592,23 +602,25 @@ func cefZoomFromFactor(factor float64) float64 { | |||
| 592 | 602 | return math.Log(factor) / math.Log(chromiumZoomBase) | |
| 593 | 603 | } | |
| 594 | 604 | ||
| 595 | - | func zoomScaleRatio(surfaceScale, backingScale float64) float64 { | |
| 596 | - | backingScale = normalizeScale(backingScale) | |
| 597 | - | if backingScale <= 1 { | |
| 598 | - | return 1 | |
| 599 | - | } | |
| 600 | - | return normalizeScale(surfaceScale) / backingScale | |
| 601 | - | } | |
| 602 | - | ||
| 603 | - | func cefZoomFromPageAndScaleFactors(pageZoom, surfaceScale, backingScale float64) float64 { | |
| 604 | - | return cefZoomFromFactor(pageZoom * zoomScaleRatio(surfaceScale, backingScale)) | |
| 605 | + | // cefZoomFromPageZoom converts user page zoom to CEF's logarithmic zoom level by | |
| 606 | + | // folding in the bridge's OSR page-zoom compensation, so one CSS pixel keeps | |
| 607 | + | // matching one GTK logical pixel on every output scale. | |
| 608 | + | func cefZoomFromPageZoom(pageZoom, compensation float64) float64 { | |
| 609 | + | return cefZoomFromFactor(normalizeScale(pageZoom) * normalizeScale(compensation)) | |
| 605 | 610 | } | |
| 606 | 611 | ||
| 607 | - | func pageZoomFromCEFAndScaleLevel(level, surfaceScale, backingScale float64) float64 { | |
| 608 | - | return factorFromCEFZoom(level) / zoomScaleRatio(surfaceScale, backingScale) | |
| 612 | + | // pageZoomFromCEFLevel is the inverse of cefZoomFromPageZoom: it reports the | |
| 613 | + | // user-facing page zoom for a CEF zoom level observed under compensation. | |
| 614 | + | func pageZoomFromCEFLevel(level, compensation float64) float64 { | |
| 615 | + | return factorFromCEFZoom(level) / normalizeScale(compensation) | |
| 609 | 616 | } | |
| 610 | 617 | ||
| 611 | - | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, surfaceScale, backingScale float64) { | |
| 618 | + | // applyZoomForCompensation applies one user page zoom through CEF using the | |
| 619 | + | // compensation of the OSR contract that is active in the bridge. Every zoom | |
| 620 | + | // application goes through this path so the applied compensation is recorded once. | |
| 621 | + | func (wv *WebView) applyZoomForCompensation(host purecef.BrowserHost, pageZoom, compensation float64) { | |
| 622 | + | compensation = normalizeScale(compensation) | |
| 623 | + | cefLevel := cefZoomFromPageZoom(pageZoom, compensation) | |
| 612 | 624 | host.SetZoomLevel(cefLevel) | |
| 613 | 625 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | |
| 614 | 626 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | |
| @@ -617,12 +629,13 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, | |||
| 617 | 629 | // SynchronizeVisualProperties cycle, which makes the renderer produce | |
| 618 | 630 | // a new compositor frame at the new zoom level. | |
| 619 | 631 | host.NotifyScreenInfoChanged() | |
| 620 | - | wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale) | |
| 632 | + | wv.recordAppliedZoomCompensation(compensation) | |
| 633 | + | epoch := wv.zoomApplicationEpoch.Add(1) | |
| 621 | 634 | // Zoom is applied asynchronously in the renderer process. Request a couple | |
| 622 | 635 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | |
| 623 | 636 | // compositor frame after the zoom IPC has been processed. | |
| 624 | 637 | wv.scheduleZoomRefresh() | |
| 625 | - | wv.scheduleZoomReadback(factor, cefLevel) | |
| 638 | + | wv.scheduleZoomReadback(pageZoom, cefLevel, compensation, epoch) | |
| 626 | 639 | } | |
| 627 | 640 | ||
| 628 | 641 | // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a | |
| @@ -651,15 +664,15 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||
| 651 | 664 | return errNoBrowser | |
| 652 | 665 | } | |
| 653 | 666 | surfaceScale := wv.viewBridgeScale() | |
| 654 | - | backingScale := wv.osrBackingScaleFactor() | |
| 655 | - | cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale) | |
| 667 | + | compensation := wv.pageZoomCompensation() | |
| 668 | + | cefLevel := cefZoomFromPageZoom(factor, compensation) | |
| 656 | 669 | logging.FromContext(wv.ctx).Debug(). | |
| 657 | 670 | Float64("factor", factor). | |
| 658 | 671 | Float64("cef_level", cefLevel). | |
| 659 | - | Float64("osr_backing_scale", backingScale). | |
| 672 | + | Float64("page_zoom_compensation", compensation). | |
| 660 | 673 | Float64("surface_scale", surfaceScale). | |
| 661 | 674 | Msg("cef: SetZoomLevel") | |
| 662 | - | wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale) | |
| 675 | + | wv.applyZoomForCompensation(host, factor, compensation) | |
| 663 | 676 | wv.zoomFactor.Store(factor) | |
| 664 | 677 | return nil | |
| 665 | 678 | } | |
| @@ -1376,8 +1389,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||
| 1376 | 1389 | } | |
| 1377 | 1390 | _ = wv.viewBridge.DetachInput() | |
| 1378 | 1391 | _ = wv.viewBridge.Destroy() | |
| 1392 | + | wv.mu.Lock() | |
| 1379 | 1393 | wv.viewBridge = nil | |
| 1394 | + | wv.zoomCompensation = nil | |
| 1380 | 1395 | wv.nativeWidget = nil | |
| 1396 | + | wv.mu.Unlock() | |
| 1381 | 1397 | // The bridge actually tore down here (second arrivals return early on | |
| 1382 | 1398 | // the nil guard above), completing the outstanding teardown exactly once. | |
| 1383 | 1399 | if wv.engine != nil { | |
| @@ -1822,6 +1838,9 @@ func (wv *WebView) viewBridgeScale() float64 { | |||
| 1822 | 1838 | return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor())) | |
| 1823 | 1839 | } | |
| 1824 | 1840 | ||
| 1841 | + | // osrBackingScaleFactor reports the bridge's OSR backing scale used for view, | |
| 1842 | + | // popup, and input geometry. It is 1 when the bridge uses CEF's normal logical | |
| 1843 | + | // OSR contract. | |
| 1825 | 1844 | func (wv *WebView) osrBackingScaleFactor() float64 { | |
| 1826 | 1845 | if wv == nil || wv.viewBridge == nil { | |
| 1827 | 1846 | return 1 | |
| @@ -1829,23 +1848,63 @@ func (wv *WebView) osrBackingScaleFactor() float64 { | |||
| 1829 | 1848 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | |
| 1830 | 1849 | } | |
| 1831 | 1850 | ||
| 1832 | - | func (wv *WebView) recordAppliedZoomScaleRatio(surfaceScale, backingScale float64) { | |
| 1851 | + | // pageZoomCompensation reports the compensation of the OSR geometry contract | |
| 1852 | + | // currently active in the render bridge. It is 1 when the bridge is missing or | |
| 1853 | + | // uses CEF's normal logical OSR contract, and it must never be persisted: user | |
| 1854 | + | // page zoom stays a user-facing value that survives output and density changes. | |
| 1855 | + | func (wv *WebView) pageZoomCompensation() float64 { | |
| 1856 | + | if wv == nil { | |
| 1857 | + | return 1 | |
| 1858 | + | } | |
| 1859 | + | wv.mu.RLock() | |
| 1860 | + | bridge := wv.zoomCompensation | |
| 1861 | + | wv.mu.RUnlock() | |
| 1862 | + | if bridge == nil { | |
| 1863 | + | return 1 | |
| 1864 | + | } | |
| 1865 | + | return normalizeScale(bridge.PageZoomCompensation()) | |
| 1866 | + | } | |
| 1867 | + | ||
| 1868 | + | func (wv *WebView) recordAppliedZoomCompensation(compensation float64) { | |
| 1833 | 1869 | if wv == nil { | |
| 1834 | 1870 | return | |
| 1835 | 1871 | } | |
| 1836 | - | wv.lastAppliedZoomScaleRatioBits.Store(math.Float64bits(zoomScaleRatio(surfaceScale, backingScale))) | |
| 1872 | + | wv.lastAppliedZoomCompensationBits.Store(math.Float64bits(normalizeScale(compensation))) | |
| 1837 | 1873 | } | |
| 1838 | 1874 | ||
| 1839 | - | func (wv *WebView) shouldReapplyZoomForScaleRatio(surfaceScale, backingScale float64) bool { | |
| 1875 | + | func (wv *WebView) clearAppliedZoomCompensation() { | |
| 1840 | 1876 | if wv == nil { | |
| 1841 | - | return false | |
| 1877 | + | return | |
| 1842 | 1878 | } | |
| 1843 | - | ratio := zoomScaleRatio(surfaceScale, backingScale) | |
| 1844 | - | bits := wv.lastAppliedZoomScaleRatioBits.Load() | |
| 1879 | + | wv.lastAppliedZoomCompensationBits.Store(0) | |
| 1880 | + | } | |
| 1881 | + | ||
| 1882 | + | // appliedZoomCompensation returns the compensation of the most recent zoom | |
| 1883 | + | // application, or 0 when no zoom has been applied to the current host. | |
| 1884 | + | func (wv *WebView) appliedZoomCompensation() float64 { | |
| 1885 | + | if wv == nil { | |
| 1886 | + | return 0 | |
| 1887 | + | } | |
| 1888 | + | bits := wv.lastAppliedZoomCompensationBits.Load() | |
| 1845 | 1889 | if bits == 0 { | |
| 1890 | + | return 0 | |
| 1891 | + | } | |
| 1892 | + | return math.Float64frombits(bits) | |
| 1893 | + | } | |
| 1894 | + | ||
| 1895 | + | // shouldReapplyZoomForCompensation reports whether page zoom must be applied | |
| 1896 | + | // again for compensation. An unknown applied compensation always reapplies; an | |
| 1897 | + | // unchanged one never does, so an output transition that keeps the same OSR | |
| 1898 | + | // contract causes no redundant reapplication. | |
| 1899 | + | func (wv *WebView) shouldReapplyZoomForCompensation(compensation float64) bool { | |
| 1900 | + | if wv == nil { | |
| 1901 | + | return false | |
| 1902 | + | } | |
| 1903 | + | applied := wv.appliedZoomCompensation() | |
| 1904 | + | if applied == 0 { | |
| 1846 | 1905 | return true | |
| 1847 | 1906 | } | |
| 1848 | - | return math.Float64frombits(bits) != ratio | |
| 1907 | + | return applied != normalizeScale(compensation) | |
| 1849 | 1908 | } | |
| 1850 | 1909 | ||
| 1851 | 1910 | func (wv *WebView) handleMiddleClickFromBridge() bool { | |
| @@ -2287,10 +2346,18 @@ func (wv *WebView) scheduleZoomRefresh() { | |||
| 2287 | 2346 | // process the zoom IPC and reflect the new level back to the browser. | |
| 2288 | 2347 | // | |
| 2289 | 2348 | // These values are empirically tuned for CEF's async zoom application. | |
| 2290 | - | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |
| 2349 | + | // | |
| 2350 | + | // Both readbacks are decoded with the compensation of the application they | |
| 2351 | + | // diagnose and are skipped when a newer application superseded it, so a stale | |
| 2352 | + | // CEF zoom level is never relabelled with a newer compensation. | |
| 2353 | + | func (wv *WebView) scheduleZoomReadback(expectedPageZoom, expectedLevel, compensation float64, epoch uint64) { | |
| 2354 | + | compensation = normalizeScale(compensation) | |
| 2291 | 2355 | for _, delayMs := range [...]int64{0, 64} { | |
| 2292 | 2356 | task := cefNewTask(cefTaskFunc(func() { | |
| 2293 | - | if wv.destroyed.Load() { | |
| 2357 | + | if wv.destroyed.Load() || wv.zoomApplicationEpoch.Load() != epoch { | |
| 2358 | + | return | |
| 2359 | + | } | |
| 2360 | + | if wv.appliedZoomCompensation() != compensation { | |
| 2294 | 2361 | return | |
| 2295 | 2362 | } | |
| 2296 | 2363 | wv.mu.RLock() | |
| @@ -2300,16 +2367,15 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||
| 2300 | 2367 | return | |
| 2301 | 2368 | } | |
| 2302 | 2369 | surfaceScale := wv.viewBridgeScale() | |
| 2303 | - | backingScale := wv.osrBackingScaleFactor() | |
| 2304 | 2370 | actualLevel := host.GetZoomLevel() | |
| 2305 | 2371 | logging.FromContext(wv.ctx).Debug(). | |
| 2306 | 2372 | Int64("delay_ms", delayMs). | |
| 2307 | - | Float64("expected_factor", expectedFactor). | |
| 2373 | + | Float64("expected_factor", expectedPageZoom). | |
| 2308 | 2374 | Float64("expected_cef_level", expectedLevel). | |
| 2309 | - | Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)). | |
| 2375 | + | Float64("actual_factor", pageZoomFromCEFLevel(actualLevel, compensation)). | |
| 2310 | 2376 | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | |
| 2311 | 2377 | Float64("actual_cef_level", actualLevel). | |
| 2312 | - | Float64("osr_backing_scale", backingScale). | |
| 2378 | + | Float64("page_zoom_compensation", compensation). | |
| 2313 | 2379 | Float64("surface_scale", surfaceScale). | |
| 2314 | 2380 | Msg("cef: zoom level readback") | |
| 2315 | 2381 | })) | |
| @@ -2320,6 +2386,10 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||
| 2320 | 2386 | } | |
| 2321 | 2387 | } | |
| 2322 | 2388 | ||
| 2389 | + | // reapplyCurrentZoomForBackingScale reapplies the current user page zoom after | |
| 2390 | + | // the bridge reported a different OSR page-zoom compensation, so the CSS | |
| 2391 | + | // viewport keeps matching the GTK logical view size. User page zoom itself is | |
| 2392 | + | // never mutated by an output transition. | |
| 2323 | 2393 | func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |
| 2324 | 2394 | if wv == nil || wv.destroyed.Load() { | |
| 2325 | 2395 | return | |
| @@ -2330,18 +2400,15 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |||
| 2330 | 2400 | if host == nil { | |
| 2331 | 2401 | return | |
| 2332 | 2402 | } | |
| 2333 | - | factor := wv.GetZoomLevel() | |
| 2334 | - | surfaceScale := wv.viewBridgeScale() | |
| 2335 | - | backingScale := wv.osrBackingScaleFactor() | |
| 2336 | - | cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale) | |
| 2337 | - | wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale) | |
| 2403 | + | pageZoom := wv.GetZoomLevel() | |
| 2404 | + | compensation := wv.pageZoomCompensation() | |
| 2405 | + | wv.applyZoomForCompensation(host, pageZoom, compensation) | |
| 2338 | 2406 | logging.FromContext(wv.ctx).Debug(). | |
| 2339 | 2407 | Str("reason", reason). | |
| 2340 | - | Float64("factor", factor). | |
| 2341 | - | Float64("cef_level", cefLevel). | |
| 2342 | - | Float64("osr_backing_scale", backingScale). | |
| 2343 | - | Float64("surface_scale", surfaceScale). | |
| 2344 | - | Msg("cef: reapplied zoom after OSR backing scale change") | |
| 2408 | + | Float64("factor", pageZoom). | |
| 2409 | + | Float64("cef_level", cefZoomFromPageZoom(pageZoom, compensation)). | |
| 2410 | + | Float64("page_zoom_compensation", compensation). | |
| 2411 | + | Msg("cef: reapplied zoom after OSR page-zoom compensation change") | |
| 2345 | 2412 | } | |
| 2346 | 2413 | ||
| 2347 | 2414 | func (wv *WebView) scheduleStartBeginFrameLoop() { | |
| @@ -99,6 +99,14 @@ var ( | |||||
| 99 | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | 99 | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | ||
| 100 | ) | 100 | ) | ||
| 101 | 101 | ||||
| 102 | + | // pageZoomCompensationBridge is the narrow bridge surface Dumber reads the OSR | |||
| 103 | + | // page-zoom compensation from. The compensation is owned by purego-cef2gtk's OSR | |||
| 104 | + | // geometry contract, so Dumber must never derive it from GTK surface or backing | |||
| 105 | + | // geometry itself. | |||
| 106 | + | type pageZoomCompensationBridge interface { | |||
| 107 | + | PageZoomCompensation() float64 | |||
| 108 | + | } | |||
| 109 | + | ||||
| 102 | // WebView implements port.WebView using a CEF off-screen browser rendered and | 110 | // WebView implements port.WebView using a CEF off-screen browser rendered and | ||
| 103 | // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the | 111 | // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the | ||
| 104 | // bridge owns GTK rendering and input forwarding. | 112 | // bridge owns GTK rendering and input forwarding. | ||
| @@ -111,6 +119,7 @@ type WebView struct { | |||||
| 111 | host purecef.BrowserHost | 119 | host purecef.BrowserHost | ||
| 112 | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | 120 | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | ||
| 113 | viewBridge *Cef2gtkAdapter | 121 | viewBridge *Cef2gtkAdapter | ||
| 122 | + | zoomCompensation pageZoomCompensationBridge | |||
| 114 | popupSurface *popupBridgeSurface | 123 | popupSurface *popupBridgeSurface | ||
| 115 | nativeWidget *gtk.Widget | 124 | nativeWidget *gtk.Widget | ||
| 116 | handlers *handlerSet | 125 | handlers *handlerSet | ||
| @@ -283,11 +292,12 @@ type WebView struct { | |||||
| 283 | // bridgeTeardownNoted gates quiescence accounting exactly once per | 292 | // bridgeTeardownNoted gates quiescence accounting exactly once per | ||
| 284 | // view: every bridge teardown is scheduled through the Sync/Async | 293 | // view: every bridge teardown is scheduled through the Sync/Async | ||
| 285 | // wrappers below and completes in destroyViewBridgeOnGTKThread. | 294 | // wrappers below and completes in destroyViewBridgeOnGTKThread. | ||
| 286 | - | bridgeTeardownNoted atomic.Bool | 295 | + | bridgeTeardownNoted atomic.Bool |
| 287 | - | generation atomic.Uint64 | 296 | + | generation atomic.Uint64 |
| 288 | - | audioPlaying atomic.Bool | 297 | + | audioPlaying atomic.Bool |
| 289 | - | zoomFactor atomic.Value // float64, initialized to 1.0 | 298 | + | zoomFactor atomic.Value // float64, initialized to 1.0 |
| 290 | - | lastAppliedZoomScaleRatioBits atomic.Uint64 | 299 | + | lastAppliedZoomCompensationBits atomic.Uint64 |
| 300 | + | zoomApplicationEpoch atomic.Uint64 | |||
| 291 | 301 | ||||
| 292 | // Browser creation defaults copied from the factory so native popup shells | 302 | // Browser creation defaults copied from the factory so native popup shells | ||
| 293 | // can apply the same settings in OnBeforePopup. | 303 | // can apply the same settings in OnBeforePopup. | ||
| @@ -592,23 +602,25 @@ func cefZoomFromFactor(factor float64) float64 { | |||||
| 592 | return math.Log(factor) / math.Log(chromiumZoomBase) | 602 | return math.Log(factor) / math.Log(chromiumZoomBase) | ||
| 593 | } | 603 | } | ||
| 594 | 604 | ||||
| 595 | - | func zoomScaleRatio(surfaceScale, backingScale float64) float64 { | 605 | + | // cefZoomFromPageZoom converts user page zoom to CEF's logarithmic zoom level by |
| 596 | - | backingScale = normalizeScale(backingScale) | 606 | + | // folding in the bridge's OSR page-zoom compensation, so one CSS pixel keeps |
| 597 | - | if backingScale <= 1 { | 607 | + | // matching one GTK logical pixel on every output scale. |
| 598 | - | return 1 | 608 | + | func cefZoomFromPageZoom(pageZoom, compensation float64) float64 { |
| 599 | - | } | 609 | + | return cefZoomFromFactor(normalizeScale(pageZoom) * normalizeScale(compensation)) |
| 600 | - | return normalizeScale(surfaceScale) / backingScale | |||
| 601 | - | } | |||
| 602 | - | ||||
| 603 | - | func cefZoomFromPageAndScaleFactors(pageZoom, surfaceScale, backingScale float64) float64 { | |||
| 604 | - | return cefZoomFromFactor(pageZoom * zoomScaleRatio(surfaceScale, backingScale)) | |||
| 605 | } | 610 | } | ||
| 606 | 611 | ||||
| 607 | - | func pageZoomFromCEFAndScaleLevel(level, surfaceScale, backingScale float64) float64 { | 612 | + | // pageZoomFromCEFLevel is the inverse of cefZoomFromPageZoom: it reports the |
| 608 | - | return factorFromCEFZoom(level) / zoomScaleRatio(surfaceScale, backingScale) | 613 | + | // user-facing page zoom for a CEF zoom level observed under compensation. |
| 614 | + | func pageZoomFromCEFLevel(level, compensation float64) float64 { | |||
| 615 | + | return factorFromCEFZoom(level) / normalizeScale(compensation) | |||
| 609 | } | 616 | } | ||
| 610 | 617 | ||||
| 611 | - | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, surfaceScale, backingScale float64) { | 618 | + | // applyZoomForCompensation applies one user page zoom through CEF using the |
| 619 | + | // compensation of the OSR contract that is active in the bridge. Every zoom | |||
| 620 | + | // application goes through this path so the applied compensation is recorded once. | |||
| 621 | + | func (wv *WebView) applyZoomForCompensation(host purecef.BrowserHost, pageZoom, compensation float64) { | |||
| 622 | + | compensation = normalizeScale(compensation) | |||
| 623 | + | cefLevel := cefZoomFromPageZoom(pageZoom, compensation) | |||
| 612 | host.SetZoomLevel(cefLevel) | 624 | host.SetZoomLevel(cefLevel) | ||
| 613 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | 625 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | ||
| 614 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | 626 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | ||
| @@ -617,12 +629,13 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, | |||||
| 617 | // SynchronizeVisualProperties cycle, which makes the renderer produce | 629 | // SynchronizeVisualProperties cycle, which makes the renderer produce | ||
| 618 | // a new compositor frame at the new zoom level. | 630 | // a new compositor frame at the new zoom level. | ||
| 619 | host.NotifyScreenInfoChanged() | 631 | host.NotifyScreenInfoChanged() | ||
| 620 | - | wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale) | 632 | + | wv.recordAppliedZoomCompensation(compensation) |
| 633 | + | epoch := wv.zoomApplicationEpoch.Add(1) | |||
| 621 | // Zoom is applied asynchronously in the renderer process. Request a couple | 634 | // Zoom is applied asynchronously in the renderer process. Request a couple | ||
| 622 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | 635 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | ||
| 623 | // compositor frame after the zoom IPC has been processed. | 636 | // compositor frame after the zoom IPC has been processed. | ||
| 624 | wv.scheduleZoomRefresh() | 637 | wv.scheduleZoomRefresh() | ||
| 625 | - | wv.scheduleZoomReadback(factor, cefLevel) | 638 | + | wv.scheduleZoomReadback(pageZoom, cefLevel, compensation, epoch) |
| 626 | } | 639 | } | ||
| 627 | 640 | ||||
| 628 | // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a | 641 | // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a | ||
| @@ -651,15 +664,15 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||||
| 651 | return errNoBrowser | 664 | return errNoBrowser | ||
| 652 | } | 665 | } | ||
| 653 | surfaceScale := wv.viewBridgeScale() | 666 | surfaceScale := wv.viewBridgeScale() | ||
| 654 | - | backingScale := wv.osrBackingScaleFactor() | 667 | + | compensation := wv.pageZoomCompensation() |
| 655 | - | cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale) | 668 | + | cefLevel := cefZoomFromPageZoom(factor, compensation) |
| 656 | logging.FromContext(wv.ctx).Debug(). | 669 | logging.FromContext(wv.ctx).Debug(). | ||
| 657 | Float64("factor", factor). | 670 | Float64("factor", factor). | ||
| 658 | Float64("cef_level", cefLevel). | 671 | Float64("cef_level", cefLevel). | ||
| 659 | - | Float64("osr_backing_scale", backingScale). | 672 | + | Float64("page_zoom_compensation", compensation). |
| 660 | Float64("surface_scale", surfaceScale). | 673 | Float64("surface_scale", surfaceScale). | ||
| 661 | Msg("cef: SetZoomLevel") | 674 | Msg("cef: SetZoomLevel") | ||
| 662 | - | wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale) | 675 | + | wv.applyZoomForCompensation(host, factor, compensation) |
| 663 | wv.zoomFactor.Store(factor) | 676 | wv.zoomFactor.Store(factor) | ||
| 664 | return nil | 677 | return nil | ||
| 665 | } | 678 | } | ||
| @@ -1376,8 +1389,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() { | |||||
| 1376 | } | 1389 | } | ||
| 1377 | _ = wv.viewBridge.DetachInput() | 1390 | _ = wv.viewBridge.DetachInput() | ||
| 1378 | _ = wv.viewBridge.Destroy() | 1391 | _ = wv.viewBridge.Destroy() | ||
| 1392 | + | wv.mu.Lock() | |||
| 1379 | wv.viewBridge = nil | 1393 | wv.viewBridge = nil | ||
| 1394 | + | wv.zoomCompensation = nil | |||
| 1380 | wv.nativeWidget = nil | 1395 | wv.nativeWidget = nil | ||
| 1396 | + | wv.mu.Unlock() | |||
| 1381 | // The bridge actually tore down here (second arrivals return early on | 1397 | // The bridge actually tore down here (second arrivals return early on | ||
| 1382 | // the nil guard above), completing the outstanding teardown exactly once. | 1398 | // the nil guard above), completing the outstanding teardown exactly once. | ||
| 1383 | if wv.engine != nil { | 1399 | if wv.engine != nil { | ||
| @@ -1822,6 +1838,9 @@ func (wv *WebView) viewBridgeScale() float64 { | |||||
| 1822 | return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor())) | 1838 | return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor())) | ||
| 1823 | } | 1839 | } | ||
| 1824 | 1840 | ||||
| 1841 | + | // osrBackingScaleFactor reports the bridge's OSR backing scale used for view, | |||
| 1842 | + | // popup, and input geometry. It is 1 when the bridge uses CEF's normal logical | |||
| 1843 | + | // OSR contract. | |||
| 1825 | func (wv *WebView) osrBackingScaleFactor() float64 { | 1844 | func (wv *WebView) osrBackingScaleFactor() float64 { | ||
| 1826 | if wv == nil || wv.viewBridge == nil { | 1845 | if wv == nil || wv.viewBridge == nil { | ||
| 1827 | return 1 | 1846 | return 1 | ||
| @@ -1829,23 +1848,63 @@ func (wv *WebView) osrBackingScaleFactor() float64 { | |||||
| 1829 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | 1848 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | ||
| 1830 | } | 1849 | } | ||
| 1831 | 1850 | ||||
| 1832 | - | func (wv *WebView) recordAppliedZoomScaleRatio(surfaceScale, backingScale float64) { | 1851 | + | // pageZoomCompensation reports the compensation of the OSR geometry contract |
| 1852 | + | // currently active in the render bridge. It is 1 when the bridge is missing or | |||
| 1853 | + | // uses CEF's normal logical OSR contract, and it must never be persisted: user | |||
| 1854 | + | // page zoom stays a user-facing value that survives output and density changes. | |||
| 1855 | + | func (wv *WebView) pageZoomCompensation() float64 { | |||
| 1856 | + | if wv == nil { | |||
| 1857 | + | return 1 | |||
| 1858 | + | } | |||
| 1859 | + | wv.mu.RLock() | |||
| 1860 | + | bridge := wv.zoomCompensation | |||
| 1861 | + | wv.mu.RUnlock() | |||
| 1862 | + | if bridge == nil { | |||
| 1863 | + | return 1 | |||
| 1864 | + | } | |||
| 1865 | + | return normalizeScale(bridge.PageZoomCompensation()) | |||
| 1866 | + | } | |||
| 1867 | + | ||||
| 1868 | + | func (wv *WebView) recordAppliedZoomCompensation(compensation float64) { | |||
| 1833 | if wv == nil { | 1869 | if wv == nil { | ||
| 1834 | return | 1870 | return | ||
| 1835 | } | 1871 | } | ||
| 1836 | - | wv.lastAppliedZoomScaleRatioBits.Store(math.Float64bits(zoomScaleRatio(surfaceScale, backingScale))) | 1872 | + | wv.lastAppliedZoomCompensationBits.Store(math.Float64bits(normalizeScale(compensation))) |
| 1837 | } | 1873 | } | ||
| 1838 | 1874 | ||||
| 1839 | - | func (wv *WebView) shouldReapplyZoomForScaleRatio(surfaceScale, backingScale float64) bool { | 1875 | + | func (wv *WebView) clearAppliedZoomCompensation() { |
| 1840 | if wv == nil { | 1876 | if wv == nil { | ||
| 1841 | - | return false | 1877 | + | return |
| 1842 | } | 1878 | } | ||
| 1843 | - | ratio := zoomScaleRatio(surfaceScale, backingScale) | 1879 | + | wv.lastAppliedZoomCompensationBits.Store(0) |
| 1844 | - | bits := wv.lastAppliedZoomScaleRatioBits.Load() | 1880 | + | } |
| 1881 | + | ||||
| 1882 | + | // appliedZoomCompensation returns the compensation of the most recent zoom | |||
| 1883 | + | // application, or 0 when no zoom has been applied to the current host. | |||
| 1884 | + | func (wv *WebView) appliedZoomCompensation() float64 { | |||
| 1885 | + | if wv == nil { | |||
| 1886 | + | return 0 | |||
| 1887 | + | } | |||
| 1888 | + | bits := wv.lastAppliedZoomCompensationBits.Load() | |||
| 1845 | if bits == 0 { | 1889 | if bits == 0 { | ||
| 1890 | + | return 0 | |||
| 1891 | + | } | |||
| 1892 | + | return math.Float64frombits(bits) | |||
| 1893 | + | } | |||
| 1894 | + | ||||
| 1895 | + | // shouldReapplyZoomForCompensation reports whether page zoom must be applied | |||
| 1896 | + | // again for compensation. An unknown applied compensation always reapplies; an | |||
| 1897 | + | // unchanged one never does, so an output transition that keeps the same OSR | |||
| 1898 | + | // contract causes no redundant reapplication. | |||
| 1899 | + | func (wv *WebView) shouldReapplyZoomForCompensation(compensation float64) bool { | |||
| 1900 | + | if wv == nil { | |||
| 1901 | + | return false | |||
| 1902 | + | } | |||
| 1903 | + | applied := wv.appliedZoomCompensation() | |||
| 1904 | + | if applied == 0 { | |||
| 1846 | return true | 1905 | return true | ||
| 1847 | } | 1906 | } | ||
| 1848 | - | return math.Float64frombits(bits) != ratio | 1907 | + | return applied != normalizeScale(compensation) |
| 1849 | } | 1908 | } | ||
| 1850 | 1909 | ||||
| 1851 | func (wv *WebView) handleMiddleClickFromBridge() bool { | 1910 | func (wv *WebView) handleMiddleClickFromBridge() bool { | ||
| @@ -2287,10 +2346,18 @@ func (wv *WebView) scheduleZoomRefresh() { | |||||
| 2287 | // process the zoom IPC and reflect the new level back to the browser. | 2346 | // process the zoom IPC and reflect the new level back to the browser. | ||
| 2288 | // | 2347 | // | ||
| 2289 | // These values are empirically tuned for CEF's async zoom application. | 2348 | // These values are empirically tuned for CEF's async zoom application. | ||
| 2290 | - | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | 2349 | + | // |
| 2350 | + | // Both readbacks are decoded with the compensation of the application they | |||
| 2351 | + | // diagnose and are skipped when a newer application superseded it, so a stale | |||
| 2352 | + | // CEF zoom level is never relabelled with a newer compensation. | |||
| 2353 | + | func (wv *WebView) scheduleZoomReadback(expectedPageZoom, expectedLevel, compensation float64, epoch uint64) { | |||
| 2354 | + | compensation = normalizeScale(compensation) | |||
| 2291 | for _, delayMs := range [...]int64{0, 64} { | 2355 | for _, delayMs := range [...]int64{0, 64} { | ||
| 2292 | task := cefNewTask(cefTaskFunc(func() { | 2356 | task := cefNewTask(cefTaskFunc(func() { | ||
| 2293 | - | if wv.destroyed.Load() { | 2357 | + | if wv.destroyed.Load() || wv.zoomApplicationEpoch.Load() != epoch { |
| 2358 | + | return | |||
| 2359 | + | } | |||
| 2360 | + | if wv.appliedZoomCompensation() != compensation { | |||
| 2294 | return | 2361 | return | ||
| 2295 | } | 2362 | } | ||
| 2296 | wv.mu.RLock() | 2363 | wv.mu.RLock() | ||
| @@ -2300,16 +2367,15 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||||
| 2300 | return | 2367 | return | ||
| 2301 | } | 2368 | } | ||
| 2302 | surfaceScale := wv.viewBridgeScale() | 2369 | surfaceScale := wv.viewBridgeScale() | ||
| 2303 | - | backingScale := wv.osrBackingScaleFactor() | |||
| 2304 | actualLevel := host.GetZoomLevel() | 2370 | actualLevel := host.GetZoomLevel() | ||
| 2305 | logging.FromContext(wv.ctx).Debug(). | 2371 | logging.FromContext(wv.ctx).Debug(). | ||
| 2306 | Int64("delay_ms", delayMs). | 2372 | Int64("delay_ms", delayMs). | ||
| 2307 | - | Float64("expected_factor", expectedFactor). | 2373 | + | Float64("expected_factor", expectedPageZoom). |
| 2308 | Float64("expected_cef_level", expectedLevel). | 2374 | Float64("expected_cef_level", expectedLevel). | ||
| 2309 | - | Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)). | 2375 | + | Float64("actual_factor", pageZoomFromCEFLevel(actualLevel, compensation)). |
| 2310 | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | 2376 | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | ||
| 2311 | Float64("actual_cef_level", actualLevel). | 2377 | Float64("actual_cef_level", actualLevel). | ||
| 2312 | - | Float64("osr_backing_scale", backingScale). | 2378 | + | Float64("page_zoom_compensation", compensation). |
| 2313 | Float64("surface_scale", surfaceScale). | 2379 | Float64("surface_scale", surfaceScale). | ||
| 2314 | Msg("cef: zoom level readback") | 2380 | Msg("cef: zoom level readback") | ||
| 2315 | })) | 2381 | })) | ||
| @@ -2320,6 +2386,10 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||||
| 2320 | } | 2386 | } | ||
| 2321 | } | 2387 | } | ||
| 2322 | 2388 | ||||
| 2389 | + | // reapplyCurrentZoomForBackingScale reapplies the current user page zoom after | |||
| 2390 | + | // the bridge reported a different OSR page-zoom compensation, so the CSS | |||
| 2391 | + | // viewport keeps matching the GTK logical view size. User page zoom itself is | |||
| 2392 | + | // never mutated by an output transition. | |||
| 2323 | func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | 2393 | func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | ||
| 2324 | if wv == nil || wv.destroyed.Load() { | 2394 | if wv == nil || wv.destroyed.Load() { | ||
| 2325 | return | 2395 | return | ||
| @@ -2330,18 +2400,15 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |||||
| 2330 | if host == nil { | 2400 | if host == nil { | ||
| 2331 | return | 2401 | return | ||
| 2332 | } | 2402 | } | ||
| 2333 | - | factor := wv.GetZoomLevel() | 2403 | + | pageZoom := wv.GetZoomLevel() |
| 2334 | - | surfaceScale := wv.viewBridgeScale() | 2404 | + | compensation := wv.pageZoomCompensation() |
| 2335 | - | backingScale := wv.osrBackingScaleFactor() | 2405 | + | wv.applyZoomForCompensation(host, pageZoom, compensation) |
| 2336 | - | cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale) | |||
| 2337 | - | wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale) | |||
| 2338 | logging.FromContext(wv.ctx).Debug(). | 2406 | logging.FromContext(wv.ctx).Debug(). | ||
| 2339 | Str("reason", reason). | 2407 | Str("reason", reason). | ||
| 2340 | - | Float64("factor", factor). | 2408 | + | Float64("factor", pageZoom). |
| 2341 | - | Float64("cef_level", cefLevel). | 2409 | + | Float64("cef_level", cefZoomFromPageZoom(pageZoom, compensation)). |
| 2342 | - | Float64("osr_backing_scale", backingScale). | 2410 | + | Float64("page_zoom_compensation", compensation). |
| 2343 | - | Float64("surface_scale", surfaceScale). | 2411 | + | Msg("cef: reapplied zoom after OSR page-zoom compensation change") |
| 2344 | - | Msg("cef: reapplied zoom after OSR backing scale change") | |||
| 2345 | } | 2412 | } | ||
| 2346 | 2413 | ||||
| 2347 | func (wv *WebView) scheduleStartBeginFrameLoop() { | 2414 | func (wv *WebView) scheduleStartBeginFrameLoop() { | ||
M internal/infrastructure/cef/webview_handlers.go +2 -2
| @@ -889,7 +889,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 889 | 889 | ||
| 890 | 890 | h.wv.browser = browser | |
| 891 | 891 | h.wv.host = host | |
| 892 | - | h.wv.lastAppliedZoomScaleRatioBits.Store(0) | |
| 892 | + | h.wv.clearAppliedZoomCompensation() | |
| 893 | 893 | h.wv.pendingCreate = nil | |
| 894 | 894 | bridge := h.wv.viewBridge | |
| 895 | 895 | h.wv.inputAttached = bridge == nil | |
| @@ -1007,7 +1007,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 1007 | 1007 | h.wv.mu.Lock() | |
| 1008 | 1008 | h.wv.browser = nil | |
| 1009 | 1009 | h.wv.host = nil | |
| 1010 | - | h.wv.lastAppliedZoomScaleRatioBits.Store(0) | |
| 1010 | + | h.wv.clearAppliedZoomCompensation() | |
| 1011 | 1011 | h.wv.inputAttached = false | |
| 1012 | 1012 | if h.wv.findCtrl != nil { | |
| 1013 | 1013 | h.wv.findCtrl.setHost(nil) | |
| @@ -889,7 +889,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 889 | 889 | ||||
| 890 | h.wv.browser = browser | 890 | h.wv.browser = browser | ||
| 891 | h.wv.host = host | 891 | h.wv.host = host | ||
| 892 | - | h.wv.lastAppliedZoomScaleRatioBits.Store(0) | 892 | + | h.wv.clearAppliedZoomCompensation() |
| 893 | h.wv.pendingCreate = nil | 893 | h.wv.pendingCreate = nil | ||
| 894 | bridge := h.wv.viewBridge | 894 | bridge := h.wv.viewBridge | ||
| 895 | h.wv.inputAttached = bridge == nil | 895 | h.wv.inputAttached = bridge == nil | ||
| @@ -1007,7 +1007,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||||
| 1007 | h.wv.mu.Lock() | 1007 | h.wv.mu.Lock() | ||
| 1008 | h.wv.browser = nil | 1008 | h.wv.browser = nil | ||
| 1009 | h.wv.host = nil | 1009 | h.wv.host = nil | ||
| 1010 | - | h.wv.lastAppliedZoomScaleRatioBits.Store(0) | 1010 | + | h.wv.clearAppliedZoomCompensation() |
| 1011 | h.wv.inputAttached = false | 1011 | h.wv.inputAttached = false | ||
| 1012 | if h.wv.findCtrl != nil { | 1012 | if h.wv.findCtrl != nil { | ||
| 1013 | h.wv.findCtrl.setHost(nil) | 1013 | h.wv.findCtrl.setHost(nil) | ||
M internal/infrastructure/cef/webview_lifecycle_test.go +10 -0
| @@ -7,6 +7,16 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestCef2gtkAdapterPageZoomCompensationDefaultsToOne(t *testing.T) { | |
| 11 | + | var nilAdapter *Cef2gtkAdapter | |
| 12 | + | require.InDelta(t, 1.0, nilAdapter.PageZoomCompensation(), 1e-9) | |
| 13 | + | ||
| 14 | + | adapter := &Cef2gtkAdapter{} | |
| 15 | + | require.InDelta(t, 1.0, adapter.PageZoomCompensation(), 1e-9) | |
| 16 | + | adapter.destroyed.Store(true) | |
| 17 | + | require.InDelta(t, 1.0, adapter.PageZoomCompensation(), 1e-9) | |
| 18 | + | } | |
| 19 | + | ||
| 10 | 20 | func TestDestroy_WithHostDefersBridgeDestroyUntilBeforeClose(t *testing.T) { | |
| 11 | 21 | bridge := &Cef2gtkAdapter{} | |
| 12 | 22 | host := &stubBrowserHost{} | |
| @@ -7,6 +7,16 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestCef2gtkAdapterPageZoomCompensationDefaultsToOne(t *testing.T) { | |||
| 11 | + | var nilAdapter *Cef2gtkAdapter | |||
| 12 | + | require.InDelta(t, 1.0, nilAdapter.PageZoomCompensation(), 1e-9) | |||
| 13 | + | ||||
| 14 | + | adapter := &Cef2gtkAdapter{} | |||
| 15 | + | require.InDelta(t, 1.0, adapter.PageZoomCompensation(), 1e-9) | |||
| 16 | + | adapter.destroyed.Store(true) | |||
| 17 | + | require.InDelta(t, 1.0, adapter.PageZoomCompensation(), 1e-9) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 10 | func TestDestroy_WithHostDefersBridgeDestroyUntilBeforeClose(t *testing.T) { | 20 | func TestDestroy_WithHostDefersBridgeDestroyUntilBeforeClose(t *testing.T) { | ||
| 11 | bridge := &Cef2gtkAdapter{} | 21 | bridge := &Cef2gtkAdapter{} | ||
| 12 | host := &stubBrowserHost{} | 22 | host := &stubBrowserHost{} | ||
M internal/infrastructure/cef/webview_viewport.go +5 -4
| @@ -551,7 +551,8 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||
| 551 | 551 | if host == nil || wv == nil { | |
| 552 | 552 | return | |
| 553 | 553 | } | |
| 554 | - | if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) { | |
| 554 | + | compensation := wv.pageZoomCompensation() | |
| 555 | + | if !wv.shouldReapplyZoomForCompensation(compensation) { | |
| 555 | 556 | return | |
| 556 | 557 | } | |
| 557 | 558 | task := cefNewTask(cefTaskFunc(func() { | |
| @@ -564,9 +565,9 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||
| 564 | 565 | if currentHost != host { | |
| 565 | 566 | return | |
| 566 | 567 | } | |
| 567 | - | surfaceScale := wv.viewBridgeScale() | |
| 568 | - | backingScale := wv.osrBackingScaleFactor() | |
| 569 | - | if !wv.shouldReapplyZoomForScaleRatio(surfaceScale, backingScale) { | |
| 568 | + | // Re-check on execution: a newer output scale may have superseded the | |
| 569 | + | // compensation captured when this task was scheduled. | |
| 570 | + | if !wv.shouldReapplyZoomForCompensation(wv.pageZoomCompensation()) { | |
| 570 | 571 | return | |
| 571 | 572 | } | |
| 572 | 573 | wv.reapplyCurrentZoomForBackingScale(reason) | |
| @@ -551,7 +551,8 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||||
| 551 | if host == nil || wv == nil { | 551 | if host == nil || wv == nil { | ||
| 552 | return | 552 | return | ||
| 553 | } | 553 | } | ||
| 554 | - | if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) { | 554 | + | compensation := wv.pageZoomCompensation() |
| 555 | + | if !wv.shouldReapplyZoomForCompensation(compensation) { | |||
| 555 | return | 556 | return | ||
| 556 | } | 557 | } | ||
| 557 | task := cefNewTask(cefTaskFunc(func() { | 558 | task := cefNewTask(cefTaskFunc(func() { | ||
| @@ -564,9 +565,9 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||||
| 564 | if currentHost != host { | 565 | if currentHost != host { | ||
| 565 | return | 566 | return | ||
| 566 | } | 567 | } | ||
| 567 | - | surfaceScale := wv.viewBridgeScale() | 568 | + | // Re-check on execution: a newer output scale may have superseded the |
| 568 | - | backingScale := wv.osrBackingScaleFactor() | 569 | + | // compensation captured when this task was scheduled. |
| 569 | - | if !wv.shouldReapplyZoomForScaleRatio(surfaceScale, backingScale) { | 570 | + | if !wv.shouldReapplyZoomForCompensation(wv.pageZoomCompensation()) { |
| 570 | return | 571 | return | ||
| 571 | } | 572 | } | ||
| 572 | wv.reapplyCurrentZoomForBackingScale(reason) | 573 | wv.reapplyCurrentZoomForBackingScale(reason) | ||
M internal/infrastructure/cef/webview_viewport_test.go +88 -3
| @@ -11,8 +11,9 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | type viewportSyncOrderHost struct { | |
| 13 | 13 | purecef.BrowserHost | |
| 14 | - | calls []string | |
| 15 | - | zoomLevel float64 | |
| 14 | + | calls []string | |
| 15 | + | zoomLevel float64 | |
| 16 | + | getZoomLevelCalls int | |
| 16 | 17 | } | |
| 17 | 18 | ||
| 18 | 19 | type pendingBrowserCreateObservedSizeBridgeStub struct { | |
| @@ -61,6 +62,7 @@ func (h *viewportSyncOrderHost) SetZoomLevel(level float64) { | |||
| 61 | 62 | } | |
| 62 | 63 | ||
| 63 | 64 | func (h *viewportSyncOrderHost) GetZoomLevel() float64 { | |
| 65 | + | h.getZoomLevelCalls++ | |
| 64 | 66 | return h.zoomLevel | |
| 65 | 67 | } | |
| 66 | 68 | ||
| @@ -208,7 +210,7 @@ func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingSc | |||
| 208 | 210 | } | |
| 209 | 211 | ||
| 210 | 212 | host := &viewportSyncOrderHost{} | |
| 211 | - | wv := &WebView{ctx: context.Background(), host: host} | |
| 213 | + | wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |
| 212 | 214 | ||
| 213 | 215 | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |
| 214 | 216 | require.NotNil(t, scheduled) | |
| @@ -222,6 +224,89 @@ func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingSc | |||
| 222 | 224 | require.Empty(t, host.calls) | |
| 223 | 225 | } | |
| 224 | 226 | ||
| 227 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_ReappliesWhenCompensationChangesWithoutTouchingUserZoom(t *testing.T) { | |
| 228 | + | oldNewTask := cefNewTask | |
| 229 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 230 | + | defer func() { | |
| 231 | + | cefNewTask = oldNewTask | |
| 232 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 233 | + | }() | |
| 234 | + | ||
| 235 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 236 | + | ||
| 237 | + | var scheduled []purecef.Task | |
| 238 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 239 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 240 | + | if delayMs == 0 { | |
| 241 | + | scheduled = append(scheduled, task) | |
| 242 | + | } | |
| 243 | + | return 1 | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | host := &viewportSyncOrderHost{} | |
| 247 | + | wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |
| 248 | + | wv.zoomFactor.Store(1.3) | |
| 249 | + | ||
| 250 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |
| 251 | + | require.Len(t, scheduled, 1) | |
| 252 | + | scheduled[0].Execute() | |
| 253 | + | require.InDelta(t, cefZoomFromFactor(1.625), host.zoomLevel, 1e-9) | |
| 254 | + | ||
| 255 | + | // Moving to a different output scale must reapply the same user zoom under the | |
| 256 | + | // new compensation and must not mutate the persisted user zoom. | |
| 257 | + | host.calls = nil | |
| 258 | + | scheduled = nil | |
| 259 | + | wv.zoomCompensation = zoomCompensationStub{compensation: 2.0} | |
| 260 | + | ||
| 261 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |
| 262 | + | require.Len(t, scheduled, 1) | |
| 263 | + | scheduled[0].Execute() | |
| 264 | + | require.InDelta(t, cefZoomFromFactor(2.6), host.zoomLevel, 1e-9) | |
| 265 | + | require.InDelta(t, 1.3, wv.GetZoomLevel(), 1e-9, "user zoom must survive an output transition") | |
| 266 | + | require.Equal(t, []string{"SetZoomLevel", "NotifyScreenInfoChanged"}, host.calls) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsStaleHostAndDestroyedView(t *testing.T) { | |
| 270 | + | oldNewTask := cefNewTask | |
| 271 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 272 | + | defer func() { | |
| 273 | + | cefNewTask = oldNewTask | |
| 274 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 275 | + | }() | |
| 276 | + | ||
| 277 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 278 | + | ||
| 279 | + | var scheduled []purecef.Task | |
| 280 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { | |
| 281 | + | scheduled = append(scheduled, task) | |
| 282 | + | return 1 | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | capturedHost := &viewportSyncOrderHost{} | |
| 286 | + | currentHost := &viewportSyncOrderHost{} | |
| 287 | + | wv := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |
| 288 | + | ||
| 289 | + | wv.syncZoomForBackingScaleOnCEFUIThread(capturedHost, "host-replaced") | |
| 290 | + | require.Len(t, scheduled, 1) | |
| 291 | + | scheduled[0].Execute() | |
| 292 | + | require.Empty(t, capturedHost.calls) | |
| 293 | + | require.Empty(t, currentHost.calls) | |
| 294 | + | ||
| 295 | + | destroyed := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |
| 296 | + | destroyed.destroyed.Store(true) | |
| 297 | + | scheduled = nil | |
| 298 | + | destroyed.syncZoomForBackingScaleOnCEFUIThread(currentHost, "destroyed") | |
| 299 | + | require.Len(t, scheduled, 1) | |
| 300 | + | scheduled[0].Execute() | |
| 301 | + | require.Empty(t, currentHost.calls) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsMissingHost(t *testing.T) { | |
| 305 | + | wv := &WebView{ctx: context.Background(), zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |
| 306 | + | wv.syncZoomForBackingScaleOnCEFUIThread(nil, "missing-host") | |
| 307 | + | require.Zero(t, wv.appliedZoomCompensation()) | |
| 308 | + | } | |
| 309 | + | ||
| 225 | 310 | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | |
| 226 | 311 | oldNewTask := cefNewTask | |
| 227 | 312 | oldPostDelayedTask := cefPostDelayedTask | |
| @@ -11,8 +11,9 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | type viewportSyncOrderHost struct { | 12 | type viewportSyncOrderHost struct { | ||
| 13 | purecef.BrowserHost | 13 | purecef.BrowserHost | ||
| 14 | - | calls []string | 14 | + | calls []string |
| 15 | - | zoomLevel float64 | 15 | + | zoomLevel float64 |
| 16 | + | getZoomLevelCalls int | |||
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 18 | type pendingBrowserCreateObservedSizeBridgeStub struct { | 19 | type pendingBrowserCreateObservedSizeBridgeStub struct { | ||
| @@ -61,6 +62,7 @@ func (h *viewportSyncOrderHost) SetZoomLevel(level float64) { | |||||
| 61 | } | 62 | } | ||
| 62 | 63 | ||||
| 63 | func (h *viewportSyncOrderHost) GetZoomLevel() float64 { | 64 | func (h *viewportSyncOrderHost) GetZoomLevel() float64 { | ||
| 65 | + | h.getZoomLevelCalls++ | |||
| 64 | return h.zoomLevel | 66 | return h.zoomLevel | ||
| 65 | } | 67 | } | ||
| 66 | 68 | ||||
| @@ -208,7 +210,7 @@ func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingSc | |||||
| 208 | } | 210 | } | ||
| 209 | 211 | ||||
| 210 | host := &viewportSyncOrderHost{} | 212 | host := &viewportSyncOrderHost{} | ||
| 211 | - | wv := &WebView{ctx: context.Background(), host: host} | 213 | + | wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}} |
| 212 | 214 | ||||
| 213 | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | 215 | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | ||
| 214 | require.NotNil(t, scheduled) | 216 | require.NotNil(t, scheduled) | ||
| @@ -222,6 +224,89 @@ func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingSc | |||||
| 222 | require.Empty(t, host.calls) | 224 | require.Empty(t, host.calls) | ||
| 223 | } | 225 | } | ||
| 224 | 226 | ||||
| 227 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_ReappliesWhenCompensationChangesWithoutTouchingUserZoom(t *testing.T) { | |||
| 228 | + | oldNewTask := cefNewTask | |||
| 229 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 230 | + | defer func() { | |||
| 231 | + | cefNewTask = oldNewTask | |||
| 232 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 233 | + | }() | |||
| 234 | + | ||||
| 235 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 236 | + | ||||
| 237 | + | var scheduled []purecef.Task | |||
| 238 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 239 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 240 | + | if delayMs == 0 { | |||
| 241 | + | scheduled = append(scheduled, task) | |||
| 242 | + | } | |||
| 243 | + | return 1 | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | host := &viewportSyncOrderHost{} | |||
| 247 | + | wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |||
| 248 | + | wv.zoomFactor.Store(1.3) | |||
| 249 | + | ||||
| 250 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |||
| 251 | + | require.Len(t, scheduled, 1) | |||
| 252 | + | scheduled[0].Execute() | |||
| 253 | + | require.InDelta(t, cefZoomFromFactor(1.625), host.zoomLevel, 1e-9) | |||
| 254 | + | ||||
| 255 | + | // Moving to a different output scale must reapply the same user zoom under the | |||
| 256 | + | // new compensation and must not mutate the persisted user zoom. | |||
| 257 | + | host.calls = nil | |||
| 258 | + | scheduled = nil | |||
| 259 | + | wv.zoomCompensation = zoomCompensationStub{compensation: 2.0} | |||
| 260 | + | ||||
| 261 | + | wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer") | |||
| 262 | + | require.Len(t, scheduled, 1) | |||
| 263 | + | scheduled[0].Execute() | |||
| 264 | + | require.InDelta(t, cefZoomFromFactor(2.6), host.zoomLevel, 1e-9) | |||
| 265 | + | require.InDelta(t, 1.3, wv.GetZoomLevel(), 1e-9, "user zoom must survive an output transition") | |||
| 266 | + | require.Equal(t, []string{"SetZoomLevel", "NotifyScreenInfoChanged"}, host.calls) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsStaleHostAndDestroyedView(t *testing.T) { | |||
| 270 | + | oldNewTask := cefNewTask | |||
| 271 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 272 | + | defer func() { | |||
| 273 | + | cefNewTask = oldNewTask | |||
| 274 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 275 | + | }() | |||
| 276 | + | ||||
| 277 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 278 | + | ||||
| 279 | + | var scheduled []purecef.Task | |||
| 280 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { | |||
| 281 | + | scheduled = append(scheduled, task) | |||
| 282 | + | return 1 | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | capturedHost := &viewportSyncOrderHost{} | |||
| 286 | + | currentHost := &viewportSyncOrderHost{} | |||
| 287 | + | wv := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |||
| 288 | + | ||||
| 289 | + | wv.syncZoomForBackingScaleOnCEFUIThread(capturedHost, "host-replaced") | |||
| 290 | + | require.Len(t, scheduled, 1) | |||
| 291 | + | scheduled[0].Execute() | |||
| 292 | + | require.Empty(t, capturedHost.calls) | |||
| 293 | + | require.Empty(t, currentHost.calls) | |||
| 294 | + | ||||
| 295 | + | destroyed := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |||
| 296 | + | destroyed.destroyed.Store(true) | |||
| 297 | + | scheduled = nil | |||
| 298 | + | destroyed.syncZoomForBackingScaleOnCEFUIThread(currentHost, "destroyed") | |||
| 299 | + | require.Len(t, scheduled, 1) | |||
| 300 | + | scheduled[0].Execute() | |||
| 301 | + | require.Empty(t, currentHost.calls) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsMissingHost(t *testing.T) { | |||
| 305 | + | wv := &WebView{ctx: context.Background(), zoomCompensation: zoomCompensationStub{compensation: 1.25}} | |||
| 306 | + | wv.syncZoomForBackingScaleOnCEFUIThread(nil, "missing-host") | |||
| 307 | + | require.Zero(t, wv.appliedZoomCompensation()) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 225 | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | 310 | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | ||
| 226 | oldNewTask := cefNewTask | 311 | oldNewTask := cefNewTask | ||
| 227 | oldPostDelayedTask := cefPostDelayedTask | 312 | oldPostDelayedTask := cefPostDelayedTask | ||
M internal/infrastructure/cef/webview_zoom_test.go +157 -30
| @@ -1,10 +1,14 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 6 | + | "math" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 8 | 12 | ) | |
| 9 | 13 | ||
| 10 | 14 | func TestZoomConversionsRoundTrip(t *testing.T) { | |
| @@ -18,58 +22,181 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||
| 18 | 22 | } | |
| 19 | 23 | } | |
| 20 | 24 | ||
| 21 | - | func TestZoomConversionsCompensateDeviceSizedOSRBackingAndSurfaceScale(t *testing.T) { | |
| 25 | + | func TestZoomConversionsApplyUserZoomAndCompensation(t *testing.T) { | |
| 22 | 26 | tests := []struct { | |
| 23 | 27 | name string | |
| 24 | - | surfaceScale float64 | |
| 25 | - | backingScale float64 | |
| 26 | - | pageZoom float64 | |
| 28 | + | userZoom float64 | |
| 29 | + | compensation float64 | |
| 27 | 30 | wantInternal float64 | |
| 28 | 31 | }{ | |
| 29 | 32 | { | |
| 30 | - | name: "fractional_hidpi_keeps_100_percent_internal_zoom", | |
| 31 | - | surfaceScale: 1.2, | |
| 32 | - | backingScale: 1.2, | |
| 33 | - | pageZoom: 1.0, | |
| 33 | + | name: "unit_output_needs_no_compensation", | |
| 34 | + | userZoom: 1.0, | |
| 35 | + | compensation: 1.0, | |
| 34 | 36 | wantInternal: 1.0, | |
| 35 | 37 | }, | |
| 36 | 38 | { | |
| 37 | - | name: "integer_hidpi_keeps_requested_user_zoom", | |
| 38 | - | surfaceScale: 2.0, | |
| 39 | - | backingScale: 2.0, | |
| 40 | - | pageZoom: 1.2, | |
| 41 | - | wantInternal: 1.2, | |
| 39 | + | name: "user_zoom_on_unit_output", | |
| 40 | + | userZoom: 1.3, | |
| 41 | + | compensation: 1.0, | |
| 42 | + | wantInternal: 1.3, | |
| 42 | 43 | }, | |
| 43 | 44 | { | |
| 44 | - | name: "normal_osr_hidpi_keeps_requested_user_zoom", | |
| 45 | - | surfaceScale: 1.2, | |
| 46 | - | backingScale: 1.0, | |
| 47 | - | pageZoom: 1.0, | |
| 48 | - | wantInternal: 1.0, | |
| 45 | + | name: "fractional_output_compensates_100_percent", | |
| 46 | + | userZoom: 1.0, | |
| 47 | + | compensation: 1.25, | |
| 48 | + | wantInternal: 1.25, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "user_zoom_times_fractional_output", | |
| 52 | + | userZoom: 1.3, | |
| 53 | + | compensation: 1.25, | |
| 54 | + | wantInternal: 1.625, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "application_scale_composed_compensation", | |
| 58 | + | userZoom: 1.0, | |
| 59 | + | compensation: 1.75, | |
| 60 | + | wantInternal: 1.75, | |
| 49 | 61 | }, | |
| 50 | 62 | { | |
| 51 | - | name: "device_sized_backing_remains_compensated", | |
| 52 | - | surfaceScale: 1.25, | |
| 53 | - | backingScale: 2.0, | |
| 54 | - | pageZoom: 1.2, | |
| 55 | - | wantInternal: 0.75, | |
| 63 | + | name: "integer_output_doubles_internal_factor", | |
| 64 | + | userZoom: 2.0, | |
| 65 | + | compensation: 2.0, | |
| 66 | + | wantInternal: 4.0, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "half_user_zoom_stays_compensated", | |
| 70 | + | userZoom: 0.5, | |
| 71 | + | compensation: 1.25, | |
| 72 | + | wantInternal: 0.625, | |
| 56 | 73 | }, | |
| 57 | 74 | } | |
| 58 | 75 | ||
| 59 | 76 | for _, tt := range tests { | |
| 60 | 77 | t.Run(tt.name, func(t *testing.T) { | |
| 61 | - | level := cefZoomFromPageAndScaleFactors(tt.pageZoom, tt.surfaceScale, tt.backingScale) | |
| 62 | - | assert.InDelta(t, tt.wantInternal, factorFromCEFZoom(level), 1e-9) | |
| 63 | - | assert.InDelta(t, tt.pageZoom, pageZoomFromCEFAndScaleLevel(level, tt.surfaceScale, tt.backingScale), 1e-9) | |
| 78 | + | level := cefZoomFromPageZoom(tt.userZoom, tt.compensation) | |
| 79 | + | assert.InDelta(t, tt.wantInternal, factorFromCEFZoom(level), 1e-9, "internal CEF factor") | |
| 80 | + | assert.InDelta(t, tt.userZoom, pageZoomFromCEFLevel(level, tt.compensation), 1e-9, "inverse must return user zoom") | |
| 64 | 81 | }) | |
| 65 | 82 | } | |
| 66 | 83 | } | |
| 67 | 84 | ||
| 68 | - | func TestZoomReapplyGuardTracksEffectiveScaleRatio(t *testing.T) { | |
| 85 | + | func TestZoomConversionsNormalizeInvalidCompensation(t *testing.T) { | |
| 86 | + | for _, compensation := range []float64{0, -1.5, math.NaN(), math.Inf(1)} { | |
| 87 | + | level := cefZoomFromPageZoom(1.3, compensation) | |
| 88 | + | assert.InDelta(t, 1.3, factorFromCEFZoom(level), 1e-9, "invalid compensation %v must behave as 1", compensation) | |
| 89 | + | assert.InDelta(t, 1.3, pageZoomFromCEFLevel(level, compensation), 1e-9) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestZoomReapplyGuardTracksAppliedCompensation(t *testing.T) { | |
| 69 | 94 | wv := &WebView{} | |
| 70 | 95 | ||
| 71 | - | wv.recordAppliedZoomScaleRatio(1.2, 1.2) | |
| 96 | + | assert.True(t, wv.shouldReapplyZoomForCompensation(1.25), "unknown applied compensation must reapply") | |
| 97 | + | assert.Zero(t, wv.appliedZoomCompensation()) | |
| 98 | + | ||
| 99 | + | wv.recordAppliedZoomCompensation(1.25) | |
| 100 | + | ||
| 101 | + | assert.False(t, wv.shouldReapplyZoomForCompensation(1.25), "unchanged compensation must not reapply") | |
| 102 | + | assert.True(t, wv.shouldReapplyZoomForCompensation(2.0), "compensation change 1.25 -> 2 must reapply") | |
| 103 | + | assert.InDelta(t, 1.25, wv.appliedZoomCompensation(), 1e-9) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestPageZoomCompensationDefaultsToOneWithoutBridge(t *testing.T) { | |
| 107 | + | wv := &WebView{} | |
| 108 | + | assert.InDelta(t, 1.0, wv.pageZoomCompensation(), 1e-9) | |
| 109 | + | ||
| 110 | + | wv.zoomCompensation = zoomCompensationStub{compensation: 1.75} | |
| 111 | + | assert.InDelta(t, 1.75, wv.pageZoomCompensation(), 1e-9) | |
| 112 | + | ||
| 113 | + | wv.zoomCompensation = zoomCompensationStub{compensation: math.NaN()} | |
| 114 | + | assert.InDelta(t, 1.0, wv.pageZoomCompensation(), 1e-9) | |
| 115 | + | ||
| 116 | + | var typedNil *Cef2gtkAdapter | |
| 117 | + | wv.zoomCompensation = typedNil | |
| 118 | + | assert.InDelta(t, 1.0, wv.pageZoomCompensation(), 1e-9) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // withSynchronousCEFTasks replaces delayed task posting with a recorder so tests | |
| 122 | + | // can inspect and run zoom tasks without a CEF message loop. | |
| 123 | + | func withSynchronousCEFTasks(t *testing.T) *[]purecef.Task { | |
| 124 | + | t.Helper() | |
| 125 | + | oldNewTask := cefNewTask | |
| 126 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 127 | + | t.Cleanup(func() { | |
| 128 | + | cefNewTask = oldNewTask | |
| 129 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 133 | + | scheduled := &[]purecef.Task{} | |
| 134 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, _ int64) int32 { | |
| 135 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 136 | + | require.NotNil(t, task) | |
| 137 | + | *scheduled = append(*scheduled, task) | |
| 138 | + | return 1 | |
| 139 | + | } | |
| 140 | + | return scheduled | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestSetZoomLevelAppliesUserZoomThroughCompensation(t *testing.T) { | |
| 144 | + | scheduled := withSynchronousCEFTasks(t) | |
| 145 | + | ||
| 146 | + | host := &viewportSyncOrderHost{} | |
| 147 | + | wv := &WebView{ | |
| 148 | + | ctx: context.Background(), | |
| 149 | + | host: host, | |
| 150 | + | zoomCompensation: zoomCompensationStub{compensation: 1.25}, | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | require.NoError(t, wv.SetZoomLevel(context.Background(), 1.3)) | |
| 154 | + | ||
| 155 | + | assert.Equal(t, []string{"SetZoomLevel", "NotifyScreenInfoChanged"}, host.calls) | |
| 156 | + | assert.InDelta(t, cefZoomFromFactor(1.625), host.zoomLevel, 1e-9, "internal CEF level must include user zoom x compensation") | |
| 157 | + | assert.InDelta(t, 1.3, wv.GetZoomLevel(), 1e-9, "user zoom must stay user-facing") | |
| 158 | + | assert.InDelta(t, 1.25, wv.appliedZoomCompensation(), 1e-9) | |
| 159 | + | assert.False(t, wv.shouldReapplyZoomForCompensation(1.25)) | |
| 160 | + | assert.NotEmpty(t, *scheduled) | |
| 161 | + | ||
| 162 | + | // The diagnostic readback decodes the same CEF level back to user zoom with | |
| 163 | + | // the compensation of its own application. | |
| 164 | + | for _, task := range *scheduled { | |
| 165 | + | task.Execute() | |
| 166 | + | } | |
| 167 | + | assert.InDelta(t, 1.3, pageZoomFromCEFLevel(host.zoomLevel, 1.25), 1e-9) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestSetZoomLevelRejectsDestroyedViewAndMissingHost(t *testing.T) { | |
| 171 | + | destroyed := &WebView{ctx: context.Background()} | |
| 172 | + | destroyed.destroyed.Store(true) | |
| 173 | + | require.ErrorIs(t, destroyed.SetZoomLevel(context.Background(), 1.2), errDestroyed) | |
| 174 | + | ||
| 175 | + | require.ErrorIs(t, (&WebView{ctx: context.Background()}).SetZoomLevel(context.Background(), 1.2), errNoBrowser) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestScheduleZoomReadbackSkipsSupersededApplication(t *testing.T) { | |
| 179 | + | scheduled := withSynchronousCEFTasks(t) | |
| 180 | + | host := &viewportSyncOrderHost{} | |
| 181 | + | wv := &WebView{ctx: context.Background(), host: host} | |
| 182 | + | ||
| 183 | + | wv.recordAppliedZoomCompensation(1.25) | |
| 184 | + | firstEpoch := wv.zoomApplicationEpoch.Add(1) | |
| 185 | + | wv.scheduleZoomReadback(1.3, cefZoomFromPageZoom(1.3, 1.25), 1.25, firstEpoch) | |
| 186 | + | require.Len(t, *scheduled, 2) | |
| 187 | + | stale := (*scheduled)[0] | |
| 188 | + | ||
| 189 | + | // A newer application supersedes the pending readback: decoding the newer CEF | |
| 190 | + | // level with the older compensation would report a wrong user zoom. | |
| 191 | + | wv.recordAppliedZoomCompensation(1.25) | |
| 192 | + | wv.zoomApplicationEpoch.Add(1) | |
| 193 | + | stale.Execute() | |
| 194 | + | assert.Zero(t, host.getZoomLevelCalls, "superseded application must not be diagnosed") | |
| 72 | 195 | ||
| 73 | - | assert.False(t, wv.shouldReapplyZoomForScaleRatio(2.0, 2.0), "same effective ratio should not reapply") | |
| 74 | - | assert.True(t, wv.shouldReapplyZoomForScaleRatio(1.2, 2.0), "changed active-backing ratio should reapply") | |
| 196 | + | wv.recordAppliedZoomCompensation(2.0) | |
| 197 | + | currentEpoch := wv.zoomApplicationEpoch.Add(1) | |
| 198 | + | wv.scheduleZoomReadback(1.3, cefZoomFromPageZoom(1.3, 2.0), 2.0, currentEpoch) | |
| 199 | + | require.Len(t, *scheduled, 4) | |
| 200 | + | (*scheduled)[2].Execute() | |
| 201 | + | assert.Equal(t, 1, host.getZoomLevelCalls, "current application must be diagnosed") | |
| 75 | 202 | } | |
| @@ -1,10 +1,14 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | 8 | ||||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 7 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 8 | ) | 12 | ) | ||
| 9 | 13 | ||||
| 10 | func TestZoomConversionsRoundTrip(t *testing.T) { | 14 | func TestZoomConversionsRoundTrip(t *testing.T) { | ||
| @@ -18,58 +22,181 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||||
| 18 | } | 22 | } | ||
| 19 | } | 23 | } | ||
| 20 | 24 | ||||
| 21 | - | func TestZoomConversionsCompensateDeviceSizedOSRBackingAndSurfaceScale(t *testing.T) { | 25 | + | func TestZoomConversionsApplyUserZoomAndCompensation(t *testing.T) { |
| 22 | tests := []struct { | 26 | tests := []struct { | ||
| 23 | name string | 27 | name string | ||
| 24 | - | surfaceScale float64 | 28 | + | userZoom float64 |
| 25 | - | backingScale float64 | 29 | + | compensation float64 |
| 26 | - | pageZoom float64 | |||
| 27 | wantInternal float64 | 30 | wantInternal float64 | ||
| 28 | }{ | 31 | }{ | ||
| 29 | { | 32 | { | ||
| 30 | - | name: "fractional_hidpi_keeps_100_percent_internal_zoom", | 33 | + | name: "unit_output_needs_no_compensation", |
| 31 | - | surfaceScale: 1.2, | 34 | + | userZoom: 1.0, |
| 32 | - | backingScale: 1.2, | 35 | + | compensation: 1.0, |
| 33 | - | pageZoom: 1.0, | |||
| 34 | wantInternal: 1.0, | 36 | wantInternal: 1.0, | ||
| 35 | }, | 37 | }, | ||
| 36 | { | 38 | { | ||
| 37 | - | name: "integer_hidpi_keeps_requested_user_zoom", | 39 | + | name: "user_zoom_on_unit_output", |
| 38 | - | surfaceScale: 2.0, | 40 | + | userZoom: 1.3, |
| 39 | - | backingScale: 2.0, | 41 | + | compensation: 1.0, |
| 40 | - | pageZoom: 1.2, | 42 | + | wantInternal: 1.3, |
| 41 | - | wantInternal: 1.2, | |||
| 42 | }, | 43 | }, | ||
| 43 | { | 44 | { | ||
| 44 | - | name: "normal_osr_hidpi_keeps_requested_user_zoom", | 45 | + | name: "fractional_output_compensates_100_percent", |
| 45 | - | surfaceScale: 1.2, | 46 | + | userZoom: 1.0, |
| 46 | - | backingScale: 1.0, | 47 | + | compensation: 1.25, |
| 47 | - | pageZoom: 1.0, | 48 | + | wantInternal: 1.25, |
| 48 | - | wantInternal: 1.0, | 49 | + | }, |
| 50 | + | { | |||
| 51 | + | name: "user_zoom_times_fractional_output", | |||
| 52 | + | userZoom: 1.3, | |||
| 53 | + | compensation: 1.25, | |||
| 54 | + | wantInternal: 1.625, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "application_scale_composed_compensation", | |||
| 58 | + | userZoom: 1.0, | |||
| 59 | + | compensation: 1.75, | |||
| 60 | + | wantInternal: 1.75, | |||
| 49 | }, | 61 | }, | ||
| 50 | { | 62 | { | ||
| 51 | - | name: "device_sized_backing_remains_compensated", | 63 | + | name: "integer_output_doubles_internal_factor", |
| 52 | - | surfaceScale: 1.25, | 64 | + | userZoom: 2.0, |
| 53 | - | backingScale: 2.0, | 65 | + | compensation: 2.0, |
| 54 | - | pageZoom: 1.2, | 66 | + | wantInternal: 4.0, |
| 55 | - | wantInternal: 0.75, | 67 | + | }, |
| 68 | + | { | |||
| 69 | + | name: "half_user_zoom_stays_compensated", | |||
| 70 | + | userZoom: 0.5, | |||
| 71 | + | compensation: 1.25, | |||
| 72 | + | wantInternal: 0.625, | |||
| 56 | }, | 73 | }, | ||
| 57 | } | 74 | } | ||
| 58 | 75 | ||||
| 59 | for _, tt := range tests { | 76 | for _, tt := range tests { | ||
| 60 | t.Run(tt.name, func(t *testing.T) { | 77 | t.Run(tt.name, func(t *testing.T) { | ||
| 61 | - | level := cefZoomFromPageAndScaleFactors(tt.pageZoom, tt.surfaceScale, tt.backingScale) | 78 | + | level := cefZoomFromPageZoom(tt.userZoom, tt.compensation) |
| 62 | - | assert.InDelta(t, tt.wantInternal, factorFromCEFZoom(level), 1e-9) | 79 | + | assert.InDelta(t, tt.wantInternal, factorFromCEFZoom(level), 1e-9, "internal CEF factor") |
| 63 | - | assert.InDelta(t, tt.pageZoom, pageZoomFromCEFAndScaleLevel(level, tt.surfaceScale, tt.backingScale), 1e-9) | 80 | + | assert.InDelta(t, tt.userZoom, pageZoomFromCEFLevel(level, tt.compensation), 1e-9, "inverse must return user zoom") |
| 64 | }) | 81 | }) | ||
| 65 | } | 82 | } | ||
| 66 | } | 83 | } | ||
| 67 | 84 | ||||
| 68 | - | func TestZoomReapplyGuardTracksEffectiveScaleRatio(t *testing.T) { | 85 | + | func TestZoomConversionsNormalizeInvalidCompensation(t *testing.T) { |
| 86 | + | for _, compensation := range []float64{0, -1.5, math.NaN(), math.Inf(1)} { | |||
| 87 | + | level := cefZoomFromPageZoom(1.3, compensation) | |||
| 88 | + | assert.InDelta(t, 1.3, factorFromCEFZoom(level), 1e-9, "invalid compensation %v must behave as 1", compensation) | |||
| 89 | + | assert.InDelta(t, 1.3, pageZoomFromCEFLevel(level, compensation), 1e-9) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestZoomReapplyGuardTracksAppliedCompensation(t *testing.T) { | |||
| 69 | wv := &WebView{} | 94 | wv := &WebView{} | ||
| 70 | 95 | ||||
| 71 | - | wv.recordAppliedZoomScaleRatio(1.2, 1.2) | 96 | + | assert.True(t, wv.shouldReapplyZoomForCompensation(1.25), "unknown applied compensation must reapply") |
| 97 | + | assert.Zero(t, wv.appliedZoomCompensation()) | |||
| 98 | + | ||||
| 99 | + | wv.recordAppliedZoomCompensation(1.25) | |||
| 100 | + | ||||
| 101 | + | assert.False(t, wv.shouldReapplyZoomForCompensation(1.25), "unchanged compensation must not reapply") | |||
| 102 | + | assert.True(t, wv.shouldReapplyZoomForCompensation(2.0), "compensation change 1.25 -> 2 must reapply") | |||
| 103 | + | assert.InDelta(t, 1.25, wv.appliedZoomCompensation(), 1e-9) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestPageZoomCompensationDefaultsToOneWithoutBridge(t *testing.T) { | |||
| 107 | + | wv := &WebView{} | |||
| 108 | + | assert.InDelta(t, 1.0, wv.pageZoomCompensation(), 1e-9) | |||
| 109 | + | ||||
| 110 | + | wv.zoomCompensation = zoomCompensationStub{compensation: 1.75} | |||
| 111 | + | assert.InDelta(t, 1.75, wv.pageZoomCompensation(), 1e-9) | |||
| 112 | + | ||||
| 113 | + | wv.zoomCompensation = zoomCompensationStub{compensation: math.NaN()} | |||
| 114 | + | assert.InDelta(t, 1.0, wv.pageZoomCompensation(), 1e-9) | |||
| 115 | + | ||||
| 116 | + | var typedNil *Cef2gtkAdapter | |||
| 117 | + | wv.zoomCompensation = typedNil | |||
| 118 | + | assert.InDelta(t, 1.0, wv.pageZoomCompensation(), 1e-9) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // withSynchronousCEFTasks replaces delayed task posting with a recorder so tests | |||
| 122 | + | // can inspect and run zoom tasks without a CEF message loop. | |||
| 123 | + | func withSynchronousCEFTasks(t *testing.T) *[]purecef.Task { | |||
| 124 | + | t.Helper() | |||
| 125 | + | oldNewTask := cefNewTask | |||
| 126 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 127 | + | t.Cleanup(func() { | |||
| 128 | + | cefNewTask = oldNewTask | |||
| 129 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 130 | + | }) | |||
| 131 | + | ||||
| 132 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 133 | + | scheduled := &[]purecef.Task{} | |||
| 134 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, _ int64) int32 { | |||
| 135 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 136 | + | require.NotNil(t, task) | |||
| 137 | + | *scheduled = append(*scheduled, task) | |||
| 138 | + | return 1 | |||
| 139 | + | } | |||
| 140 | + | return scheduled | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestSetZoomLevelAppliesUserZoomThroughCompensation(t *testing.T) { | |||
| 144 | + | scheduled := withSynchronousCEFTasks(t) | |||
| 145 | + | ||||
| 146 | + | host := &viewportSyncOrderHost{} | |||
| 147 | + | wv := &WebView{ | |||
| 148 | + | ctx: context.Background(), | |||
| 149 | + | host: host, | |||
| 150 | + | zoomCompensation: zoomCompensationStub{compensation: 1.25}, | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | require.NoError(t, wv.SetZoomLevel(context.Background(), 1.3)) | |||
| 154 | + | ||||
| 155 | + | assert.Equal(t, []string{"SetZoomLevel", "NotifyScreenInfoChanged"}, host.calls) | |||
| 156 | + | assert.InDelta(t, cefZoomFromFactor(1.625), host.zoomLevel, 1e-9, "internal CEF level must include user zoom x compensation") | |||
| 157 | + | assert.InDelta(t, 1.3, wv.GetZoomLevel(), 1e-9, "user zoom must stay user-facing") | |||
| 158 | + | assert.InDelta(t, 1.25, wv.appliedZoomCompensation(), 1e-9) | |||
| 159 | + | assert.False(t, wv.shouldReapplyZoomForCompensation(1.25)) | |||
| 160 | + | assert.NotEmpty(t, *scheduled) | |||
| 161 | + | ||||
| 162 | + | // The diagnostic readback decodes the same CEF level back to user zoom with | |||
| 163 | + | // the compensation of its own application. | |||
| 164 | + | for _, task := range *scheduled { | |||
| 165 | + | task.Execute() | |||
| 166 | + | } | |||
| 167 | + | assert.InDelta(t, 1.3, pageZoomFromCEFLevel(host.zoomLevel, 1.25), 1e-9) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestSetZoomLevelRejectsDestroyedViewAndMissingHost(t *testing.T) { | |||
| 171 | + | destroyed := &WebView{ctx: context.Background()} | |||
| 172 | + | destroyed.destroyed.Store(true) | |||
| 173 | + | require.ErrorIs(t, destroyed.SetZoomLevel(context.Background(), 1.2), errDestroyed) | |||
| 174 | + | ||||
| 175 | + | require.ErrorIs(t, (&WebView{ctx: context.Background()}).SetZoomLevel(context.Background(), 1.2), errNoBrowser) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestScheduleZoomReadbackSkipsSupersededApplication(t *testing.T) { | |||
| 179 | + | scheduled := withSynchronousCEFTasks(t) | |||
| 180 | + | host := &viewportSyncOrderHost{} | |||
| 181 | + | wv := &WebView{ctx: context.Background(), host: host} | |||
| 182 | + | ||||
| 183 | + | wv.recordAppliedZoomCompensation(1.25) | |||
| 184 | + | firstEpoch := wv.zoomApplicationEpoch.Add(1) | |||
| 185 | + | wv.scheduleZoomReadback(1.3, cefZoomFromPageZoom(1.3, 1.25), 1.25, firstEpoch) | |||
| 186 | + | require.Len(t, *scheduled, 2) | |||
| 187 | + | stale := (*scheduled)[0] | |||
| 188 | + | ||||
| 189 | + | // A newer application supersedes the pending readback: decoding the newer CEF | |||
| 190 | + | // level with the older compensation would report a wrong user zoom. | |||
| 191 | + | wv.recordAppliedZoomCompensation(1.25) | |||
| 192 | + | wv.zoomApplicationEpoch.Add(1) | |||
| 193 | + | stale.Execute() | |||
| 194 | + | assert.Zero(t, host.getZoomLevelCalls, "superseded application must not be diagnosed") | |||
| 72 | 195 | ||||
| 73 | - | assert.False(t, wv.shouldReapplyZoomForScaleRatio(2.0, 2.0), "same effective ratio should not reapply") | 196 | + | wv.recordAppliedZoomCompensation(2.0) |
| 74 | - | assert.True(t, wv.shouldReapplyZoomForScaleRatio(1.2, 2.0), "changed active-backing ratio should reapply") | 197 | + | currentEpoch := wv.zoomApplicationEpoch.Add(1) |
| 198 | + | wv.scheduleZoomReadback(1.3, cefZoomFromPageZoom(1.3, 2.0), 2.0, currentEpoch) | |||
| 199 | + | require.Len(t, *scheduled, 4) | |||
| 200 | + | (*scheduled)[2].Execute() | |||
| 201 | + | assert.Equal(t, 1, host.getZoomLevelCalls, "current application must be diagnosed") | |||
| 75 | } | 202 | } | ||
M internal/ui/app.go +1 -1
| @@ -2452,7 +2452,7 @@ func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action s | |||
| 2452 | 2452 | } | |
| 2453 | 2453 | if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil { | |
| 2454 | 2454 | if paneView := wsView.GetPaneView(paneID); paneView != nil { | |
| 2455 | - | paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100)) | |
| 2455 | + | paneView.ShowZoomToast(ctx, entity.ZoomPercentage(newZoom.ZoomFactor)) | |
| 2456 | 2456 | } | |
| 2457 | 2457 | } | |
| 2458 | 2458 | return nil | |
| @@ -2452,7 +2452,7 @@ func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action s | |||||
| 2452 | } | 2452 | } | ||
| 2453 | if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil { | 2453 | if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil { | ||
| 2454 | if paneView := wsView.GetPaneView(paneID); paneView != nil { | 2454 | if paneView := wsView.GetPaneView(paneID); paneView != nil { | ||
| 2455 | - | paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100)) | 2455 | + | paneView.ShowZoomToast(ctx, entity.ZoomPercentage(newZoom.ZoomFactor)) |
| 2456 | } | 2456 | } | ||
| 2457 | } | 2457 | } | ||
| 2458 | return nil | 2458 | return nil | ||
M internal/ui/component/omnibox.go +2 -2
| @@ -2939,10 +2939,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||
| 2939 | 2939 | if o.zoomLabel == nil { | |
| 2940 | 2940 | return false | |
| 2941 | 2941 | } | |
| 2942 | - | if factor == 1.0 { | |
| 2942 | + | percentage := entity.ZoomPercentage(factor) | |
| 2943 | + | if percentage == 100 { | |
| 2943 | 2944 | o.zoomLabel.SetVisible(false) | |
| 2944 | 2945 | } else { | |
| 2945 | - | percentage := int(factor * 100) | |
| 2946 | 2946 | o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage)) | |
| 2947 | 2947 | o.zoomLabel.SetVisible(true) | |
| 2948 | 2948 | } | |
| @@ -2939,10 +2939,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||||
| 2939 | if o.zoomLabel == nil { | 2939 | if o.zoomLabel == nil { | ||
| 2940 | return false | 2940 | return false | ||
| 2941 | } | 2941 | } | ||
| 2942 | - | if factor == 1.0 { | 2942 | + | percentage := entity.ZoomPercentage(factor) |
| 2943 | + | if percentage == 100 { | |||
| 2943 | o.zoomLabel.SetVisible(false) | 2944 | o.zoomLabel.SetVisible(false) | ||
| 2944 | } else { | 2945 | } else { | ||
| 2945 | - | percentage := int(factor * 100) | |||
| 2946 | o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage)) | 2946 | o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage)) | ||
| 2947 | o.zoomLabel.SetVisible(true) | 2947 | o.zoomLabel.SetVisible(true) | ||
| 2948 | } | 2948 | } | ||
M internal/ui/dispatcher/keyboard.go +1 -1
| @@ -612,7 +612,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro | |||
| 612 | 612 | d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor) | |
| 613 | 613 | ||
| 614 | 614 | // Show zoom toast on the active pane | |
| 615 | - | zoomPercent := int(newZoom.ZoomFactor * 100) | |
| 615 | + | zoomPercent := entity.ZoomPercentage(newZoom.ZoomFactor) | |
| 616 | 616 | d.wsCoord.ShowZoomToast(ctx, zoomPercent) | |
| 617 | 617 | ||
| 618 | 618 | log.Debug(). | |
| @@ -612,7 +612,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro | |||||
| 612 | d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor) | 612 | d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor) | ||
| 613 | 613 | ||||
| 614 | // Show zoom toast on the active pane | 614 | // Show zoom toast on the active pane | ||
| 615 | - | zoomPercent := int(newZoom.ZoomFactor * 100) | 615 | + | zoomPercent := entity.ZoomPercentage(newZoom.ZoomFactor) |
| 616 | d.wsCoord.ShowZoomToast(ctx, zoomPercent) | 616 | d.wsCoord.ShowZoomToast(ctx, zoomPercent) | ||
| 617 | 617 | ||||
| 618 | log.Debug(). | 618 | log.Debug(). | ||