detached e04d7eb

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 UnverifiedB5690EEEBB952194 The 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
299299
300300 `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.
301301
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+
302304 `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.
303305
304306 ### 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 migration306 ### Legacy key migration
M go.mod +1 -1
@@ -9,7 +9,7 @@ require (
99 github.com/andybalholm/brotli v1.2.3
1010 github.com/bnema/purego v0.11.0-bnema.4
1111 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
1313 github.com/bnema/purego-pipewire v0.1.6
1414 github.com/bnema/purego-sqlite v0.1.5
1515 github.com/bnema/purego-webp v0.2.1
@@ -9,7 +9,7 @@ require (
9 github.com/andybalholm/brotli v1.2.39 github.com/andybalholm/brotli v1.2.3
10 github.com/bnema/purego v0.11.0-bnema.410 github.com/bnema/purego v0.11.0-bnema.4
11 github.com/bnema/purego-cef v0.14.211 github.com/bnema/purego-cef v0.14.2
12- github.com/bnema/purego-cef2gtk v0.10.112+ github.com/bnema/purego-cef2gtk v0.10.2-0.20260911042437-e9e81371d3b8
13 github.com/bnema/purego-pipewire v0.1.613 github.com/bnema/purego-pipewire v0.1.6
14 github.com/bnema/purego-sqlite v0.1.514 github.com/bnema/purego-sqlite v0.1.5
15 github.com/bnema/purego-webp v0.2.115 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 @@
11 package entity
22
3-import "time"
3+import (
4+ "math"
5+ "time"
6+)
47
58 // ZoomLevel represents the zoom factor for a specific domain.
69 // Allows users to set persistent zoom levels per-site.
@@ -53,9 +56,16 @@ func (z *ZoomLevel) IsDefault() bool {
5356 return z.ZoomFactor == ZoomDefault
5457 }
5558
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.
5767 func (z *ZoomLevel) Percentage() int {
58- return int(z.ZoomFactor * 100)
68+ return ZoomPercentage(z.ZoomFactor)
5969 }
6070
6171 // clampZoom constrains a zoom factor to the valid range.
@@ -1,6 +1,9 @@
1 package entity1 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 == ZoomDefault56 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 {
123123 return cef2gtk.OSRBackingScaleFactorForScale(float64(a.view.DeviceScaleFactor()))
124124 }
125125
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+
126143 // AddSizeObserver registers a positive-size change callback. Register and
127144 // unregister from the GTK main thread; callbacks are invoked by the bridge from
128145 // 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 and143 // AddSizeObserver registers a positive-size change callback. Register and
127 // unregister from the GTK main thread; callbacks are invoked by the bridge from144 // 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) {
127127 engine: f.engine,
128128 factory: f,
129129 viewBridge: viewBridge,
130+ zoomCompensation: viewBridge,
130131 audioOutputFactory: f.audioOutputFactory,
131132 adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
132133 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 (
88 const scaleProbeRoundFactor = 1_000_000
99
1010 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
1717 }
1818
1919 func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool {
@@ -29,20 +29,20 @@ func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool {
2929
3030 func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
3131 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,
3838 }
3939 if wv == nil {
4040 return m
4141 }
4242 m.UserZoom = normalizeScale(wv.GetZoomLevel())
43- m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
43+ m.PageZoomCompensation = wv.pageZoomCompensation()
4444 m.SurfaceScale = normalizeScale(wv.viewBridgeScale())
45- m.InternalCEFFactor = m.UserZoom * zoomScaleRatio(m.SurfaceScale, m.OSRBackingScale)
45+ m.InternalCEFFactor = m.UserZoom * m.PageZoomCompensation
4646 if wv.viewBridge != nil {
4747 m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
4848 }
@@ -51,12 +51,12 @@ func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
5151
5252 func (m cefScaleProbeMetrics) logFields() map[string]any {
5353 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),
6060 }
6161 }
6262
@@ -8,12 +8,12 @@ import (
8 const scaleProbeRoundFactor = 1_000_0008 const scaleProbeRoundFactor = 1_000_000
9 9
10 type cefScaleProbeMetrics struct {10 type cefScaleProbeMetrics struct {
11- SurfaceWidth int3211+ SurfaceWidth int32
12- SurfaceHeight int3212+ SurfaceHeight int32
13- SurfaceScale float6413+ SurfaceScale float64
14- OSRBackingScale float6414+ PageZoomCompensation float64
15- UserZoom float6415+ UserZoom float64
16- InternalCEFFactor float6416+ 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 m40 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) {
4040 if s.SurfaceWidth != 1 || s.SurfaceHeight != 1 {
4141 t.Fatalf("surface size = %dx%d, want 1x1", s.SurfaceWidth, s.SurfaceHeight)
4242 }
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)
4545 }
4646 if s.UserZoom != 1 || s.InternalCEFFactor != 1 {
4747 t.Fatalf("zoom = user %v internal %v, want 1/1", s.UserZoom, s.InternalCEFFactor)
4848 }
4949 }
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
9494 }
9595 func (stubMenuModel) SetFontList(_ int32, _ string) int32 { panic("unexpected call") }
9696 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 (
9999 cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
100100 )
101101
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+
102110 // WebView implements port.WebView using a CEF off-screen browser rendered and
103111 // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the
104112 // bridge owns GTK rendering and input forwarding.
@@ -111,6 +119,7 @@ type WebView struct {
111119 host purecef.BrowserHost
112120 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
113121 viewBridge *Cef2gtkAdapter
122+ zoomCompensation pageZoomCompensationBridge
114123 popupSurface *popupBridgeSurface
115124 nativeWidget *gtk.Widget
116125 handlers *handlerSet
@@ -283,11 +292,12 @@ type WebView struct {
283292 // bridgeTeardownNoted gates quiescence accounting exactly once per
284293 // view: every bridge teardown is scheduled through the Sync/Async
285294 // 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
291301
292302 // Browser creation defaults copied from the factory so native popup shells
293303 // can apply the same settings in OnBeforePopup.
@@ -592,23 +602,25 @@ func cefZoomFromFactor(factor float64) float64 {
592602 return math.Log(factor) / math.Log(chromiumZoomBase)
593603 }
594604
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))
605610 }
606611
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)
609616 }
610617
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)
612624 host.SetZoomLevel(cefLevel)
613625 // Force CEF to produce a new frame at the new zoom level. In OSR mode,
614626 // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
@@ -617,12 +629,13 @@ func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel,
617629 // SynchronizeVisualProperties cycle, which makes the renderer produce
618630 // a new compositor frame at the new zoom level.
619631 host.NotifyScreenInfoChanged()
620- wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale)
632+ wv.recordAppliedZoomCompensation(compensation)
633+ epoch := wv.zoomApplicationEpoch.Add(1)
621634 // Zoom is applied asynchronously in the renderer process. Request a couple
622635 // of follow-up refreshes on the CEF UI thread so OSR captures the updated
623636 // compositor frame after the zoom IPC has been processed.
624637 wv.scheduleZoomRefresh()
625- wv.scheduleZoomReadback(factor, cefLevel)
638+ wv.scheduleZoomReadback(pageZoom, cefLevel, compensation, epoch)
626639 }
627640
628641 // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
@@ -651,15 +664,15 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
651664 return errNoBrowser
652665 }
653666 surfaceScale := wv.viewBridgeScale()
654- backingScale := wv.osrBackingScaleFactor()
655- cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
667+ compensation := wv.pageZoomCompensation()
668+ cefLevel := cefZoomFromPageZoom(factor, compensation)
656669 logging.FromContext(wv.ctx).Debug().
657670 Float64("factor", factor).
658671 Float64("cef_level", cefLevel).
659- Float64("osr_backing_scale", backingScale).
672+ Float64("page_zoom_compensation", compensation).
660673 Float64("surface_scale", surfaceScale).
661674 Msg("cef: SetZoomLevel")
662- wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
675+ wv.applyZoomForCompensation(host, factor, compensation)
663676 wv.zoomFactor.Store(factor)
664677 return nil
665678 }
@@ -1376,8 +1389,11 @@ func (wv *WebView) destroyViewBridgeOnGTKThread() {
13761389 }
13771390 _ = wv.viewBridge.DetachInput()
13781391 _ = wv.viewBridge.Destroy()
1392+ wv.mu.Lock()
13791393 wv.viewBridge = nil
1394+ wv.zoomCompensation = nil
13801395 wv.nativeWidget = nil
1396+ wv.mu.Unlock()
13811397 // The bridge actually tore down here (second arrivals return early on
13821398 // the nil guard above), completing the outstanding teardown exactly once.
13831399 if wv.engine != nil {
@@ -1822,6 +1838,9 @@ func (wv *WebView) viewBridgeScale() float64 {
18221838 return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
18231839 }
18241840
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.
18251844 func (wv *WebView) osrBackingScaleFactor() float64 {
18261845 if wv == nil || wv.viewBridge == nil {
18271846 return 1
@@ -1829,23 +1848,63 @@ func (wv *WebView) osrBackingScaleFactor() float64 {
18291848 return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
18301849 }
18311850
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) {
18331869 if wv == nil {
18341870 return
18351871 }
1836- wv.lastAppliedZoomScaleRatioBits.Store(math.Float64bits(zoomScaleRatio(surfaceScale, backingScale)))
1872+ wv.lastAppliedZoomCompensationBits.Store(math.Float64bits(normalizeScale(compensation)))
18371873 }
18381874
1839-func (wv *WebView) shouldReapplyZoomForScaleRatio(surfaceScale, backingScale float64) bool {
1875+func (wv *WebView) clearAppliedZoomCompensation() {
18401876 if wv == nil {
1841- return false
1877+ return
18421878 }
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()
18451889 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 {
18461905 return true
18471906 }
1848- return math.Float64frombits(bits) != ratio
1907+ return applied != normalizeScale(compensation)
18491908 }
18501909
18511910 func (wv *WebView) handleMiddleClickFromBridge() bool {
@@ -2287,10 +2346,18 @@ func (wv *WebView) scheduleZoomRefresh() {
22872346 // process the zoom IPC and reflect the new level back to the browser.
22882347 //
22892348 // 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)
22912355 for _, delayMs := range [...]int64{0, 64} {
22922356 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 {
22942361 return
22952362 }
22962363 wv.mu.RLock()
@@ -2300,16 +2367,15 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
23002367 return
23012368 }
23022369 surfaceScale := wv.viewBridgeScale()
2303- backingScale := wv.osrBackingScaleFactor()
23042370 actualLevel := host.GetZoomLevel()
23052371 logging.FromContext(wv.ctx).Debug().
23062372 Int64("delay_ms", delayMs).
2307- Float64("expected_factor", expectedFactor).
2373+ Float64("expected_factor", expectedPageZoom).
23082374 Float64("expected_cef_level", expectedLevel).
2309- Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)).
2375+ Float64("actual_factor", pageZoomFromCEFLevel(actualLevel, compensation)).
23102376 Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).
23112377 Float64("actual_cef_level", actualLevel).
2312- Float64("osr_backing_scale", backingScale).
2378+ Float64("page_zoom_compensation", compensation).
23132379 Float64("surface_scale", surfaceScale).
23142380 Msg("cef: zoom level readback")
23152381 }))
@@ -2320,6 +2386,10 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
23202386 }
23212387 }
23222388
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.
23232393 func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
23242394 if wv == nil || wv.destroyed.Load() {
23252395 return
@@ -2330,18 +2400,15 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
23302400 if host == nil {
23312401 return
23322402 }
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)
23382406 logging.FromContext(wv.ctx).Debug().
23392407 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")
23452412 }
23462413
23472414 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 and110 // WebView implements port.WebView using a CEF off-screen browser rendered and
103 // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the111 // 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.BrowserHost119 host purecef.BrowserHost
112 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it120 client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
113 viewBridge *Cef2gtkAdapter121 viewBridge *Cef2gtkAdapter
122+ zoomCompensation pageZoomCompensationBridge
114 popupSurface *popupBridgeSurface123 popupSurface *popupBridgeSurface
115 nativeWidget *gtk.Widget124 nativeWidget *gtk.Widget
116 handlers *handlerSet125 handlers *handlerSet
@@ -283,11 +292,12 @@ type WebView struct {
283 // bridgeTeardownNoted gates quiescence accounting exactly once per292 // bridgeTeardownNoted gates quiescence accounting exactly once per
284 // view: every bridge teardown is scheduled through the Sync/Async293 // 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.Bool295+ bridgeTeardownNoted atomic.Bool
287- generation atomic.Uint64296+ generation atomic.Uint64
288- audioPlaying atomic.Bool297+ audioPlaying atomic.Bool
289- zoomFactor atomic.Value // float64, initialized to 1.0298+ zoomFactor atomic.Value // float64, initialized to 1.0
290- lastAppliedZoomScaleRatioBits atomic.Uint64299+ lastAppliedZoomCompensationBits atomic.Uint64
300+ zoomApplicationEpoch atomic.Uint64
291 301
292 // Browser creation defaults copied from the factory so native popup shells302 // 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 1608+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 a626 // 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 produce629 // 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 couple634 // 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 updated635 // 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 a641 // 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 errNoBrowser664 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 nil677 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 = nil1393 wv.viewBridge = nil
1394+ wv.zoomCompensation = nil
1380 wv.nativeWidget = nil1395 wv.nativeWidget = nil
1396+ wv.mu.Unlock()
1381 // The bridge actually tore down here (second arrivals return early on1397 // 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 11846 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 return1870 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 false1877+ 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 true1905 return true
1847 }1906 }
1848- return math.Float64frombits(bits) != ratio1907+ 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 return2361 return
2295 }2362 }
2296 wv.mu.RLock()2363 wv.mu.RLock()
@@ -2300,16 +2367,15 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
2300 return2367 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 return2395 return
@@ -2330,18 +2400,15 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
2330 if host == nil {2400 if host == nil {
2331 return2401 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(
889889
890890 h.wv.browser = browser
891891 h.wv.host = host
892- h.wv.lastAppliedZoomScaleRatioBits.Store(0)
892+ h.wv.clearAppliedZoomCompensation()
893893 h.wv.pendingCreate = nil
894894 bridge := h.wv.viewBridge
895895 h.wv.inputAttached = bridge == nil
@@ -1007,7 +1007,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
10071007 h.wv.mu.Lock()
10081008 h.wv.browser = nil
10091009 h.wv.host = nil
1010- h.wv.lastAppliedZoomScaleRatioBits.Store(0)
1010+ h.wv.clearAppliedZoomCompensation()
10111011 h.wv.inputAttached = false
10121012 if h.wv.findCtrl != nil {
10131013 h.wv.findCtrl.setHost(nil)
@@ -889,7 +889,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
889 889
890 h.wv.browser = browser890 h.wv.browser = browser
891 h.wv.host = host891 h.wv.host = host
892- h.wv.lastAppliedZoomScaleRatioBits.Store(0)892+ h.wv.clearAppliedZoomCompensation()
893 h.wv.pendingCreate = nil893 h.wv.pendingCreate = nil
894 bridge := h.wv.viewBridge894 bridge := h.wv.viewBridge
895 h.wv.inputAttached = bridge == nil895 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 = nil1008 h.wv.browser = nil
1009 h.wv.host = nil1009 h.wv.host = nil
1010- h.wv.lastAppliedZoomScaleRatioBits.Store(0)1010+ h.wv.clearAppliedZoomCompensation()
1011 h.wv.inputAttached = false1011 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 (
77 "github.com/stretchr/testify/require"
88 )
99
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+
1020 func TestDestroy_WithHostDefersBridgeDestroyUntilBeforeClose(t *testing.T) {
1121 bridge := &Cef2gtkAdapter{}
1222 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
551551 if host == nil || wv == nil {
552552 return
553553 }
554- if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) {
554+ compensation := wv.pageZoomCompensation()
555+ if !wv.shouldReapplyZoomForCompensation(compensation) {
555556 return
556557 }
557558 task := cefNewTask(cefTaskFunc(func() {
@@ -564,9 +565,9 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost
564565 if currentHost != host {
565566 return
566567 }
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()) {
570571 return
571572 }
572573 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 return552 return
553 }553 }
554- if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) {554+ compensation := wv.pageZoomCompensation()
555+ if !wv.shouldReapplyZoomForCompensation(compensation) {
555 return556 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 return566 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 return571 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 (
1111
1212 type viewportSyncOrderHost struct {
1313 purecef.BrowserHost
14- calls []string
15- zoomLevel float64
14+ calls []string
15+ zoomLevel float64
16+ getZoomLevelCalls int
1617 }
1718
1819 type pendingBrowserCreateObservedSizeBridgeStub struct {
@@ -61,6 +62,7 @@ func (h *viewportSyncOrderHost) SetZoomLevel(level float64) {
6162 }
6263
6364 func (h *viewportSyncOrderHost) GetZoomLevel() float64 {
65+ h.getZoomLevelCalls++
6466 return h.zoomLevel
6567 }
6668
@@ -208,7 +210,7 @@ func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingSc
208210 }
209211
210212 host := &viewportSyncOrderHost{}
211- wv := &WebView{ctx: context.Background(), host: host}
213+ wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}}
212214
213215 wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
214216 require.NotNil(t, scheduled)
@@ -222,6 +224,89 @@ func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingSc
222224 require.Empty(t, host.calls)
223225 }
224226
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+
225310 func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) {
226311 oldNewTask := cefNewTask
227312 oldPostDelayedTask := cefPostDelayedTask
@@ -11,8 +11,9 @@ import (
11 11
12 type viewportSyncOrderHost struct {12 type viewportSyncOrderHost struct {
13 purecef.BrowserHost13 purecef.BrowserHost
14- calls []string14+ calls []string
15- zoomLevel float6415+ 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.zoomLevel66 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 := cefNewTask311 oldNewTask := cefNewTask
227 oldPostDelayedTask := cefPostDelayedTask312 oldPostDelayedTask := cefPostDelayedTask
M internal/infrastructure/cef/webview_zoom_test.go +157 -30
@@ -1,10 +1,14 @@
11 package cef
22
33 import (
4+ "context"
45 "fmt"
6+ "math"
57 "testing"
68
9+ purecef "github.com/bnema/purego-cef/cef"
710 "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
812 )
913
1014 func TestZoomConversionsRoundTrip(t *testing.T) {
@@ -18,58 +22,181 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
1822 }
1923 }
2024
21-func TestZoomConversionsCompensateDeviceSizedOSRBackingAndSurfaceScale(t *testing.T) {
25+func TestZoomConversionsApplyUserZoomAndCompensation(t *testing.T) {
2226 tests := []struct {
2327 name string
24- surfaceScale float64
25- backingScale float64
26- pageZoom float64
28+ userZoom float64
29+ compensation float64
2730 wantInternal float64
2831 }{
2932 {
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,
3436 wantInternal: 1.0,
3537 },
3638 {
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,
4243 },
4344 {
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,
4961 },
5062 {
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,
5673 },
5774 }
5875
5976 for _, tt := range tests {
6077 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")
6481 })
6582 }
6683 }
6784
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) {
6994 wv := &WebView{}
7095
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")
72195
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")
75202 }
@@ -1,10 +1,14 @@
1 package cef1 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 string27 name string
24- surfaceScale float6428+ userZoom float64
25- backingScale float6429+ compensation float64
26- pageZoom float64
27 wantInternal float6430 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
24522452 }
24532453 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
24542454 if paneView := wsView.GetPaneView(paneID); paneView != nil {
2455- paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100))
2455+ paneView.ShowZoomToast(ctx, entity.ZoomPercentage(newZoom.ZoomFactor))
24562456 }
24572457 }
24582458 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 nil2458 return nil
M internal/ui/component/omnibox.go +2 -2
@@ -2939,10 +2939,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
29392939 if o.zoomLabel == nil {
29402940 return false
29412941 }
2942- if factor == 1.0 {
2942+ percentage := entity.ZoomPercentage(factor)
2943+ if percentage == 100 {
29432944 o.zoomLabel.SetVisible(false)
29442945 } else {
2945- percentage := int(factor * 100)
29462946 o.zoomLabel.SetText(fmt.Sprintf("%d%%", percentage))
29472947 o.zoomLabel.SetVisible(true)
29482948 }
@@ -2939,10 +2939,10 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2939 if o.zoomLabel == nil {2939 if o.zoomLabel == nil {
2940 return false2940 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
612612 d.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
613613
614614 // Show zoom toast on the active pane
615- zoomPercent := int(newZoom.ZoomFactor * 100)
615+ zoomPercent := entity.ZoomPercentage(newZoom.ZoomFactor)
616616 d.wsCoord.ShowZoomToast(ctx, zoomPercent)
617617
618618 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 pane614 // 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().

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close