Merge pull request #253 from bnema/fix/fractional-wayland-scale
Fix fractional-scale CEF OSR rendering
22 files · +416 -104
M go.mod +1 -1
| @@ -7,7 +7,7 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | 9 | github.com/bnema/purego-cef v0.13.0 | |
| 10 | - | github.com/bnema/purego-cef2gtk v0.1.1 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.2.0 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.3 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | 13 | github.com/bnema/puregotk v0.5.2 | |
| @@ -7,7 +7,7 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | github.com/bnema/purego-cef v0.13.0 | 9 | github.com/bnema/purego-cef v0.13.0 | ||
| 10 | - | github.com/bnema/purego-cef2gtk v0.1.1 | 10 | + | github.com/bnema/purego-cef2gtk v0.2.0 |
| 11 | github.com/bnema/purego-pipewire v0.1.3 | 11 | github.com/bnema/purego-pipewire v0.1.3 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | github.com/bnema/puregotk v0.5.2 | 13 | github.com/bnema/puregotk v0.5.2 | ||
M go.sum +2 -2
| @@ -14,8 +14,8 @@ github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj | |||
| 14 | 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | |
| 15 | 15 | github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnvJI= | |
| 16 | 16 | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 17 | - | github.com/bnema/purego-cef2gtk v0.1.1 h1:ZPmfFdry860/l+X+efkMNXGfqBuH+LIz2p7T5mI0pNw= | |
| 18 | - | github.com/bnema/purego-cef2gtk v0.1.1/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | |
| 17 | + | github.com/bnema/purego-cef2gtk v0.2.0 h1:IcQdV+ILHPRp0tDIHt5zEtELM8/ru7mcsPBOFPbOQ60= | |
| 18 | + | github.com/bnema/purego-cef2gtk v0.2.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | |
| 19 | 19 | github.com/bnema/purego-pipewire v0.1.3 h1:A3jYkuMe1KseJ8yr5Am1iLvRFu6yDWM2AwUIpl88460= | |
| 20 | 20 | github.com/bnema/purego-pipewire v0.1.3/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | |
| 21 | 21 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | |
| @@ -14,8 +14,8 @@ github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj | |||||
| 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | 14 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | ||
| 15 | github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnvJI= | 15 | github.com/bnema/purego-cef v0.13.0 h1:TNPY19mBOqlJeFi5CZCLJIDfAgTEMp9tviQEyZgnvJI= | ||
| 16 | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | 16 | github.com/bnema/purego-cef v0.13.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | ||
| 17 | - | github.com/bnema/purego-cef2gtk v0.1.1 h1:ZPmfFdry860/l+X+efkMNXGfqBuH+LIz2p7T5mI0pNw= | 17 | + | github.com/bnema/purego-cef2gtk v0.2.0 h1:IcQdV+ILHPRp0tDIHt5zEtELM8/ru7mcsPBOFPbOQ60= |
| 18 | - | github.com/bnema/purego-cef2gtk v0.1.1/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= | 18 | + | github.com/bnema/purego-cef2gtk v0.2.0/go.mod h1:xDVdLTepKdPngYlwT/BscGHh5ohaCB4LLUeThW/Ml7c= |
| 19 | github.com/bnema/purego-pipewire v0.1.3 h1:A3jYkuMe1KseJ8yr5Am1iLvRFu6yDWM2AwUIpl88460= | 19 | github.com/bnema/purego-pipewire v0.1.3 h1:A3jYkuMe1KseJ8yr5Am1iLvRFu6yDWM2AwUIpl88460= | ||
| 20 | github.com/bnema/purego-pipewire v0.1.3/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | 20 | github.com/bnema/purego-pipewire v0.1.3/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8= | ||
| 21 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | 21 | github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE= | ||
M internal/infrastructure/cef/cef2gtk_adapter.go +15 -0
| @@ -107,6 +107,21 @@ func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 { | |||
| 107 | 107 | return a.view.DeviceScaleFactor() | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | + | // OSRBackingScaleFactor returns the device backing scale used for CEF OSR | |
| 111 | + | // coordinates. It returns 1 when purego-cef2gtk is using CEF's normal logical | |
| 112 | + | // OSR contract. | |
| 113 | + | func (a *Cef2gtkAdapter) OSRBackingScaleFactor() float64 { | |
| 114 | + | if a == nil || a.destroyed.Load() { | |
| 115 | + | return 1 | |
| 116 | + | } | |
| 117 | + | a.viewMu.RLock() | |
| 118 | + | defer a.viewMu.RUnlock() | |
| 119 | + | if a.view == nil { | |
| 120 | + | return 1 | |
| 121 | + | } | |
| 122 | + | return cef2gtk.OSRBackingScaleFactorForScale(float64(a.view.DeviceScaleFactor())) | |
| 123 | + | } | |
| 124 | + | ||
| 110 | 125 | // AddSizeObserver registers a positive-size change callback. Register and | |
| 111 | 126 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | |
| 112 | 127 | // GTK size notifications. | |
| @@ -107,6 +107,21 @@ func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 { | |||||
| 107 | return a.view.DeviceScaleFactor() | 107 | return a.view.DeviceScaleFactor() | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | + | // OSRBackingScaleFactor returns the device backing scale used for CEF OSR | |||
| 111 | + | // coordinates. It returns 1 when purego-cef2gtk is using CEF's normal logical | |||
| 112 | + | // OSR contract. | |||
| 113 | + | func (a *Cef2gtkAdapter) OSRBackingScaleFactor() float64 { | |||
| 114 | + | if a == nil || a.destroyed.Load() { | |||
| 115 | + | return 1 | |||
| 116 | + | } | |||
| 117 | + | a.viewMu.RLock() | |||
| 118 | + | defer a.viewMu.RUnlock() | |||
| 119 | + | if a.view == nil { | |||
| 120 | + | return 1 | |||
| 121 | + | } | |||
| 122 | + | return cef2gtk.OSRBackingScaleFactorForScale(float64(a.view.DeviceScaleFactor())) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 110 | // AddSizeObserver registers a positive-size change callback. Register and | 125 | // AddSizeObserver registers a positive-size change callback. Register and | ||
| 111 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | 126 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | ||
| 112 | // GTK size notifications. | 127 | // GTK size notifications. | ||
M internal/infrastructure/cef/context_menu.go +6 -6
| @@ -79,7 +79,7 @@ func (h *handlerSet) RunContextMenu( | |||
| 79 | 79 | return 0 | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | - | x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale()) | |
| 82 | + | x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor()) | |
| 83 | 83 | executor := h.contextMenuExecutor() | |
| 84 | 84 | wv := h.wv | |
| 85 | 85 | wv.runOnGTK(func() { | |
| @@ -126,14 +126,14 @@ func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor { | |||
| 126 | 126 | ) | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) { | |
| 129 | + | func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) { | |
| 130 | 130 | if params == nil { | |
| 131 | 131 | return 0, 0 | |
| 132 | 132 | } | |
| 133 | - | if scale <= 0 { | |
| 134 | - | scale = 1 | |
| 133 | + | if backingScale <= 1 { | |
| 134 | + | return params.GetXcoord(), params.GetYcoord() | |
| 135 | 135 | } | |
| 136 | - | return params.GetXcoord() / scale, params.GetYcoord() / scale | |
| 136 | + | return deviceToLogicalCoord(params.GetXcoord(), scale), deviceToLogicalCoord(params.GetYcoord(), scale) | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | 139 | func logContextMenuPopupRequest( | |
| @@ -152,7 +152,7 @@ func logContextMenuPopupRequest( | |||
| 152 | 152 | Int32("raw_y", rawY). | |
| 153 | 153 | Int32("popup_x", x). | |
| 154 | 154 | Int32("popup_y", y). | |
| 155 | - | Int32("scale", h.wv.viewBridgeScale()). | |
| 155 | + | Float64("scale", h.wv.viewBridgeScale()). | |
| 156 | 156 | Int("anchor_width", widget.GetAllocatedWidth()). | |
| 157 | 157 | Int("anchor_height", widget.GetAllocatedHeight()). | |
| 158 | 158 | Int("parent_width", cefWidgetAllocatedWidth(parent)). | |
| @@ -79,7 +79,7 @@ func (h *handlerSet) RunContextMenu( | |||||
| 79 | return 0 | 79 | return 0 | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | - | x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale()) | 82 | + | x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor()) |
| 83 | executor := h.contextMenuExecutor() | 83 | executor := h.contextMenuExecutor() | ||
| 84 | wv := h.wv | 84 | wv := h.wv | ||
| 85 | wv.runOnGTK(func() { | 85 | wv.runOnGTK(func() { | ||
| @@ -126,14 +126,14 @@ func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor { | |||||
| 126 | ) | 126 | ) | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) { | 129 | + | func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) { |
| 130 | if params == nil { | 130 | if params == nil { | ||
| 131 | return 0, 0 | 131 | return 0, 0 | ||
| 132 | } | 132 | } | ||
| 133 | - | if scale <= 0 { | 133 | + | if backingScale <= 1 { |
| 134 | - | scale = 1 | 134 | + | return params.GetXcoord(), params.GetYcoord() |
| 135 | } | 135 | } | ||
| 136 | - | return params.GetXcoord() / scale, params.GetYcoord() / scale | 136 | + | return deviceToLogicalCoord(params.GetXcoord(), scale), deviceToLogicalCoord(params.GetYcoord(), scale) |
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | func logContextMenuPopupRequest( | 139 | func logContextMenuPopupRequest( | ||
| @@ -152,7 +152,7 @@ func logContextMenuPopupRequest( | |||||
| 152 | Int32("raw_y", rawY). | 152 | Int32("raw_y", rawY). | ||
| 153 | Int32("popup_x", x). | 153 | Int32("popup_x", x). | ||
| 154 | Int32("popup_y", y). | 154 | Int32("popup_y", y). | ||
| 155 | - | Int32("scale", h.wv.viewBridgeScale()). | 155 | + | Float64("scale", h.wv.viewBridgeScale()). |
| 156 | Int("anchor_width", widget.GetAllocatedWidth()). | 156 | Int("anchor_width", widget.GetAllocatedWidth()). | ||
| 157 | Int("anchor_height", widget.GetAllocatedHeight()). | 157 | Int("anchor_height", widget.GetAllocatedHeight()). | ||
| 158 | Int("parent_width", cefWidgetAllocatedWidth(parent)). | 158 | Int("parent_width", cefWidgetAllocatedWidth(parent)). | ||
M internal/infrastructure/cef/context_menu_adapter_test.go +8 -4
| @@ -216,11 +216,15 @@ func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t | |||
| 216 | 216 | require.Zero(t, executor.executeCalls) | |
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | - | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | |
| 220 | - | x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 2) | |
| 219 | + | func TestContextMenuAnchorPositionScalesCEFCoordinatesForDeviceBacking(t *testing.T) { | |
| 220 | + | x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 1.25, 1) | |
| 221 | 221 | ||
| 222 | - | require.Equal(t, int32(160), x) | |
| 223 | - | require.Equal(t, int32(90), y) | |
| 222 | + | require.Equal(t, int32(320), x) | |
| 223 | + | require.Equal(t, int32(180), y) | |
| 224 | + | ||
| 225 | + | x, y = contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 1.25, 1.25) | |
| 226 | + | require.Equal(t, int32(256), x) | |
| 227 | + | require.Equal(t, int32(144), y) | |
| 224 | 228 | } | |
| 225 | 229 | ||
| 226 | 230 | func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) { | |
| @@ -216,11 +216,15 @@ func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t | |||||
| 216 | require.Zero(t, executor.executeCalls) | 216 | require.Zero(t, executor.executeCalls) | ||
| 217 | } | 217 | } | ||
| 218 | 218 | ||||
| 219 | - | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | 219 | + | func TestContextMenuAnchorPositionScalesCEFCoordinatesForDeviceBacking(t *testing.T) { |
| 220 | - | x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 2) | 220 | + | x, y := contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 1.25, 1) |
| 221 | 221 | ||||
| 222 | - | require.Equal(t, int32(160), x) | 222 | + | require.Equal(t, int32(320), x) |
| 223 | - | require.Equal(t, int32(90), y) | 223 | + | require.Equal(t, int32(180), y) |
| 224 | + | ||||
| 225 | + | x, y = contextMenuAnchorPosition(stubContextMenuParams{x: 320, y: 180}, 1.25, 1.25) | |||
| 226 | + | require.Equal(t, int32(256), x) | |||
| 227 | + | require.Equal(t, int32(144), y) | |||
| 224 | } | 228 | } | ||
| 225 | 229 | ||||
| 226 | func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) { | 230 | func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) { | ||
M internal/infrastructure/cef/engine_init.go +0 -35
| @@ -208,7 +208,6 @@ func wireEngine( | |||
| 208 | 208 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| 209 | 209 | ) (*Engine, error) { | |
| 210 | 210 | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | |
| 211 | - | scale: detectHiDPIScale(logger), | |
| 212 | 211 | windowlessFrameRate: windowlessFrameRate, | |
| 213 | 212 | audioOutputFactory: audioFactory, | |
| 214 | 213 | }) | |
| @@ -315,40 +314,6 @@ func registerEngineSchemeFactory( | |||
| 315 | 314 | Msg("cef: registered internal https handler factory") | |
| 316 | 315 | } | |
| 317 | 316 | ||
| 318 | - | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | |
| 319 | - | func getPrimaryMonitor() *gdk.Monitor { | |
| 320 | - | display := gdk.DisplayGetDefault() | |
| 321 | - | if display == nil { | |
| 322 | - | return nil | |
| 323 | - | } | |
| 324 | - | monitors := display.GetMonitors() | |
| 325 | - | if monitors == nil { | |
| 326 | - | return nil | |
| 327 | - | } | |
| 328 | - | obj := monitors.GetObject(0) | |
| 329 | - | if obj == nil { | |
| 330 | - | return nil | |
| 331 | - | } | |
| 332 | - | mon := &gdk.Monitor{} | |
| 333 | - | mon.SetGoPointer(obj.GoPointer()) | |
| 334 | - | return mon | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | // detectHiDPIScale queries the primary GDK monitor for its scale factor. | |
| 338 | - | func detectHiDPIScale(logger *zerolog.Logger) int32 { | |
| 339 | - | mon := getPrimaryMonitor() | |
| 340 | - | if mon == nil { | |
| 341 | - | logger.Debug().Msg("cef: no primary monitor, using scale=1") | |
| 342 | - | return 1 | |
| 343 | - | } | |
| 344 | - | if s := mon.GetScaleFactor(); s > 0 { | |
| 345 | - | logger.Info().Int32("scale", int32(s)).Msg("cef: detected HiDPI scale from monitor") | |
| 346 | - | return int32(s) | |
| 347 | - | } | |
| 348 | - | logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1") | |
| 349 | - | return 1 | |
| 350 | - | } | |
| 351 | - | ||
| 352 | 317 | // appendIfMissing appends flag to args if it's not already present. | |
| 353 | 318 | func appendIfMissing(args []string, flag string) []string { | |
| 354 | 319 | for _, a := range args { | |
| @@ -208,7 +208,6 @@ func wireEngine( | |||||
| 208 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | 208 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | ||
| 209 | ) (*Engine, error) { | 209 | ) (*Engine, error) { | ||
| 210 | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | 210 | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | ||
| 211 | - | scale: detectHiDPIScale(logger), | |||
| 212 | windowlessFrameRate: windowlessFrameRate, | 211 | windowlessFrameRate: windowlessFrameRate, | ||
| 213 | audioOutputFactory: audioFactory, | 212 | audioOutputFactory: audioFactory, | ||
| 214 | }) | 213 | }) | ||
| @@ -315,40 +314,6 @@ func registerEngineSchemeFactory( | |||||
| 315 | Msg("cef: registered internal https handler factory") | 314 | Msg("cef: registered internal https handler factory") | ||
| 316 | } | 315 | } | ||
| 317 | 316 | ||||
| 318 | - | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | |||
| 319 | - | func getPrimaryMonitor() *gdk.Monitor { | |||
| 320 | - | display := gdk.DisplayGetDefault() | |||
| 321 | - | if display == nil { | |||
| 322 | - | return nil | |||
| 323 | - | } | |||
| 324 | - | monitors := display.GetMonitors() | |||
| 325 | - | if monitors == nil { | |||
| 326 | - | return nil | |||
| 327 | - | } | |||
| 328 | - | obj := monitors.GetObject(0) | |||
| 329 | - | if obj == nil { | |||
| 330 | - | return nil | |||
| 331 | - | } | |||
| 332 | - | mon := &gdk.Monitor{} | |||
| 333 | - | mon.SetGoPointer(obj.GoPointer()) | |||
| 334 | - | return mon | |||
| 335 | - | } | |||
| 336 | - | ||||
| 337 | - | // detectHiDPIScale queries the primary GDK monitor for its scale factor. | |||
| 338 | - | func detectHiDPIScale(logger *zerolog.Logger) int32 { | |||
| 339 | - | mon := getPrimaryMonitor() | |||
| 340 | - | if mon == nil { | |||
| 341 | - | logger.Debug().Msg("cef: no primary monitor, using scale=1") | |||
| 342 | - | return 1 | |||
| 343 | - | } | |||
| 344 | - | if s := mon.GetScaleFactor(); s > 0 { | |||
| 345 | - | logger.Info().Int32("scale", int32(s)).Msg("cef: detected HiDPI scale from monitor") | |||
| 346 | - | return int32(s) | |||
| 347 | - | } | |||
| 348 | - | logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1") | |||
| 349 | - | return 1 | |||
| 350 | - | } | |||
| 351 | - | ||||
| 352 | // appendIfMissing appends flag to args if it's not already present. | 317 | // appendIfMissing appends flag to args if it's not already present. | ||
| 353 | func appendIfMissing(args []string, flag string) []string { | 318 | func appendIfMissing(args []string, flag string) []string { | ||
| 354 | for _, a := range args { | 319 | for _, a := range args { | ||
M internal/infrastructure/cef/env.go +6 -0
| @@ -13,6 +13,8 @@ const ( | |||
| 13 | 13 | cefRenderNodeEnvVar = "DUMBER_CEF_RENDER_NODE" | |
| 14 | 14 | cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY" | |
| 15 | 15 | cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE" | |
| 16 | + | cefScaleProbeEnvVar = "DUMBER_CEF_SCALE_PROBE" | |
| 17 | + | cef2GTKTraceScaleEnvVar = "PUREGO_CEF2GTK_TRACE_SCALE" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | 20 | // envBoolEnabled returns true when the given environment variable is set | |
| @@ -41,3 +43,7 @@ func renderStallRecoveryEnabled() bool { | |||
| 41 | 43 | func renderStallBacktraceEnabled() bool { | |
| 42 | 44 | return envBoolEnabled(cefRenderStallBacktraceEnvVar) | |
| 43 | 45 | } | |
| 46 | + | ||
| 47 | + | func cefScaleProbeEnabled() bool { | |
| 48 | + | return envBoolEnabled(cefScaleProbeEnvVar) || os.Getenv(cef2GTKTraceScaleEnvVar) != "" | |
| 49 | + | } | |
| @@ -13,6 +13,8 @@ const ( | |||||
| 13 | cefRenderNodeEnvVar = "DUMBER_CEF_RENDER_NODE" | 13 | cefRenderNodeEnvVar = "DUMBER_CEF_RENDER_NODE" | ||
| 14 | cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY" | 14 | cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY" | ||
| 15 | cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE" | 15 | cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE" | ||
| 16 | + | cefScaleProbeEnvVar = "DUMBER_CEF_SCALE_PROBE" | |||
| 17 | + | cef2GTKTraceScaleEnvVar = "PUREGO_CEF2GTK_TRACE_SCALE" | |||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 18 | // envBoolEnabled returns true when the given environment variable is set | 20 | // envBoolEnabled returns true when the given environment variable is set | ||
| @@ -41,3 +43,7 @@ func renderStallRecoveryEnabled() bool { | |||||
| 41 | func renderStallBacktraceEnabled() bool { | 43 | func renderStallBacktraceEnabled() bool { | ||
| 42 | return envBoolEnabled(cefRenderStallBacktraceEnvVar) | 44 | return envBoolEnabled(cefRenderStallBacktraceEnvVar) | ||
| 43 | } | 45 | } | ||
| 46 | + | ||||
| 47 | + | func cefScaleProbeEnabled() bool { | |||
| 48 | + | return envBoolEnabled(cefScaleProbeEnvVar) || os.Getenv(cef2GTKTraceScaleEnvVar) != "" | |||
| 49 | + | } | |||
M internal/infrastructure/cef/factory.go +1 -8
| @@ -22,14 +22,12 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||
| 22 | 22 | type WebViewFactory struct { | |
| 23 | 23 | engine *Engine | |
| 24 | 24 | nextID atomic.Uint64 | |
| 25 | - | scale int32 | |
| 26 | 25 | windowlessFrameRate int32 | |
| 27 | 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | |
| 28 | 27 | audioOutputFactory port.AudioOutputFactory | |
| 29 | 28 | } | |
| 30 | 29 | ||
| 31 | 30 | type webViewFactoryOptions struct { | |
| 32 | - | scale int32 | |
| 33 | 31 | windowlessFrameRate int32 | |
| 34 | 32 | audioOutputFactory port.AudioOutputFactory | |
| 35 | 33 | } | |
| @@ -46,18 +44,13 @@ const ( | |||
| 46 | 44 | pendingBrowserCreateMaxPostRetries = 3 | |
| 47 | 45 | ) | |
| 48 | 46 | ||
| 49 | - | // newWebViewFactory returns a factory that will create WebViews using the | |
| 50 | - | // given HiDPI scale factor. | |
| 47 | + | // newWebViewFactory returns a factory that will create WebViews. | |
| 51 | 48 | func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory { | |
| 52 | - | if opts.scale < 1 { | |
| 53 | - | opts.scale = 1 | |
| 54 | - | } | |
| 55 | 49 | if opts.windowlessFrameRate < 1 { | |
| 56 | 50 | opts.windowlessFrameRate = 60 | |
| 57 | 51 | } | |
| 58 | 52 | return &WebViewFactory{ | |
| 59 | 53 | engine: engine, | |
| 60 | - | scale: opts.scale, | |
| 61 | 54 | windowlessFrameRate: opts.windowlessFrameRate, | |
| 62 | 55 | audioOutputFactory: opts.audioOutputFactory, | |
| 63 | 56 | } | |
| @@ -22,14 +22,12 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil) | |||||
| 22 | type WebViewFactory struct { | 22 | type WebViewFactory struct { | ||
| 23 | engine *Engine | 23 | engine *Engine | ||
| 24 | nextID atomic.Uint64 | 24 | nextID atomic.Uint64 | ||
| 25 | - | scale int32 | |||
| 26 | windowlessFrameRate int32 | 25 | windowlessFrameRate int32 | ||
| 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 26 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | ||
| 28 | audioOutputFactory port.AudioOutputFactory | 27 | audioOutputFactory port.AudioOutputFactory | ||
| 29 | } | 28 | } | ||
| 30 | 29 | ||||
| 31 | type webViewFactoryOptions struct { | 30 | type webViewFactoryOptions struct { | ||
| 32 | - | scale int32 | |||
| 33 | windowlessFrameRate int32 | 31 | windowlessFrameRate int32 | ||
| 34 | audioOutputFactory port.AudioOutputFactory | 32 | audioOutputFactory port.AudioOutputFactory | ||
| 35 | } | 33 | } | ||
| @@ -46,18 +44,13 @@ const ( | |||||
| 46 | pendingBrowserCreateMaxPostRetries = 3 | 44 | pendingBrowserCreateMaxPostRetries = 3 | ||
| 47 | ) | 45 | ) | ||
| 48 | 46 | ||||
| 49 | - | // newWebViewFactory returns a factory that will create WebViews using the | 47 | + | // newWebViewFactory returns a factory that will create WebViews. |
| 50 | - | // given HiDPI scale factor. | |||
| 51 | func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory { | 48 | func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory { | ||
| 52 | - | if opts.scale < 1 { | |||
| 53 | - | opts.scale = 1 | |||
| 54 | - | } | |||
| 55 | if opts.windowlessFrameRate < 1 { | 49 | if opts.windowlessFrameRate < 1 { | ||
| 56 | opts.windowlessFrameRate = 60 | 50 | opts.windowlessFrameRate = 60 | ||
| 57 | } | 51 | } | ||
| 58 | return &WebViewFactory{ | 52 | return &WebViewFactory{ | ||
| 59 | engine: engine, | 53 | engine: engine, | ||
| 60 | - | scale: opts.scale, | |||
| 61 | windowlessFrameRate: opts.windowlessFrameRate, | 54 | windowlessFrameRate: opts.windowlessFrameRate, | ||
| 62 | audioOutputFactory: opts.audioOutputFactory, | 55 | audioOutputFactory: opts.audioOutputFactory, | ||
| 63 | } | 56 | } | ||
M internal/infrastructure/cef/process_diagnostics.go +2 -1
| @@ -94,7 +94,8 @@ func safeChromiumCmdlineFlags(cmdline string) []string { | |||
| 94 | 94 | var safe string | |
| 95 | 95 | switch name { | |
| 96 | 96 | case "--type", "--lang", "--enable-features", "--disable-features", "--ozone-platform", | |
| 97 | - | "--use-gl", "--use-angle", "--enable-logging", "--log-severity": | |
| 97 | + | "--use-gl", "--use-angle", "--enable-logging", "--log-severity", | |
| 98 | + | "--force-device-scale-factor", "--high-dpi-support", "--enable-use-zoom-for-dsf": | |
| 98 | 99 | if hasValue && value != "" { | |
| 99 | 100 | safe = name + "=" + value | |
| 100 | 101 | } else { | |
| @@ -94,7 +94,8 @@ func safeChromiumCmdlineFlags(cmdline string) []string { | |||||
| 94 | var safe string | 94 | var safe string | ||
| 95 | switch name { | 95 | switch name { | ||
| 96 | case "--type", "--lang", "--enable-features", "--disable-features", "--ozone-platform", | 96 | case "--type", "--lang", "--enable-features", "--disable-features", "--ozone-platform", | ||
| 97 | - | "--use-gl", "--use-angle", "--enable-logging", "--log-severity": | 97 | + | "--use-gl", "--use-angle", "--enable-logging", "--log-severity", |
| 98 | + | "--force-device-scale-factor", "--high-dpi-support", "--enable-use-zoom-for-dsf": | |||
| 98 | if hasValue && value != "" { | 99 | if hasValue && value != "" { | ||
| 99 | safe = name + "=" + value | 100 | safe = name + "=" + value | ||
| 100 | } else { | 101 | } else { | ||
M internal/infrastructure/cef/render_stack_env.go +16 -13
| @@ -12,19 +12,20 @@ const ( | |||
| 12 | 12 | dumberRenderStackEnvVar = "DUMBER_RENDER_STACK" | |
| 13 | 13 | dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT" | |
| 14 | 14 | ||
| 15 | - | cefEngineType = "cef" | |
| 16 | - | renderStackAuto = "auto" | |
| 17 | - | renderStackVulkanDMABUF = "vulkan-dmabuf" | |
| 18 | - | renderStackLegacyGL = "legacy-gl" | |
| 19 | - | cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND" | |
| 20 | - | cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND" | |
| 21 | - | gskRendererEnvVar = "GSK_RENDERER" | |
| 22 | - | cef2gtkBackendGDKDMABUF = "gdk-dmabuf" | |
| 23 | - | cef2gtkBackendGLArea = "glarea" | |
| 24 | - | cef2gtkAngleVulkan = "vulkan" | |
| 25 | - | cef2gtkAngleGLEGL = "gl-egl" | |
| 26 | - | gskRendererVulkan = "vulkan" | |
| 27 | - | gskRendererOpenGL = "opengl" | |
| 15 | + | cefEngineType = "cef" | |
| 16 | + | renderStackAuto = "auto" | |
| 17 | + | renderStackVulkanDMABUF = "vulkan-dmabuf" | |
| 18 | + | renderStackLegacyGL = "legacy-gl" | |
| 19 | + | cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND" | |
| 20 | + | cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND" | |
| 21 | + | cef2gtkOSRBackingScaleEnvVar = "PUREGO_CEF2GTK_OSR_BACKING_SCALE" | |
| 22 | + | gskRendererEnvVar = "GSK_RENDERER" | |
| 23 | + | cef2gtkBackendGDKDMABUF = "gdk-dmabuf" | |
| 24 | + | cef2gtkBackendGLArea = "glarea" | |
| 25 | + | cef2gtkAngleVulkan = "vulkan" | |
| 26 | + | cef2gtkAngleGLEGL = "gl-egl" | |
| 27 | + | gskRendererVulkan = "vulkan" | |
| 28 | + | gskRendererOpenGL = "opengl" | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| 30 | 31 | // ApplyDefaultRenderStackEnvironment configures the process environment for | |
| @@ -54,6 +55,7 @@ func applyDefaultRenderStackEnvironment(logger port.Logger) string { | |||
| 54 | 55 | setRenderStackEnv(logger, gskRendererEnvVar, gskRendererVulkan) | |
| 55 | 56 | setRenderStackEnv(logger, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF) | |
| 56 | 57 | setRenderStackEnv(logger, cef2gtkAngleBackendVar, cef2gtkAngleVulkan) | |
| 58 | + | setEnvDefault(logger, cef2gtkOSRBackingScaleEnvVar, "auto") | |
| 57 | 59 | } | |
| 58 | 60 | logRenderStackEnvironment(logger, stack) | |
| 59 | 61 | return stack | |
| @@ -65,6 +67,7 @@ func logRenderStackEnvironment(logger port.Logger, stack string) { | |||
| 65 | 67 | port.Field("gsk_renderer", os.Getenv(gskRendererEnvVar)), | |
| 66 | 68 | port.Field("cef2gtk_backend", os.Getenv(cef2gtkBackendEnvVar)), | |
| 67 | 69 | port.Field("cef2gtk_angle_backend", os.Getenv(cef2gtkAngleBackendVar)), | |
| 70 | + | port.Field("cef2gtk_osr_backing_scale", os.Getenv(cef2gtkOSRBackingScaleEnvVar)), | |
| 68 | 71 | port.Field("split_stack_allowed", envBoolEnabled(dumberRenderStackAllowSplitEnvVar)), | |
| 69 | 72 | ) | |
| 70 | 73 | } | |
| @@ -12,19 +12,20 @@ const ( | |||||
| 12 | dumberRenderStackEnvVar = "DUMBER_RENDER_STACK" | 12 | dumberRenderStackEnvVar = "DUMBER_RENDER_STACK" | ||
| 13 | dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT" | 13 | dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT" | ||
| 14 | 14 | ||||
| 15 | - | cefEngineType = "cef" | 15 | + | cefEngineType = "cef" |
| 16 | - | renderStackAuto = "auto" | 16 | + | renderStackAuto = "auto" |
| 17 | - | renderStackVulkanDMABUF = "vulkan-dmabuf" | 17 | + | renderStackVulkanDMABUF = "vulkan-dmabuf" |
| 18 | - | renderStackLegacyGL = "legacy-gl" | 18 | + | renderStackLegacyGL = "legacy-gl" |
| 19 | - | cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND" | 19 | + | cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND" |
| 20 | - | cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND" | 20 | + | cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND" |
| 21 | - | gskRendererEnvVar = "GSK_RENDERER" | 21 | + | cef2gtkOSRBackingScaleEnvVar = "PUREGO_CEF2GTK_OSR_BACKING_SCALE" |
| 22 | - | cef2gtkBackendGDKDMABUF = "gdk-dmabuf" | 22 | + | gskRendererEnvVar = "GSK_RENDERER" |
| 23 | - | cef2gtkBackendGLArea = "glarea" | 23 | + | cef2gtkBackendGDKDMABUF = "gdk-dmabuf" |
| 24 | - | cef2gtkAngleVulkan = "vulkan" | 24 | + | cef2gtkBackendGLArea = "glarea" |
| 25 | - | cef2gtkAngleGLEGL = "gl-egl" | 25 | + | cef2gtkAngleVulkan = "vulkan" |
| 26 | - | gskRendererVulkan = "vulkan" | 26 | + | cef2gtkAngleGLEGL = "gl-egl" |
| 27 | - | gskRendererOpenGL = "opengl" | 27 | + | gskRendererVulkan = "vulkan" |
| 28 | + | gskRendererOpenGL = "opengl" | |||
| 28 | ) | 29 | ) | ||
| 29 | 30 | ||||
| 30 | // ApplyDefaultRenderStackEnvironment configures the process environment for | 31 | // ApplyDefaultRenderStackEnvironment configures the process environment for | ||
| @@ -54,6 +55,7 @@ func applyDefaultRenderStackEnvironment(logger port.Logger) string { | |||||
| 54 | setRenderStackEnv(logger, gskRendererEnvVar, gskRendererVulkan) | 55 | setRenderStackEnv(logger, gskRendererEnvVar, gskRendererVulkan) | ||
| 55 | setRenderStackEnv(logger, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF) | 56 | setRenderStackEnv(logger, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF) | ||
| 56 | setRenderStackEnv(logger, cef2gtkAngleBackendVar, cef2gtkAngleVulkan) | 57 | setRenderStackEnv(logger, cef2gtkAngleBackendVar, cef2gtkAngleVulkan) | ||
| 58 | + | setEnvDefault(logger, cef2gtkOSRBackingScaleEnvVar, "auto") | |||
| 57 | } | 59 | } | ||
| 58 | logRenderStackEnvironment(logger, stack) | 60 | logRenderStackEnvironment(logger, stack) | ||
| 59 | return stack | 61 | return stack | ||
| @@ -65,6 +67,7 @@ func logRenderStackEnvironment(logger port.Logger, stack string) { | |||||
| 65 | port.Field("gsk_renderer", os.Getenv(gskRendererEnvVar)), | 67 | port.Field("gsk_renderer", os.Getenv(gskRendererEnvVar)), | ||
| 66 | port.Field("cef2gtk_backend", os.Getenv(cef2gtkBackendEnvVar)), | 68 | port.Field("cef2gtk_backend", os.Getenv(cef2gtkBackendEnvVar)), | ||
| 67 | port.Field("cef2gtk_angle_backend", os.Getenv(cef2gtkAngleBackendVar)), | 69 | port.Field("cef2gtk_angle_backend", os.Getenv(cef2gtkAngleBackendVar)), | ||
| 70 | + | port.Field("cef2gtk_osr_backing_scale", os.Getenv(cef2gtkOSRBackingScaleEnvVar)), | |||
| 68 | port.Field("split_stack_allowed", envBoolEnabled(dumberRenderStackAllowSplitEnvVar)), | 71 | port.Field("split_stack_allowed", envBoolEnabled(dumberRenderStackAllowSplitEnvVar)), | ||
| 69 | ) | 72 | ) | ||
| 70 | } | 73 | } | ||
M internal/infrastructure/cef/render_stack_env_test.go +16 -0
| @@ -13,6 +13,7 @@ func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t | |||
| 13 | 13 | t.Setenv("GSK_RENDERER", "") | |
| 14 | 14 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | |
| 15 | 15 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | |
| 16 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "") | |
| 16 | 17 | ||
| 17 | 18 | got := applyDefaultRenderStackEnvironment(nil) | |
| 18 | 19 | ||
| @@ -28,6 +29,9 @@ func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t | |||
| 28 | 29 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | |
| 29 | 30 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want vulkan", got) | |
| 30 | 31 | } | |
| 32 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "auto" { | |
| 33 | + | t.Fatalf("%s = %q, want auto", cef2gtkOSRBackingScaleEnvVar, got) | |
| 34 | + | } | |
| 31 | 35 | } | |
| 32 | 36 | ||
| 33 | 37 | func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults(t *testing.T) { | |
| @@ -36,6 +40,7 @@ func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults | |||
| 36 | 40 | t.Setenv("GSK_RENDERER", "ngl") | |
| 37 | 41 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | |
| 38 | 42 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | |
| 43 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "") | |
| 39 | 44 | ||
| 40 | 45 | got := applyDefaultRenderStackEnvironment(nil) | |
| 41 | 46 | ||
| @@ -51,6 +56,9 @@ func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults | |||
| 51 | 56 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | |
| 52 | 57 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want coherent vulkan default", got) | |
| 53 | 58 | } | |
| 59 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "auto" { | |
| 60 | + | t.Fatalf("%s = %q, want auto", cef2gtkOSRBackingScaleEnvVar, got) | |
| 61 | + | } | |
| 54 | 62 | } | |
| 55 | 63 | ||
| 56 | 64 | func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelOverrides(t *testing.T) { | |
| @@ -59,6 +67,7 @@ func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelO | |||
| 59 | 67 | t.Setenv("GSK_RENDERER", "ngl") | |
| 60 | 68 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | |
| 61 | 69 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | |
| 70 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "off") | |
| 62 | 71 | ||
| 63 | 72 | got := applyDefaultRenderStackEnvironment(nil) | |
| 64 | 73 | ||
| @@ -74,6 +83,9 @@ func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelO | |||
| 74 | 83 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | |
| 75 | 84 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got) | |
| 76 | 85 | } | |
| 86 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "off" { | |
| 87 | + | t.Fatalf("%s = %q, want explicit off", cef2gtkOSRBackingScaleEnvVar, got) | |
| 88 | + | } | |
| 77 | 89 | } | |
| 78 | 90 | ||
| 79 | 91 | func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | |
| @@ -81,6 +93,7 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | |||
| 81 | 93 | t.Setenv("GSK_RENDERER", "") | |
| 82 | 94 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | |
| 83 | 95 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | |
| 96 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "") | |
| 84 | 97 | ||
| 85 | 98 | got := applyDefaultRenderStackEnvironment(nil) | |
| 86 | 99 | ||
| @@ -93,6 +106,9 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | |||
| 93 | 106 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | |
| 94 | 107 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got) | |
| 95 | 108 | } | |
| 109 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "" { | |
| 110 | + | t.Fatalf("%s = %q, want unset for legacy-gl", cef2gtkOSRBackingScaleEnvVar, got) | |
| 111 | + | } | |
| 96 | 112 | } | |
| 97 | 113 | ||
| 98 | 114 | func TestNormalizeRenderStackUnknownFallsBackToGDKDMABUF(t *testing.T) { | |
| @@ -13,6 +13,7 @@ func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t | |||||
| 13 | t.Setenv("GSK_RENDERER", "") | 13 | t.Setenv("GSK_RENDERER", "") | ||
| 14 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | 14 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | ||
| 15 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | 15 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | ||
| 16 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "") | |||
| 16 | 17 | ||||
| 17 | got := applyDefaultRenderStackEnvironment(nil) | 18 | got := applyDefaultRenderStackEnvironment(nil) | ||
| 18 | 19 | ||||
| @@ -28,6 +29,9 @@ func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t | |||||
| 28 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | 29 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | ||
| 29 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want vulkan", got) | 30 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want vulkan", got) | ||
| 30 | } | 31 | } | ||
| 32 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "auto" { | |||
| 33 | + | t.Fatalf("%s = %q, want auto", cef2gtkOSRBackingScaleEnvVar, got) | |||
| 34 | + | } | |||
| 31 | } | 35 | } | ||
| 32 | 36 | ||||
| 33 | func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults(t *testing.T) { | 37 | func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults(t *testing.T) { | ||
| @@ -36,6 +40,7 @@ func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults | |||||
| 36 | t.Setenv("GSK_RENDERER", "ngl") | 40 | t.Setenv("GSK_RENDERER", "ngl") | ||
| 37 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | 41 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | ||
| 38 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | 42 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | ||
| 43 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "") | |||
| 39 | 44 | ||||
| 40 | got := applyDefaultRenderStackEnvironment(nil) | 45 | got := applyDefaultRenderStackEnvironment(nil) | ||
| 41 | 46 | ||||
| @@ -51,6 +56,9 @@ func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults | |||||
| 51 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | 56 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | ||
| 52 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want coherent vulkan default", got) | 57 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want coherent vulkan default", got) | ||
| 53 | } | 58 | } | ||
| 59 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "auto" { | |||
| 60 | + | t.Fatalf("%s = %q, want auto", cef2gtkOSRBackingScaleEnvVar, got) | |||
| 61 | + | } | |||
| 54 | } | 62 | } | ||
| 55 | 63 | ||||
| 56 | func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelOverrides(t *testing.T) { | 64 | func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelOverrides(t *testing.T) { | ||
| @@ -59,6 +67,7 @@ func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelO | |||||
| 59 | t.Setenv("GSK_RENDERER", "ngl") | 67 | t.Setenv("GSK_RENDERER", "ngl") | ||
| 60 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | 68 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | ||
| 61 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | 69 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | ||
| 70 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "off") | |||
| 62 | 71 | ||||
| 63 | got := applyDefaultRenderStackEnvironment(nil) | 72 | got := applyDefaultRenderStackEnvironment(nil) | ||
| 64 | 73 | ||||
| @@ -74,6 +83,9 @@ func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelO | |||||
| 74 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | 83 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | ||
| 75 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got) | 84 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got) | ||
| 76 | } | 85 | } | ||
| 86 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "off" { | |||
| 87 | + | t.Fatalf("%s = %q, want explicit off", cef2gtkOSRBackingScaleEnvVar, got) | |||
| 88 | + | } | |||
| 77 | } | 89 | } | ||
| 78 | 90 | ||||
| 79 | func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | 91 | func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | ||
| @@ -81,6 +93,7 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | |||||
| 81 | t.Setenv("GSK_RENDERER", "") | 93 | t.Setenv("GSK_RENDERER", "") | ||
| 82 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | 94 | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | ||
| 83 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | 95 | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | ||
| 96 | + | t.Setenv(cef2gtkOSRBackingScaleEnvVar, "") | |||
| 84 | 97 | ||||
| 85 | got := applyDefaultRenderStackEnvironment(nil) | 98 | got := applyDefaultRenderStackEnvironment(nil) | ||
| 86 | 99 | ||||
| @@ -93,6 +106,9 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | |||||
| 93 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | 106 | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | ||
| 94 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got) | 107 | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got) | ||
| 95 | } | 108 | } | ||
| 109 | + | if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "" { | |||
| 110 | + | t.Fatalf("%s = %q, want unset for legacy-gl", cef2gtkOSRBackingScaleEnvVar, got) | |||
| 111 | + | } | |||
| 96 | } | 112 | } | ||
| 97 | 113 | ||||
| 98 | func TestNormalizeRenderStackUnknownFallsBackToGDKDMABUF(t *testing.T) { | 114 | func TestNormalizeRenderStackUnknownFallsBackToGDKDMABUF(t *testing.T) { | ||
A internal/infrastructure/cef/scale.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import "math" | |
| 4 | + | ||
| 5 | + | func normalizeScale(scale float64) float64 { | |
| 6 | + | if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 { | |
| 7 | + | return 1 | |
| 8 | + | } | |
| 9 | + | return scale | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func deviceToLogicalCoord(value int32, scale float64) int32 { | |
| 13 | + | return int32(math.Floor(float64(value) / normalizeScale(scale))) | |
| 14 | + | } | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import "math" | |||
| 4 | + | ||||
| 5 | + | func normalizeScale(scale float64) float64 { | |||
| 6 | + | if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 { | |||
| 7 | + | return 1 | |||
| 8 | + | } | |||
| 9 | + | return scale | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | func deviceToLogicalCoord(value int32, scale float64) int32 { | |||
| 13 | + | return int32(math.Floor(float64(value) / normalizeScale(scale))) | |||
| 14 | + | } | |||
A internal/infrastructure/cef/scale_probe.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | const cefScaleProbeScript = `(function(){ | |
| 4 | + | try { | |
| 5 | + | var vv = window.visualViewport; | |
| 6 | + | var probe = document.createElement('div'); | |
| 7 | + | probe.style.cssText = 'position:fixed;left:0;top:0;width:100px;height:100px;visibility:hidden;pointer-events:none;z-index:-1'; | |
| 8 | + | document.documentElement.appendChild(probe); | |
| 9 | + | var rect = probe.getBoundingClientRect(); | |
| 10 | + | probe.remove(); | |
| 11 | + | var data = { | |
| 12 | + | dpr: window.devicePixelRatio, | |
| 13 | + | inner: [window.innerWidth, window.innerHeight], | |
| 14 | + | outer: [window.outerWidth, window.outerHeight], | |
| 15 | + | client: [document.documentElement.clientWidth, document.documentElement.clientHeight], | |
| 16 | + | screen: [window.screen.width, window.screen.height], | |
| 17 | + | avail: [window.screen.availWidth, window.screen.availHeight], | |
| 18 | + | visualViewport: vv ? { | |
| 19 | + | width: vv.width, | |
| 20 | + | height: vv.height, | |
| 21 | + | scale: vv.scale, | |
| 22 | + | offsetLeft: vv.offsetLeft, | |
| 23 | + | offsetTop: vv.offsetTop, | |
| 24 | + | pageLeft: vv.pageLeft, | |
| 25 | + | pageTop: vv.pageTop | |
| 26 | + | } : null, | |
| 27 | + | css100Rect: {width: rect.width, height: rect.height, left: rect.left, top: rect.top}, | |
| 28 | + | scroll: [window.scrollX, window.scrollY] | |
| 29 | + | }; | |
| 30 | + | console.info('[SCALE-PROBE] ' + JSON.stringify(data)); | |
| 31 | + | } catch (e) { | |
| 32 | + | console.warn('[SCALE-PROBE] error ' + (e && e.message ? e.message : String(e))); | |
| 33 | + | } | |
| 34 | + | })();` | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | const cefScaleProbeScript = `(function(){ | |||
| 4 | + | try { | |||
| 5 | + | var vv = window.visualViewport; | |||
| 6 | + | var probe = document.createElement('div'); | |||
| 7 | + | probe.style.cssText = 'position:fixed;left:0;top:0;width:100px;height:100px;visibility:hidden;pointer-events:none;z-index:-1'; | |||
| 8 | + | document.documentElement.appendChild(probe); | |||
| 9 | + | var rect = probe.getBoundingClientRect(); | |||
| 10 | + | probe.remove(); | |||
| 11 | + | var data = { | |||
| 12 | + | dpr: window.devicePixelRatio, | |||
| 13 | + | inner: [window.innerWidth, window.innerHeight], | |||
| 14 | + | outer: [window.outerWidth, window.outerHeight], | |||
| 15 | + | client: [document.documentElement.clientWidth, document.documentElement.clientHeight], | |||
| 16 | + | screen: [window.screen.width, window.screen.height], | |||
| 17 | + | avail: [window.screen.availWidth, window.screen.availHeight], | |||
| 18 | + | visualViewport: vv ? { | |||
| 19 | + | width: vv.width, | |||
| 20 | + | height: vv.height, | |||
| 21 | + | scale: vv.scale, | |||
| 22 | + | offsetLeft: vv.offsetLeft, | |||
| 23 | + | offsetTop: vv.offsetTop, | |||
| 24 | + | pageLeft: vv.pageLeft, | |||
| 25 | + | pageTop: vv.pageTop | |||
| 26 | + | } : null, | |||
| 27 | + | css100Rect: {width: rect.width, height: rect.height, left: rect.left, top: rect.top}, | |||
| 28 | + | scroll: [window.scrollX, window.scrollY] | |||
| 29 | + | }; | |||
| 30 | + | console.info('[SCALE-PROBE] ' + JSON.stringify(data)); | |||
| 31 | + | } catch (e) { | |||
| 32 | + | console.warn('[SCALE-PROBE] error ' + (e && e.message ? e.message : String(e))); | |||
| 33 | + | } | |||
| 34 | + | })();` | |||
M internal/infrastructure/cef/webview.go +73 -23
| @@ -443,6 +443,30 @@ func cefZoomFromFactor(factor float64) float64 { | |||
| 443 | 443 | return math.Log(factor) / math.Log(chromiumZoomBase) | |
| 444 | 444 | } | |
| 445 | 445 | ||
| 446 | + | func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 { | |
| 447 | + | return cefZoomFromFactor(pageZoom / normalizeScale(backingScale)) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 { | |
| 451 | + | return factorFromCEFZoom(level) * normalizeScale(backingScale) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel float64) { | |
| 455 | + | host.SetZoomLevel(cefLevel) | |
| 456 | + | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | |
| 457 | + | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | |
| 458 | + | // repaint. WasResized is a no-op when view dimensions haven't changed. | |
| 459 | + | // NotifyScreenInfoChanged forces surface ID invalidation + a full | |
| 460 | + | // SynchronizeVisualProperties cycle, which makes the renderer produce | |
| 461 | + | // a new compositor frame at the new zoom level. | |
| 462 | + | host.NotifyScreenInfoChanged() | |
| 463 | + | // Zoom is applied asynchronously in the renderer process. Request a couple | |
| 464 | + | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | |
| 465 | + | // compositor frame after the zoom IPC has been processed. | |
| 466 | + | wv.scheduleZoomRefresh() | |
| 467 | + | wv.scheduleZoomReadback(factor, cefLevel) | |
| 468 | + | } | |
| 469 | + | ||
| 446 | 470 | // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a | |
| 447 | 471 | // linear zoom factor. | |
| 448 | 472 | func factorFromCEFZoom(level float64) float64 { | |
| @@ -468,25 +492,15 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||
| 468 | 492 | if host == nil { | |
| 469 | 493 | return errNoBrowser | |
| 470 | 494 | } | |
| 471 | - | cefLevel := cefZoomFromFactor(factor) | |
| 495 | + | backingScale := wv.osrBackingScaleFactor() | |
| 496 | + | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | |
| 472 | 497 | logging.FromContext(wv.ctx).Debug(). | |
| 473 | 498 | Float64("factor", factor). | |
| 474 | 499 | Float64("cef_level", cefLevel). | |
| 500 | + | Float64("osr_backing_scale", backingScale). | |
| 475 | 501 | Msg("cef: SetZoomLevel") | |
| 476 | - | host.SetZoomLevel(cefLevel) | |
| 502 | + | wv.applyCEFZoomLevel(host, factor, cefLevel) | |
| 477 | 503 | wv.zoomFactor.Store(factor) | |
| 478 | - | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | |
| 479 | - | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | |
| 480 | - | // repaint. WasResized is a no-op when view dimensions haven't changed. | |
| 481 | - | // NotifyScreenInfoChanged forces surface ID invalidation + a full | |
| 482 | - | // SynchronizeVisualProperties cycle, which makes the renderer produce | |
| 483 | - | // a new compositor frame at the new zoom level. | |
| 484 | - | host.NotifyScreenInfoChanged() | |
| 485 | - | // Zoom is applied asynchronously in the renderer process. Request a couple | |
| 486 | - | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | |
| 487 | - | // compositor frame after the zoom IPC has been processed. | |
| 488 | - | wv.scheduleZoomRefresh() | |
| 489 | - | wv.scheduleZoomReadback(factor, cefLevel) | |
| 490 | 504 | return nil | |
| 491 | 505 | } | |
| 492 | 506 | ||
| @@ -1376,15 +1390,18 @@ func (wv *WebView) selectedTextSnapshot() string { | |||
| 1376 | 1390 | return wv.selectedText | |
| 1377 | 1391 | } | |
| 1378 | 1392 | ||
| 1379 | - | func (wv *WebView) viewBridgeScale() int32 { | |
| 1393 | + | func (wv *WebView) viewBridgeScale() float64 { | |
| 1380 | 1394 | if wv == nil || wv.viewBridge == nil { | |
| 1381 | 1395 | return 1 | |
| 1382 | 1396 | } | |
| 1383 | - | scale := int32(math.Round(float64(wv.viewBridge.DeviceScaleFactor()))) | |
| 1384 | - | if scale < 1 { | |
| 1397 | + | return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor())) | |
| 1398 | + | } | |
| 1399 | + | ||
| 1400 | + | func (wv *WebView) osrBackingScaleFactor() float64 { | |
| 1401 | + | if wv == nil || wv.viewBridge == nil { | |
| 1385 | 1402 | return 1 | |
| 1386 | 1403 | } | |
| 1387 | - | return scale | |
| 1404 | + | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | |
| 1388 | 1405 | } | |
| 1389 | 1406 | ||
| 1390 | 1407 | func (wv *WebView) handleMiddleClickFromBridge() bool { | |
| @@ -1603,7 +1620,7 @@ func (wv *WebView) updateHoverURI(uri string) { | |||
| 1603 | 1620 | // These values are empirically tuned for CEF's async zoom application. | |
| 1604 | 1621 | func (wv *WebView) scheduleZoomRefresh() { | |
| 1605 | 1622 | for _, delayMs := range [...]int64{16, 48} { | |
| 1606 | - | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | |
| 1623 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 1607 | 1624 | if wv.destroyed.Load() { | |
| 1608 | 1625 | return | |
| 1609 | 1626 | } | |
| @@ -1614,7 +1631,11 @@ func (wv *WebView) scheduleZoomRefresh() { | |||
| 1614 | 1631 | return | |
| 1615 | 1632 | } | |
| 1616 | 1633 | host.Invalidate(purecef.PaintElementTypePetView) | |
| 1617 | - | })), delayMs) | |
| 1634 | + | })) | |
| 1635 | + | if task == nil { | |
| 1636 | + | continue | |
| 1637 | + | } | |
| 1638 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs) | |
| 1618 | 1639 | } | |
| 1619 | 1640 | } | |
| 1620 | 1641 | ||
| @@ -1627,7 +1648,7 @@ func (wv *WebView) scheduleZoomRefresh() { | |||
| 1627 | 1648 | // These values are empirically tuned for CEF's async zoom application. | |
| 1628 | 1649 | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |
| 1629 | 1650 | for _, delayMs := range [...]int64{0, 64} { | |
| 1630 | - | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | |
| 1651 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 1631 | 1652 | if wv.destroyed.Load() { | |
| 1632 | 1653 | return | |
| 1633 | 1654 | } | |
| @@ -1637,18 +1658,47 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||
| 1637 | 1658 | if host == nil { | |
| 1638 | 1659 | return | |
| 1639 | 1660 | } | |
| 1661 | + | backingScale := wv.osrBackingScaleFactor() | |
| 1640 | 1662 | actualLevel := host.GetZoomLevel() | |
| 1641 | 1663 | logging.FromContext(wv.ctx).Debug(). | |
| 1642 | 1664 | Int64("delay_ms", delayMs). | |
| 1643 | 1665 | Float64("expected_factor", expectedFactor). | |
| 1644 | 1666 | Float64("expected_cef_level", expectedLevel). | |
| 1645 | - | Float64("actual_factor", factorFromCEFZoom(actualLevel)). | |
| 1667 | + | Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)). | |
| 1668 | + | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | |
| 1646 | 1669 | Float64("actual_cef_level", actualLevel). | |
| 1670 | + | Float64("osr_backing_scale", backingScale). | |
| 1647 | 1671 | Msg("cef: zoom level readback") | |
| 1648 | - | })), delayMs) | |
| 1672 | + | })) | |
| 1673 | + | if task == nil { | |
| 1674 | + | continue | |
| 1675 | + | } | |
| 1676 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs) | |
| 1649 | 1677 | } | |
| 1650 | 1678 | } | |
| 1651 | 1679 | ||
| 1680 | + | func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |
| 1681 | + | if wv == nil || wv.destroyed.Load() { | |
| 1682 | + | return | |
| 1683 | + | } | |
| 1684 | + | wv.mu.RLock() | |
| 1685 | + | host := wv.host | |
| 1686 | + | wv.mu.RUnlock() | |
| 1687 | + | if host == nil { | |
| 1688 | + | return | |
| 1689 | + | } | |
| 1690 | + | factor := wv.GetZoomLevel() | |
| 1691 | + | backingScale := wv.osrBackingScaleFactor() | |
| 1692 | + | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | |
| 1693 | + | wv.applyCEFZoomLevel(host, factor, cefLevel) | |
| 1694 | + | logging.FromContext(wv.ctx).Debug(). | |
| 1695 | + | Str("reason", reason). | |
| 1696 | + | Float64("factor", factor). | |
| 1697 | + | Float64("cef_level", cefLevel). | |
| 1698 | + | Float64("osr_backing_scale", backingScale). | |
| 1699 | + | Msg("cef: reapplied zoom for OSR backing scale") | |
| 1700 | + | } | |
| 1701 | + | ||
| 1652 | 1702 | func (wv *WebView) scheduleStartBeginFrameLoop() { | |
| 1653 | 1703 | if !externalBeginFrameEnabled() { | |
| 1654 | 1704 | return | |
| @@ -443,6 +443,30 @@ func cefZoomFromFactor(factor float64) float64 { | |||||
| 443 | return math.Log(factor) / math.Log(chromiumZoomBase) | 443 | return math.Log(factor) / math.Log(chromiumZoomBase) | ||
| 444 | } | 444 | } | ||
| 445 | 445 | ||||
| 446 | + | func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 { | |||
| 447 | + | return cefZoomFromFactor(pageZoom / normalizeScale(backingScale)) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 { | |||
| 451 | + | return factorFromCEFZoom(level) * normalizeScale(backingScale) | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel float64) { | |||
| 455 | + | host.SetZoomLevel(cefLevel) | |||
| 456 | + | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | |||
| 457 | + | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | |||
| 458 | + | // repaint. WasResized is a no-op when view dimensions haven't changed. | |||
| 459 | + | // NotifyScreenInfoChanged forces surface ID invalidation + a full | |||
| 460 | + | // SynchronizeVisualProperties cycle, which makes the renderer produce | |||
| 461 | + | // a new compositor frame at the new zoom level. | |||
| 462 | + | host.NotifyScreenInfoChanged() | |||
| 463 | + | // Zoom is applied asynchronously in the renderer process. Request a couple | |||
| 464 | + | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | |||
| 465 | + | // compositor frame after the zoom IPC has been processed. | |||
| 466 | + | wv.scheduleZoomRefresh() | |||
| 467 | + | wv.scheduleZoomReadback(factor, cefLevel) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 446 | // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a | 470 | // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a | ||
| 447 | // linear zoom factor. | 471 | // linear zoom factor. | ||
| 448 | func factorFromCEFZoom(level float64) float64 { | 472 | func factorFromCEFZoom(level float64) float64 { | ||
| @@ -468,25 +492,15 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||||
| 468 | if host == nil { | 492 | if host == nil { | ||
| 469 | return errNoBrowser | 493 | return errNoBrowser | ||
| 470 | } | 494 | } | ||
| 471 | - | cefLevel := cefZoomFromFactor(factor) | 495 | + | backingScale := wv.osrBackingScaleFactor() |
| 496 | + | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | |||
| 472 | logging.FromContext(wv.ctx).Debug(). | 497 | logging.FromContext(wv.ctx).Debug(). | ||
| 473 | Float64("factor", factor). | 498 | Float64("factor", factor). | ||
| 474 | Float64("cef_level", cefLevel). | 499 | Float64("cef_level", cefLevel). | ||
| 500 | + | Float64("osr_backing_scale", backingScale). | |||
| 475 | Msg("cef: SetZoomLevel") | 501 | Msg("cef: SetZoomLevel") | ||
| 476 | - | host.SetZoomLevel(cefLevel) | 502 | + | wv.applyCEFZoomLevel(host, factor, cefLevel) |
| 477 | wv.zoomFactor.Store(factor) | 503 | wv.zoomFactor.Store(factor) | ||
| 478 | - | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | |||
| 479 | - | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | |||
| 480 | - | // repaint. WasResized is a no-op when view dimensions haven't changed. | |||
| 481 | - | // NotifyScreenInfoChanged forces surface ID invalidation + a full | |||
| 482 | - | // SynchronizeVisualProperties cycle, which makes the renderer produce | |||
| 483 | - | // a new compositor frame at the new zoom level. | |||
| 484 | - | host.NotifyScreenInfoChanged() | |||
| 485 | - | // Zoom is applied asynchronously in the renderer process. Request a couple | |||
| 486 | - | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | |||
| 487 | - | // compositor frame after the zoom IPC has been processed. | |||
| 488 | - | wv.scheduleZoomRefresh() | |||
| 489 | - | wv.scheduleZoomReadback(factor, cefLevel) | |||
| 490 | return nil | 504 | return nil | ||
| 491 | } | 505 | } | ||
| 492 | 506 | ||||
| @@ -1376,15 +1390,18 @@ func (wv *WebView) selectedTextSnapshot() string { | |||||
| 1376 | return wv.selectedText | 1390 | return wv.selectedText | ||
| 1377 | } | 1391 | } | ||
| 1378 | 1392 | ||||
| 1379 | - | func (wv *WebView) viewBridgeScale() int32 { | 1393 | + | func (wv *WebView) viewBridgeScale() float64 { |
| 1380 | if wv == nil || wv.viewBridge == nil { | 1394 | if wv == nil || wv.viewBridge == nil { | ||
| 1381 | return 1 | 1395 | return 1 | ||
| 1382 | } | 1396 | } | ||
| 1383 | - | scale := int32(math.Round(float64(wv.viewBridge.DeviceScaleFactor()))) | 1397 | + | return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor())) |
| 1384 | - | if scale < 1 { | 1398 | + | } |
| 1399 | + | ||||
| 1400 | + | func (wv *WebView) osrBackingScaleFactor() float64 { | |||
| 1401 | + | if wv == nil || wv.viewBridge == nil { | |||
| 1385 | return 1 | 1402 | return 1 | ||
| 1386 | } | 1403 | } | ||
| 1387 | - | return scale | 1404 | + | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) |
| 1388 | } | 1405 | } | ||
| 1389 | 1406 | ||||
| 1390 | func (wv *WebView) handleMiddleClickFromBridge() bool { | 1407 | func (wv *WebView) handleMiddleClickFromBridge() bool { | ||
| @@ -1603,7 +1620,7 @@ func (wv *WebView) updateHoverURI(uri string) { | |||||
| 1603 | // These values are empirically tuned for CEF's async zoom application. | 1620 | // These values are empirically tuned for CEF's async zoom application. | ||
| 1604 | func (wv *WebView) scheduleZoomRefresh() { | 1621 | func (wv *WebView) scheduleZoomRefresh() { | ||
| 1605 | for _, delayMs := range [...]int64{16, 48} { | 1622 | for _, delayMs := range [...]int64{16, 48} { | ||
| 1606 | - | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | 1623 | + | task := cefNewTask(cefTaskFunc(func() { |
| 1607 | if wv.destroyed.Load() { | 1624 | if wv.destroyed.Load() { | ||
| 1608 | return | 1625 | return | ||
| 1609 | } | 1626 | } | ||
| @@ -1614,7 +1631,11 @@ func (wv *WebView) scheduleZoomRefresh() { | |||||
| 1614 | return | 1631 | return | ||
| 1615 | } | 1632 | } | ||
| 1616 | host.Invalidate(purecef.PaintElementTypePetView) | 1633 | host.Invalidate(purecef.PaintElementTypePetView) | ||
| 1617 | - | })), delayMs) | 1634 | + | })) |
| 1635 | + | if task == nil { | |||
| 1636 | + | continue | |||
| 1637 | + | } | |||
| 1638 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs) | |||
| 1618 | } | 1639 | } | ||
| 1619 | } | 1640 | } | ||
| 1620 | 1641 | ||||
| @@ -1627,7 +1648,7 @@ func (wv *WebView) scheduleZoomRefresh() { | |||||
| 1627 | // These values are empirically tuned for CEF's async zoom application. | 1648 | // These values are empirically tuned for CEF's async zoom application. | ||
| 1628 | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | 1649 | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | ||
| 1629 | for _, delayMs := range [...]int64{0, 64} { | 1650 | for _, delayMs := range [...]int64{0, 64} { | ||
| 1630 | - | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | 1651 | + | task := cefNewTask(cefTaskFunc(func() { |
| 1631 | if wv.destroyed.Load() { | 1652 | if wv.destroyed.Load() { | ||
| 1632 | return | 1653 | return | ||
| 1633 | } | 1654 | } | ||
| @@ -1637,18 +1658,47 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||||
| 1637 | if host == nil { | 1658 | if host == nil { | ||
| 1638 | return | 1659 | return | ||
| 1639 | } | 1660 | } | ||
| 1661 | + | backingScale := wv.osrBackingScaleFactor() | |||
| 1640 | actualLevel := host.GetZoomLevel() | 1662 | actualLevel := host.GetZoomLevel() | ||
| 1641 | logging.FromContext(wv.ctx).Debug(). | 1663 | logging.FromContext(wv.ctx).Debug(). | ||
| 1642 | Int64("delay_ms", delayMs). | 1664 | Int64("delay_ms", delayMs). | ||
| 1643 | Float64("expected_factor", expectedFactor). | 1665 | Float64("expected_factor", expectedFactor). | ||
| 1644 | Float64("expected_cef_level", expectedLevel). | 1666 | Float64("expected_cef_level", expectedLevel). | ||
| 1645 | - | Float64("actual_factor", factorFromCEFZoom(actualLevel)). | 1667 | + | Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)). |
| 1668 | + | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | |||
| 1646 | Float64("actual_cef_level", actualLevel). | 1669 | Float64("actual_cef_level", actualLevel). | ||
| 1670 | + | Float64("osr_backing_scale", backingScale). | |||
| 1647 | Msg("cef: zoom level readback") | 1671 | Msg("cef: zoom level readback") | ||
| 1648 | - | })), delayMs) | 1672 | + | })) |
| 1673 | + | if task == nil { | |||
| 1674 | + | continue | |||
| 1675 | + | } | |||
| 1676 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs) | |||
| 1649 | } | 1677 | } | ||
| 1650 | } | 1678 | } | ||
| 1651 | 1679 | ||||
| 1680 | + | func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |||
| 1681 | + | if wv == nil || wv.destroyed.Load() { | |||
| 1682 | + | return | |||
| 1683 | + | } | |||
| 1684 | + | wv.mu.RLock() | |||
| 1685 | + | host := wv.host | |||
| 1686 | + | wv.mu.RUnlock() | |||
| 1687 | + | if host == nil { | |||
| 1688 | + | return | |||
| 1689 | + | } | |||
| 1690 | + | factor := wv.GetZoomLevel() | |||
| 1691 | + | backingScale := wv.osrBackingScaleFactor() | |||
| 1692 | + | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | |||
| 1693 | + | wv.applyCEFZoomLevel(host, factor, cefLevel) | |||
| 1694 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 1695 | + | Str("reason", reason). | |||
| 1696 | + | Float64("factor", factor). | |||
| 1697 | + | Float64("cef_level", cefLevel). | |||
| 1698 | + | Float64("osr_backing_scale", backingScale). | |||
| 1699 | + | Msg("cef: reapplied zoom for OSR backing scale") | |||
| 1700 | + | } | |||
| 1701 | + | ||||
| 1652 | func (wv *WebView) scheduleStartBeginFrameLoop() { | 1702 | func (wv *WebView) scheduleStartBeginFrameLoop() { | ||
| 1653 | if !externalBeginFrameEnabled() { | 1703 | if !externalBeginFrameEnabled() { | ||
| 1654 | return | 1704 | return | ||
M internal/infrastructure/cef/webview_audio_test.go +0 -2
| @@ -26,7 +26,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | // Verify webViewFactoryOptions can hold the audio factory | |
| 28 | 28 | opts := webViewFactoryOptions{ | |
| 29 | - | scale: 1, | |
| 30 | 29 | windowlessFrameRate: 60, | |
| 31 | 30 | audioOutputFactory: audioFactory, | |
| 32 | 31 | } | |
| @@ -44,7 +43,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) { | |||
| 44 | 43 | func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) { | |
| 45 | 44 | // Arrange | |
| 46 | 45 | opts := webViewFactoryOptions{ | |
| 47 | - | scale: 1, | |
| 48 | 46 | windowlessFrameRate: 60, | |
| 49 | 47 | audioOutputFactory: nil, | |
| 50 | 48 | } | |
| @@ -26,7 +26,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | // Verify webViewFactoryOptions can hold the audio factory | 27 | // Verify webViewFactoryOptions can hold the audio factory | ||
| 28 | opts := webViewFactoryOptions{ | 28 | opts := webViewFactoryOptions{ | ||
| 29 | - | scale: 1, | |||
| 30 | windowlessFrameRate: 60, | 29 | windowlessFrameRate: 60, | ||
| 31 | audioOutputFactory: audioFactory, | 30 | audioOutputFactory: audioFactory, | ||
| 32 | } | 31 | } | ||
| @@ -44,7 +43,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) { | |||||
| 44 | func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) { | 43 | func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) { | ||
| 45 | // Arrange | 44 | // Arrange | ||
| 46 | opts := webViewFactoryOptions{ | 45 | opts := webViewFactoryOptions{ | ||
| 47 | - | scale: 1, | |||
| 48 | windowlessFrameRate: 60, | 46 | windowlessFrameRate: 60, | ||
| 49 | audioOutputFactory: nil, | 47 | audioOutputFactory: nil, | ||
| 50 | } | 48 | } | ||
M internal/infrastructure/cef/webview_handlers.go +17 -4
| @@ -16,8 +16,9 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // Console message markers used by injected JavaScript for log filtering. | |
| 18 | 18 | const ( | |
| 19 | - | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 20 | - | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 19 | + | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 20 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 21 | + | consoleMarkerScaleProbe = "[SCALE-PROBE]" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // handlerSet implements all CEF handler interfaces and dispatches events to the | |
| @@ -226,7 +227,8 @@ func (h *handlerSet) OnConsoleMessage( | |||
| 226 | 227 | ) int32 { | |
| 227 | 228 | if h.wv != nil && h.wv.ctx != nil && | |
| 228 | 229 | (strings.Contains(message, consoleMarkerVideoDiag) || | |
| 229 | - | strings.Contains(message, consoleMarkerAutoCopy)) { | |
| 230 | + | strings.Contains(message, consoleMarkerAutoCopy) || | |
| 231 | + | strings.Contains(message, consoleMarkerScaleProbe)) { | |
| 230 | 232 | log := logging.FromContext(h.wv.ctx).With(). | |
| 231 | 233 | Str("component", "cef-console"). | |
| 232 | 234 | Str("source", source). | |
| @@ -399,6 +401,14 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||
| 399 | 401 | // Inject scripts and styles after page load. | |
| 400 | 402 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | |
| 401 | 403 | // and JavaScript injection requires the main thread. | |
| 404 | + | if cefScaleProbeEnabled() && httpStatusCode >= 0 && !strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") { | |
| 405 | + | // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop. | |
| 406 | + | // Re-read the current main frame through WebView.RunJavaScript instead. | |
| 407 | + | h.wv.runOnGTK(func() { | |
| 408 | + | logging.FromContext(h.wv.ctx).Debug().Msg("cef: injecting scale probe") | |
| 409 | + | h.wv.RunJavaScript(context.Background(), cefScaleProbeScript) | |
| 410 | + | }) | |
| 411 | + | } | |
| 402 | 412 | if h.wv.engine != nil && h.wv.engine.contentInj != nil { | |
| 403 | 413 | h.wv.runOnGTK(func() { | |
| 404 | 414 | h.wv.engine.contentInj.onLoadEnd(h.wv) | |
| @@ -630,7 +640,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 630 | 640 | return | |
| 631 | 641 | } | |
| 632 | 642 | if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{ | |
| 633 | - | Scale: wv.viewBridgeScale(), | |
| 643 | + | Scale: 0, | |
| 634 | 644 | OnMiddleClick: func(_, _ float64) bool { | |
| 635 | 645 | return wv.handleMiddleClickFromBridge() | |
| 636 | 646 | }, | |
| @@ -656,6 +666,9 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 656 | 666 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | |
| 657 | 667 | // painting/caret updates until explicitly told the browser is shown. | |
| 658 | 668 | host.WasHidden(0) | |
| 669 | + | if h.wv != nil { | |
| 670 | + | h.wv.SyncViewport(h.wv.ctx, "after-created") | |
| 671 | + | } | |
| 659 | 672 | return state | |
| 660 | 673 | } | |
| 661 | 674 | ||
| @@ -16,8 +16,9 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // Console message markers used by injected JavaScript for log filtering. | 17 | // Console message markers used by injected JavaScript for log filtering. | ||
| 18 | const ( | 18 | const ( | ||
| 19 | - | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | 19 | + | consoleMarkerVideoDiag = "[VIDEO-DIAG]" |
| 20 | - | consoleMarkerAutoCopy = "[AUTO-COPY]" | 20 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" |
| 21 | + | consoleMarkerScaleProbe = "[SCALE-PROBE]" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // handlerSet implements all CEF handler interfaces and dispatches events to the | 24 | // handlerSet implements all CEF handler interfaces and dispatches events to the | ||
| @@ -226,7 +227,8 @@ func (h *handlerSet) OnConsoleMessage( | |||||
| 226 | ) int32 { | 227 | ) int32 { | ||
| 227 | if h.wv != nil && h.wv.ctx != nil && | 228 | if h.wv != nil && h.wv.ctx != nil && | ||
| 228 | (strings.Contains(message, consoleMarkerVideoDiag) || | 229 | (strings.Contains(message, consoleMarkerVideoDiag) || | ||
| 229 | - | strings.Contains(message, consoleMarkerAutoCopy)) { | 230 | + | strings.Contains(message, consoleMarkerAutoCopy) || |
| 231 | + | strings.Contains(message, consoleMarkerScaleProbe)) { | |||
| 230 | log := logging.FromContext(h.wv.ctx).With(). | 232 | log := logging.FromContext(h.wv.ctx).With(). | ||
| 231 | Str("component", "cef-console"). | 233 | Str("component", "cef-console"). | ||
| 232 | Str("source", source). | 234 | Str("source", source). | ||
| @@ -399,6 +401,14 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||||
| 399 | // Inject scripts and styles after page load. | 401 | // Inject scripts and styles after page load. | ||
| 400 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | 402 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | ||
| 401 | // and JavaScript injection requires the main thread. | 403 | // and JavaScript injection requires the main thread. | ||
| 404 | + | if cefScaleProbeEnabled() && httpStatusCode >= 0 && !strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") { | |||
| 405 | + | // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop. | |||
| 406 | + | // Re-read the current main frame through WebView.RunJavaScript instead. | |||
| 407 | + | h.wv.runOnGTK(func() { | |||
| 408 | + | logging.FromContext(h.wv.ctx).Debug().Msg("cef: injecting scale probe") | |||
| 409 | + | h.wv.RunJavaScript(context.Background(), cefScaleProbeScript) | |||
| 410 | + | }) | |||
| 411 | + | } | |||
| 402 | if h.wv.engine != nil && h.wv.engine.contentInj != nil { | 412 | if h.wv.engine != nil && h.wv.engine.contentInj != nil { | ||
| 403 | h.wv.runOnGTK(func() { | 413 | h.wv.runOnGTK(func() { | ||
| 404 | h.wv.engine.contentInj.onLoadEnd(h.wv) | 414 | h.wv.engine.contentInj.onLoadEnd(h.wv) | ||
| @@ -630,7 +640,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 630 | return | 640 | return | ||
| 631 | } | 641 | } | ||
| 632 | if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{ | 642 | if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{ | ||
| 633 | - | Scale: wv.viewBridgeScale(), | 643 | + | Scale: 0, |
| 634 | OnMiddleClick: func(_, _ float64) bool { | 644 | OnMiddleClick: func(_, _ float64) bool { | ||
| 635 | return wv.handleMiddleClickFromBridge() | 645 | return wv.handleMiddleClickFromBridge() | ||
| 636 | }, | 646 | }, | ||
| @@ -656,6 +666,9 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 656 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | 666 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | ||
| 657 | // painting/caret updates until explicitly told the browser is shown. | 667 | // painting/caret updates until explicitly told the browser is shown. | ||
| 658 | host.WasHidden(0) | 668 | host.WasHidden(0) | ||
| 669 | + | if h.wv != nil { | |||
| 670 | + | h.wv.SyncViewport(h.wv.ctx, "after-created") | |||
| 671 | + | } | |||
| 659 | return state | 672 | return state | ||
| 660 | } | 673 | } | ||
| 661 | 674 | ||||
M internal/infrastructure/cef/webview_viewport.go +24 -1
| @@ -101,7 +101,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool | |||
| 101 | 101 | ||
| 102 | 102 | widget := wv.viewBridge.Widget() | |
| 103 | 103 | visible := widget != nil && widget.IsVisible() | |
| 104 | - | notifyBrowserViewportSync(host, visible) | |
| 104 | + | wv.notifyViewportSyncOnCEFUIThread(host, visible) | |
| 105 | 105 | logging.FromContext(ctx).Debug(). | |
| 106 | 106 | Uint64("webview_id", uint64(wv.id)). | |
| 107 | 107 | Bool("visible", visible). | |
| @@ -264,6 +264,29 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() { | |||
| 264 | 264 | wv.viewportRealizeFunc = nil | |
| 265 | 265 | } | |
| 266 | 266 | ||
| 267 | + | func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, visible bool) { | |
| 268 | + | if host == nil { | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 272 | + | if wv == nil || wv.destroyed.Load() { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | wv.mu.RLock() | |
| 276 | + | currentHost := wv.host | |
| 277 | + | wv.mu.RUnlock() | |
| 278 | + | if currentHost != host { | |
| 279 | + | return | |
| 280 | + | } | |
| 281 | + | notifyBrowserViewportSync(host, visible) | |
| 282 | + | wv.reapplyCurrentZoomForBackingScale("viewport-sync") | |
| 283 | + | })) | |
| 284 | + | if task == nil { | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0) | |
| 288 | + | } | |
| 289 | + | ||
| 267 | 290 | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | |
| 268 | 291 | if host == nil { | |
| 269 | 292 | return | |
| @@ -101,7 +101,7 @@ func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool | |||||
| 101 | 101 | ||||
| 102 | widget := wv.viewBridge.Widget() | 102 | widget := wv.viewBridge.Widget() | ||
| 103 | visible := widget != nil && widget.IsVisible() | 103 | visible := widget != nil && widget.IsVisible() | ||
| 104 | - | notifyBrowserViewportSync(host, visible) | 104 | + | wv.notifyViewportSyncOnCEFUIThread(host, visible) |
| 105 | logging.FromContext(ctx).Debug(). | 105 | logging.FromContext(ctx).Debug(). | ||
| 106 | Uint64("webview_id", uint64(wv.id)). | 106 | Uint64("webview_id", uint64(wv.id)). | ||
| 107 | Bool("visible", visible). | 107 | Bool("visible", visible). | ||
| @@ -264,6 +264,29 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() { | |||||
| 264 | wv.viewportRealizeFunc = nil | 264 | wv.viewportRealizeFunc = nil | ||
| 265 | } | 265 | } | ||
| 266 | 266 | ||||
| 267 | + | func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, visible bool) { | |||
| 268 | + | if host == nil { | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | task := cefNewTask(cefTaskFunc(func() { | |||
| 272 | + | if wv == nil || wv.destroyed.Load() { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | wv.mu.RLock() | |||
| 276 | + | currentHost := wv.host | |||
| 277 | + | wv.mu.RUnlock() | |||
| 278 | + | if currentHost != host { | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 281 | + | notifyBrowserViewportSync(host, visible) | |||
| 282 | + | wv.reapplyCurrentZoomForBackingScale("viewport-sync") | |||
| 283 | + | })) | |||
| 284 | + | if task == nil { | |||
| 285 | + | return | |||
| 286 | + | } | |||
| 287 | + | cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 267 | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | 290 | func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) { | ||
| 268 | if host == nil { | 291 | if host == nil { | ||
| 269 | return | 292 | return | ||
M internal/infrastructure/cef/webview_viewport_test.go +102 -1
| @@ -10,7 +10,8 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | type viewportSyncOrderHost struct { | |
| 12 | 12 | purecef.BrowserHost | |
| 13 | - | calls []string | |
| 13 | + | calls []string | |
| 14 | + | zoomLevel float64 | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | func (h *viewportSyncOrderHost) WasHidden(state int32) { | |
| @@ -32,6 +33,15 @@ func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) { | |||
| 32 | 33 | h.calls = append(h.calls, "Invalidate") | |
| 33 | 34 | } | |
| 34 | 35 | ||
| 36 | + | func (h *viewportSyncOrderHost) SetZoomLevel(level float64) { | |
| 37 | + | h.calls = append(h.calls, "SetZoomLevel") | |
| 38 | + | h.zoomLevel = level | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (h *viewportSyncOrderHost) GetZoomLevel() float64 { | |
| 42 | + | return h.zoomLevel | |
| 43 | + | } | |
| 44 | + | ||
| 35 | 45 | func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) { | |
| 36 | 46 | host := &viewportSyncOrderHost{} | |
| 37 | 47 | ||
| @@ -48,6 +58,97 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { | |||
| 48 | 58 | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | |
| 49 | 59 | } | |
| 50 | 60 | ||
| 61 | + | func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) { | |
| 62 | + | oldNewTask := cefNewTask | |
| 63 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 64 | + | defer func() { | |
| 65 | + | cefNewTask = oldNewTask | |
| 66 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 67 | + | }() | |
| 68 | + | ||
| 69 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 70 | + | ||
| 71 | + | var scheduled purecef.Task | |
| 72 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 73 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 74 | + | require.NotNil(t, task) | |
| 75 | + | if delayMs == 0 && scheduled == nil { | |
| 76 | + | scheduled = task | |
| 77 | + | } | |
| 78 | + | return 1 | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | host := &viewportSyncOrderHost{} | |
| 82 | + | wv := &WebView{ctx: context.Background(), host: host} | |
| 83 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |
| 84 | + | ||
| 85 | + | require.Empty(t, host.calls) | |
| 86 | + | require.NotNil(t, scheduled) | |
| 87 | + | ||
| 88 | + | scheduled.Execute() | |
| 89 | + | require.Equal(t, []string{ | |
| 90 | + | "WasHidden", | |
| 91 | + | "NotifyScreenInfoChanged", | |
| 92 | + | "WasResized", | |
| 93 | + | "Invalidate", | |
| 94 | + | "SetZoomLevel", | |
| 95 | + | "NotifyScreenInfoChanged", | |
| 96 | + | }, host.calls) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | |
| 100 | + | oldNewTask := cefNewTask | |
| 101 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 102 | + | defer func() { | |
| 103 | + | cefNewTask = oldNewTask | |
| 104 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 105 | + | }() | |
| 106 | + | ||
| 107 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 108 | + | ||
| 109 | + | var scheduled purecef.Task | |
| 110 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { | |
| 111 | + | scheduled = task | |
| 112 | + | return 1 | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | capturedHost := &viewportSyncOrderHost{} | |
| 116 | + | currentHost := &viewportSyncOrderHost{} | |
| 117 | + | wv := &WebView{ctx: context.Background(), host: currentHost} | |
| 118 | + | wv.notifyViewportSyncOnCEFUIThread(capturedHost, true) | |
| 119 | + | ||
| 120 | + | require.NotNil(t, scheduled) | |
| 121 | + | scheduled.Execute() | |
| 122 | + | require.Empty(t, capturedHost.calls) | |
| 123 | + | require.Empty(t, currentHost.calls) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) { | |
| 127 | + | oldNewTask := cefNewTask | |
| 128 | + | oldPostDelayedTask := cefPostDelayedTask | |
| 129 | + | defer func() { | |
| 130 | + | cefNewTask = oldNewTask | |
| 131 | + | cefPostDelayedTask = oldPostDelayedTask | |
| 132 | + | }() | |
| 133 | + | ||
| 134 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 135 | + | ||
| 136 | + | var scheduled purecef.Task | |
| 137 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { | |
| 138 | + | scheduled = task | |
| 139 | + | return 1 | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | host := &viewportSyncOrderHost{} | |
| 143 | + | wv := &WebView{ctx: context.Background(), host: host} | |
| 144 | + | wv.destroyed.Store(true) | |
| 145 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |
| 146 | + | ||
| 147 | + | require.NotNil(t, scheduled) | |
| 148 | + | scheduled.Execute() | |
| 149 | + | require.Empty(t, host.calls) | |
| 150 | + | } | |
| 151 | + | ||
| 51 | 152 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | |
| 52 | 153 | oldNewTask := cefNewTask | |
| 53 | 154 | oldPostDelayedTask := cefPostDelayedTask | |
| @@ -10,7 +10,8 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | type viewportSyncOrderHost struct { | 11 | type viewportSyncOrderHost struct { | ||
| 12 | purecef.BrowserHost | 12 | purecef.BrowserHost | ||
| 13 | - | calls []string | 13 | + | calls []string |
| 14 | + | zoomLevel float64 | |||
| 14 | } | 15 | } | ||
| 15 | 16 | ||||
| 16 | func (h *viewportSyncOrderHost) WasHidden(state int32) { | 17 | func (h *viewportSyncOrderHost) WasHidden(state int32) { | ||
| @@ -32,6 +33,15 @@ func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) { | |||||
| 32 | h.calls = append(h.calls, "Invalidate") | 33 | h.calls = append(h.calls, "Invalidate") | ||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 36 | + | func (h *viewportSyncOrderHost) SetZoomLevel(level float64) { | |||
| 37 | + | h.calls = append(h.calls, "SetZoomLevel") | |||
| 38 | + | h.zoomLevel = level | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (h *viewportSyncOrderHost) GetZoomLevel() float64 { | |||
| 42 | + | return h.zoomLevel | |||
| 43 | + | } | |||
| 44 | + | ||||
| 35 | func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) { | 45 | func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) { | ||
| 36 | host := &viewportSyncOrderHost{} | 46 | host := &viewportSyncOrderHost{} | ||
| 37 | 47 | ||||
| @@ -48,6 +58,97 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) { | |||||
| 48 | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | 58 | require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls) | ||
| 49 | } | 59 | } | ||
| 50 | 60 | ||||
| 61 | + | func TestNotifyViewportSyncOnCEFUIThread_PostsCEFWork(t *testing.T) { | |||
| 62 | + | oldNewTask := cefNewTask | |||
| 63 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 64 | + | defer func() { | |||
| 65 | + | cefNewTask = oldNewTask | |||
| 66 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 67 | + | }() | |||
| 68 | + | ||||
| 69 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 70 | + | ||||
| 71 | + | var scheduled purecef.Task | |||
| 72 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 73 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 74 | + | require.NotNil(t, task) | |||
| 75 | + | if delayMs == 0 && scheduled == nil { | |||
| 76 | + | scheduled = task | |||
| 77 | + | } | |||
| 78 | + | return 1 | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | host := &viewportSyncOrderHost{} | |||
| 82 | + | wv := &WebView{ctx: context.Background(), host: host} | |||
| 83 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |||
| 84 | + | ||||
| 85 | + | require.Empty(t, host.calls) | |||
| 86 | + | require.NotNil(t, scheduled) | |||
| 87 | + | ||||
| 88 | + | scheduled.Execute() | |||
| 89 | + | require.Equal(t, []string{ | |||
| 90 | + | "WasHidden", | |||
| 91 | + | "NotifyScreenInfoChanged", | |||
| 92 | + | "WasResized", | |||
| 93 | + | "Invalidate", | |||
| 94 | + | "SetZoomLevel", | |||
| 95 | + | "NotifyScreenInfoChanged", | |||
| 96 | + | }, host.calls) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) { | |||
| 100 | + | oldNewTask := cefNewTask | |||
| 101 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 102 | + | defer func() { | |||
| 103 | + | cefNewTask = oldNewTask | |||
| 104 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 105 | + | }() | |||
| 106 | + | ||||
| 107 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 108 | + | ||||
| 109 | + | var scheduled purecef.Task | |||
| 110 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { | |||
| 111 | + | scheduled = task | |||
| 112 | + | return 1 | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | capturedHost := &viewportSyncOrderHost{} | |||
| 116 | + | currentHost := &viewportSyncOrderHost{} | |||
| 117 | + | wv := &WebView{ctx: context.Background(), host: currentHost} | |||
| 118 | + | wv.notifyViewportSyncOnCEFUIThread(capturedHost, true) | |||
| 119 | + | ||||
| 120 | + | require.NotNil(t, scheduled) | |||
| 121 | + | scheduled.Execute() | |||
| 122 | + | require.Empty(t, capturedHost.calls) | |||
| 123 | + | require.Empty(t, currentHost.calls) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) { | |||
| 127 | + | oldNewTask := cefNewTask | |||
| 128 | + | oldPostDelayedTask := cefPostDelayedTask | |||
| 129 | + | defer func() { | |||
| 130 | + | cefNewTask = oldNewTask | |||
| 131 | + | cefPostDelayedTask = oldPostDelayedTask | |||
| 132 | + | }() | |||
| 133 | + | ||||
| 134 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 135 | + | ||||
| 136 | + | var scheduled purecef.Task | |||
| 137 | + | cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 { | |||
| 138 | + | scheduled = task | |||
| 139 | + | return 1 | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | host := &viewportSyncOrderHost{} | |||
| 143 | + | wv := &WebView{ctx: context.Background(), host: host} | |||
| 144 | + | wv.destroyed.Store(true) | |||
| 145 | + | wv.notifyViewportSyncOnCEFUIThread(host, true) | |||
| 146 | + | ||||
| 147 | + | require.NotNil(t, scheduled) | |||
| 148 | + | scheduled.Execute() | |||
| 149 | + | require.Empty(t, host.calls) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 51 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | 152 | func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) { | ||
| 52 | oldNewTask := cefNewTask | 153 | oldNewTask := cefNewTask | ||
| 53 | oldPostDelayedTask := cefPostDelayedTask | 154 | oldPostDelayedTask := cefPostDelayedTask | ||
M internal/infrastructure/cef/webview_zoom_test.go +12 -0
| @@ -17,3 +17,15 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||
| 17 | 17 | }) | |
| 18 | 18 | } | |
| 19 | 19 | } | |
| 20 | + | ||
| 21 | + | func TestZoomConversionsCompensateOSRBackingScale(t *testing.T) { | |
| 22 | + | for _, backing := range []float64{1.0, 1.25, 1.5, 2.0} { | |
| 23 | + | for _, pageZoom := range []float64{1.0, 1.75} { | |
| 24 | + | t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) { | |
| 25 | + | level := cefZoomFromPageAndBackingFactor(pageZoom, backing) | |
| 26 | + | assert.InDelta(t, pageZoom/backing, factorFromCEFZoom(level), 1e-9) | |
| 27 | + | assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9) | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -17,3 +17,15 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||||
| 17 | }) | 17 | }) | ||
| 18 | } | 18 | } | ||
| 19 | } | 19 | } | ||
| 20 | + | ||||
| 21 | + | func TestZoomConversionsCompensateOSRBackingScale(t *testing.T) { | |||
| 22 | + | for _, backing := range []float64{1.0, 1.25, 1.5, 2.0} { | |||
| 23 | + | for _, pageZoom := range []float64{1.0, 1.75} { | |||
| 24 | + | t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) { | |||
| 25 | + | level := cefZoomFromPageAndBackingFactor(pageZoom, backing) | |||
| 26 | + | assert.InDelta(t, pageZoom/backing, factorFromCEFZoom(level), 1e-9) | |||
| 27 | + | assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | } | |||
A internal/infrastructure/gtkutil/scale.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package gtkutil | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // NormalizeScale clamps invalid GTK/GDK scale values to 1. | |
| 10 | + | func NormalizeScale(scale float64) float64 { | |
| 11 | + | if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 { | |
| 12 | + | return 1 | |
| 13 | + | } | |
| 14 | + | return scale | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // WidgetFromNativePointer wraps a native GTK widget pointer. | |
| 18 | + | func WidgetFromNativePointer(ptr uintptr) *gtk.Widget { | |
| 19 | + | if ptr == 0 { | |
| 20 | + | return nil | |
| 21 | + | } | |
| 22 | + | return gtk.WidgetNewFromInternalPtr(ptr) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // DeviceToLogical converts a device-pixel coordinate into a logical coordinate. | |
| 26 | + | func DeviceToLogical(value int32, scale float64) int32 { | |
| 27 | + | return int32(math.Floor(float64(value) / NormalizeScale(scale))) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package gtkutil | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // NormalizeScale clamps invalid GTK/GDK scale values to 1. | |||
| 10 | + | func NormalizeScale(scale float64) float64 { | |||
| 11 | + | if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 { | |||
| 12 | + | return 1 | |||
| 13 | + | } | |||
| 14 | + | return scale | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // WidgetFromNativePointer wraps a native GTK widget pointer. | |||
| 18 | + | func WidgetFromNativePointer(ptr uintptr) *gtk.Widget { | |||
| 19 | + | if ptr == 0 { | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | return gtk.WidgetNewFromInternalPtr(ptr) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // DeviceToLogical converts a device-pixel coordinate into a logical coordinate. | |||
| 26 | + | func DeviceToLogical(value int32, scale float64) int32 { | |||
| 27 | + | return int32(math.Floor(float64(value) / NormalizeScale(scale))) | |||
| 28 | + | } | |||
A internal/infrastructure/gtkutil/scale_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package gtkutil | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestNormalizeScale(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | input float64 | |
| 12 | + | want float64 | |
| 13 | + | }{ | |
| 14 | + | {name: "zero", input: 0, want: 1}, | |
| 15 | + | {name: "fractional", input: 1.2, want: 1.2}, | |
| 16 | + | {name: "nan", input: math.NaN(), want: 1}, | |
| 17 | + | {name: "positive_infinity", input: math.Inf(1), want: 1}, | |
| 18 | + | {name: "negative_infinity", input: math.Inf(-1), want: 1}, | |
| 19 | + | {name: "negative", input: -1, want: 1}, | |
| 20 | + | } | |
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | if got := NormalizeScale(tt.input); got != tt.want { | |
| 24 | + | t.Fatalf("NormalizeScale(%v) = %v, want %v", tt.input, got, tt.want) | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestDeviceLogicalConversions(t *testing.T) { | |
| 31 | + | if got := DeviceToLogical(320, 1.25); got != 256 { | |
| 32 | + | t.Fatalf("DeviceToLogical(320, 1.25) = %d, want 256", got) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package gtkutil | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestNormalizeScale(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | input float64 | |||
| 12 | + | want float64 | |||
| 13 | + | }{ | |||
| 14 | + | {name: "zero", input: 0, want: 1}, | |||
| 15 | + | {name: "fractional", input: 1.2, want: 1.2}, | |||
| 16 | + | {name: "nan", input: math.NaN(), want: 1}, | |||
| 17 | + | {name: "positive_infinity", input: math.Inf(1), want: 1}, | |||
| 18 | + | {name: "negative_infinity", input: math.Inf(-1), want: 1}, | |||
| 19 | + | {name: "negative", input: -1, want: 1}, | |||
| 20 | + | } | |||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | if got := NormalizeScale(tt.input); got != tt.want { | |||
| 24 | + | t.Fatalf("NormalizeScale(%v) = %v, want %v", tt.input, got, tt.want) | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestDeviceLogicalConversions(t *testing.T) { | |||
| 31 | + | if got := DeviceToLogical(320, 1.25); got != 256 { | |||
| 32 | + | t.Fatalf("DeviceToLogical(320, 1.25) = %d, want 256", got) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/ui/coordinator/content/lifecycle.go +5 -3
| @@ -6,11 +6,11 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/gtkutil" | |
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 12 | "github.com/bnema/dumber/internal/ui/input" | |
| 12 | 13 | "github.com/bnema/dumber/internal/ui/layout" | |
| 13 | - | "github.com/bnema/puregotk/v4/gtk" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| @@ -167,8 +167,10 @@ func (c *Coordinator) WrapWidget(ctx context.Context, wv port.WebView) layout.Wi | |||
| 167 | 167 | return nil | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | - | gtkWidget := >k.Widget{} | |
| 171 | - | gtkWidget.Ptr = ptr | |
| 170 | + | gtkWidget := gtkutil.WidgetFromNativePointer(ptr) | |
| 171 | + | if gtkWidget == nil { | |
| 172 | + | return nil | |
| 173 | + | } | |
| 172 | 174 | widget := c.widgetFactory.WrapWidget(gtkWidget) | |
| 173 | 175 | ||
| 174 | 176 | // Attach gesture handler for mouse button 8/9 navigation | |
| @@ -6,11 +6,11 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/gtkutil" | |||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/dumber/internal/ui/component" | 11 | "github.com/bnema/dumber/internal/ui/component" | ||
| 11 | "github.com/bnema/dumber/internal/ui/input" | 12 | "github.com/bnema/dumber/internal/ui/input" | ||
| 12 | "github.com/bnema/dumber/internal/ui/layout" | 13 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 13 | - | "github.com/bnema/puregotk/v4/gtk" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // EnsureWebView acquires or reuses a WebView for the given pane. | 16 | // EnsureWebView acquires or reuses a WebView for the given pane. | ||
| @@ -167,8 +167,10 @@ func (c *Coordinator) WrapWidget(ctx context.Context, wv port.WebView) layout.Wi | |||||
| 167 | return nil | 167 | return nil | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | - | gtkWidget := >k.Widget{} | 170 | + | gtkWidget := gtkutil.WidgetFromNativePointer(ptr) |
| 171 | - | gtkWidget.Ptr = ptr | 171 | + | if gtkWidget == nil { |
| 172 | + | return nil | |||
| 173 | + | } | |||
| 172 | widget := c.widgetFactory.WrapWidget(gtkWidget) | 174 | widget := c.widgetFactory.WrapWidget(gtkWidget) | ||
| 173 | 175 | ||||
| 174 | // Attach gesture handler for mouse button 8/9 navigation | 176 | // Attach gesture handler for mouse button 8/9 navigation | ||