detached 7bebba6

Merge pull request #253 from bnema/fix/fractional-wayland-scale

Fix fractional-scale CEF OSR rendering
commit
7bebba626804703ae17939d1b9da636fe2be70cc
author
bnema · · 2026-05-16 17:42 UTC
committer
GitHub · · 2026-05-16 17:42 UTC
merge of
53b9850 + 900aa67 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +416 -104
M go.mod +1 -1
@@ -7,7 +7,7 @@ require (
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
99 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
1111 github.com/bnema/purego-pipewire v0.1.3
1212 github.com/bnema/purego-sqlite v0.1.2
1313 github.com/bnema/puregotk v0.5.2
@@ -7,7 +7,7 @@ require (
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9 github.com/bnema/purego-cef v0.13.09 github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.1.110+ github.com/bnema/purego-cef2gtk v0.2.0
11 github.com/bnema/purego-pipewire v0.1.311 github.com/bnema/purego-pipewire v0.1.3
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
13 github.com/bnema/puregotk v0.5.213 github.com/bnema/puregotk v0.5.2
M go.sum +2 -2 generated file: collapsed
M internal/infrastructure/cef/cef2gtk_adapter.go +15 -0
@@ -107,6 +107,21 @@ func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 {
107107 return a.view.DeviceScaleFactor()
108108 }
109109
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+
110125 // AddSizeObserver registers a positive-size change callback. Register and
111126 // unregister from the GTK main thread; callbacks are invoked by the bridge from
112127 // 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 and125 // AddSizeObserver registers a positive-size change callback. Register and
111 // unregister from the GTK main thread; callbacks are invoked by the bridge from126 // 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(
7979 return 0
8080 }
8181
82- x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale())
82+ x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor())
8383 executor := h.contextMenuExecutor()
8484 wv := h.wv
8585 wv.runOnGTK(func() {
@@ -126,14 +126,14 @@ func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
126126 )
127127 }
128128
129-func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) {
129+func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) {
130130 if params == nil {
131131 return 0, 0
132132 }
133- if scale <= 0 {
134- scale = 1
133+ if backingScale <= 1 {
134+ return params.GetXcoord(), params.GetYcoord()
135135 }
136- return params.GetXcoord() / scale, params.GetYcoord() / scale
136+ return deviceToLogicalCoord(params.GetXcoord(), scale), deviceToLogicalCoord(params.GetYcoord(), scale)
137137 }
138138
139139 func logContextMenuPopupRequest(
@@ -152,7 +152,7 @@ func logContextMenuPopupRequest(
152152 Int32("raw_y", rawY).
153153 Int32("popup_x", x).
154154 Int32("popup_y", y).
155- Int32("scale", h.wv.viewBridgeScale()).
155+ Float64("scale", h.wv.viewBridgeScale()).
156156 Int("anchor_width", widget.GetAllocatedWidth()).
157157 Int("anchor_height", widget.GetAllocatedHeight()).
158158 Int("parent_width", cefWidgetAllocatedWidth(parent)).
@@ -79,7 +79,7 @@ func (h *handlerSet) RunContextMenu(
79 return 079 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.wv84 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, 0131 return 0, 0
132 }132 }
133- if scale <= 0 {133+ if backingScale <= 1 {
134- scale = 1134+ return params.GetXcoord(), params.GetYcoord()
135 }135 }
136- return params.GetXcoord() / scale, params.GetYcoord() / scale136+ 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
216216 require.Zero(t, executor.executeCalls)
217217 }
218218
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)
221221
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)
224228 }
225229
226230 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(
208208 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
209209 ) (*Engine, error) {
210210 eng.factory = newWebViewFactory(eng, webViewFactoryOptions{
211- scale: detectHiDPIScale(logger),
212211 windowlessFrameRate: windowlessFrameRate,
213212 audioOutputFactory: audioFactory,
214213 })
@@ -315,40 +314,6 @@ func registerEngineSchemeFactory(
315314 Msg("cef: registered internal https handler factory")
316315 }
317316
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-
352317 // appendIfMissing appends flag to args if it's not already present.
353318 func appendIfMissing(args []string, flag string) []string {
354319 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 (
1313 cefRenderNodeEnvVar = "DUMBER_CEF_RENDER_NODE"
1414 cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY"
1515 cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE"
16+ cefScaleProbeEnvVar = "DUMBER_CEF_SCALE_PROBE"
17+ cef2GTKTraceScaleEnvVar = "PUREGO_CEF2GTK_TRACE_SCALE"
1618 )
1719
1820 // envBoolEnabled returns true when the given environment variable is set
@@ -41,3 +43,7 @@ func renderStallRecoveryEnabled() bool {
4143 func renderStallBacktraceEnabled() bool {
4244 return envBoolEnabled(cefRenderStallBacktraceEnvVar)
4345 }
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 set20 // 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)
2222 type WebViewFactory struct {
2323 engine *Engine
2424 nextID atomic.Uint64
25- scale int32
2625 windowlessFrameRate int32
2726 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
2827 audioOutputFactory port.AudioOutputFactory
2928 }
3029
3130 type webViewFactoryOptions struct {
32- scale int32
3331 windowlessFrameRate int32
3432 audioOutputFactory port.AudioOutputFactory
3533 }
@@ -46,18 +44,13 @@ const (
4644 pendingBrowserCreateMaxPostRetries = 3
4745 )
4846
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.
5148 func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory {
52- if opts.scale < 1 {
53- opts.scale = 1
54- }
5549 if opts.windowlessFrameRate < 1 {
5650 opts.windowlessFrameRate = 60
5751 }
5852 return &WebViewFactory{
5953 engine: engine,
60- scale: opts.scale,
6154 windowlessFrameRate: opts.windowlessFrameRate,
6255 audioOutputFactory: opts.audioOutputFactory,
6356 }
@@ -22,14 +22,12 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
22 type WebViewFactory struct {22 type WebViewFactory struct {
23 engine *Engine23 engine *Engine
24 nextID atomic.Uint6424 nextID atomic.Uint64
25- scale int32
26 windowlessFrameRate int3225 windowlessFrameRate int32
27 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor26 bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
28 audioOutputFactory port.AudioOutputFactory27 audioOutputFactory port.AudioOutputFactory
29 }28 }
30 29
31 type webViewFactoryOptions struct {30 type webViewFactoryOptions struct {
32- scale int32
33 windowlessFrameRate int3231 windowlessFrameRate int32
34 audioOutputFactory port.AudioOutputFactory32 audioOutputFactory port.AudioOutputFactory
35 }33 }
@@ -46,18 +44,13 @@ const (
46 pendingBrowserCreateMaxPostRetries = 344 pendingBrowserCreateMaxPostRetries = 3
47 )45 )
48 46
49-// newWebViewFactory returns a factory that will create WebViews using the47+// 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 = 6050 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 {
9494 var safe string
9595 switch name {
9696 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":
9899 if hasValue && value != "" {
99100 safe = name + "=" + value
100101 } else {
@@ -94,7 +94,8 @@ func safeChromiumCmdlineFlags(cmdline string) []string {
94 var safe string94 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 + "=" + value100 safe = name + "=" + value
100 } else {101 } else {
M internal/infrastructure/cef/render_stack_env.go +16 -13
@@ -12,19 +12,20 @@ const (
1212 dumberRenderStackEnvVar = "DUMBER_RENDER_STACK"
1313 dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT"
1414
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"
2829 )
2930
3031 // ApplyDefaultRenderStackEnvironment configures the process environment for
@@ -54,6 +55,7 @@ func applyDefaultRenderStackEnvironment(logger port.Logger) string {
5455 setRenderStackEnv(logger, gskRendererEnvVar, gskRendererVulkan)
5556 setRenderStackEnv(logger, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF)
5657 setRenderStackEnv(logger, cef2gtkAngleBackendVar, cef2gtkAngleVulkan)
58+ setEnvDefault(logger, cef2gtkOSRBackingScaleEnvVar, "auto")
5759 }
5860 logRenderStackEnvironment(logger, stack)
5961 return stack
@@ -65,6 +67,7 @@ func logRenderStackEnvironment(logger port.Logger, stack string) {
6567 port.Field("gsk_renderer", os.Getenv(gskRendererEnvVar)),
6668 port.Field("cef2gtk_backend", os.Getenv(cef2gtkBackendEnvVar)),
6769 port.Field("cef2gtk_angle_backend", os.Getenv(cef2gtkAngleBackendVar)),
70+ port.Field("cef2gtk_osr_backing_scale", os.Getenv(cef2gtkOSRBackingScaleEnvVar)),
6871 port.Field("split_stack_allowed", envBoolEnabled(dumberRenderStackAllowSplitEnvVar)),
6972 )
7073 }
@@ -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 for31 // 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 stack61 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
1313 t.Setenv("GSK_RENDERER", "")
1414 t.Setenv("PUREGO_CEF2GTK_BACKEND", "")
1515 t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "")
16+ t.Setenv(cef2gtkOSRBackingScaleEnvVar, "")
1617
1718 got := applyDefaultRenderStackEnvironment(nil)
1819
@@ -28,6 +29,9 @@ func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t
2829 if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" {
2930 t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want vulkan", got)
3031 }
32+ if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "auto" {
33+ t.Fatalf("%s = %q, want auto", cef2gtkOSRBackingScaleEnvVar, got)
34+ }
3135 }
3236
3337 func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults(t *testing.T) {
@@ -36,6 +40,7 @@ func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults
3640 t.Setenv("GSK_RENDERER", "ngl")
3741 t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea")
3842 t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl")
43+ t.Setenv(cef2gtkOSRBackingScaleEnvVar, "")
3944
4045 got := applyDefaultRenderStackEnvironment(nil)
4146
@@ -51,6 +56,9 @@ func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults
5156 if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" {
5257 t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want coherent vulkan default", got)
5358 }
59+ if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "auto" {
60+ t.Fatalf("%s = %q, want auto", cef2gtkOSRBackingScaleEnvVar, got)
61+ }
5462 }
5563
5664 func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelOverrides(t *testing.T) {
@@ -59,6 +67,7 @@ func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelO
5967 t.Setenv("GSK_RENDERER", "ngl")
6068 t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea")
6169 t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl")
70+ t.Setenv(cef2gtkOSRBackingScaleEnvVar, "off")
6271
6372 got := applyDefaultRenderStackEnvironment(nil)
6473
@@ -74,6 +83,9 @@ func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelO
7483 if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
7584 t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got)
7685 }
86+ if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "off" {
87+ t.Fatalf("%s = %q, want explicit off", cef2gtkOSRBackingScaleEnvVar, got)
88+ }
7789 }
7890
7991 func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
@@ -81,6 +93,7 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
8193 t.Setenv("GSK_RENDERER", "")
8294 t.Setenv("PUREGO_CEF2GTK_BACKEND", "")
8395 t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "")
96+ t.Setenv(cef2gtkOSRBackingScaleEnvVar, "")
8497
8598 got := applyDefaultRenderStackEnvironment(nil)
8699
@@ -93,6 +106,9 @@ func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) {
93106 if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" {
94107 t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got)
95108 }
109+ if got := os.Getenv(cef2gtkOSRBackingScaleEnvVar); got != "" {
110+ t.Fatalf("%s = %q, want unset for legacy-gl", cef2gtkOSRBackingScaleEnvVar, got)
111+ }
96112 }
97113
98114 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 {
443443 return math.Log(factor) / math.Log(chromiumZoomBase)
444444 }
445445
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+
446470 // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
447471 // linear zoom factor.
448472 func factorFromCEFZoom(level float64) float64 {
@@ -468,25 +492,15 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
468492 if host == nil {
469493 return errNoBrowser
470494 }
471- cefLevel := cefZoomFromFactor(factor)
495+ backingScale := wv.osrBackingScaleFactor()
496+ cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
472497 logging.FromContext(wv.ctx).Debug().
473498 Float64("factor", factor).
474499 Float64("cef_level", cefLevel).
500+ Float64("osr_backing_scale", backingScale).
475501 Msg("cef: SetZoomLevel")
476- host.SetZoomLevel(cefLevel)
502+ wv.applyCEFZoomLevel(host, factor, cefLevel)
477503 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)
490504 return nil
491505 }
492506
@@ -1376,15 +1390,18 @@ func (wv *WebView) selectedTextSnapshot() string {
13761390 return wv.selectedText
13771391 }
13781392
1379-func (wv *WebView) viewBridgeScale() int32 {
1393+func (wv *WebView) viewBridgeScale() float64 {
13801394 if wv == nil || wv.viewBridge == nil {
13811395 return 1
13821396 }
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 {
13851402 return 1
13861403 }
1387- return scale
1404+ return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
13881405 }
13891406
13901407 func (wv *WebView) handleMiddleClickFromBridge() bool {
@@ -1603,7 +1620,7 @@ func (wv *WebView) updateHoverURI(uri string) {
16031620 // These values are empirically tuned for CEF's async zoom application.
16041621 func (wv *WebView) scheduleZoomRefresh() {
16051622 for _, delayMs := range [...]int64{16, 48} {
1606- purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
1623+ task := cefNewTask(cefTaskFunc(func() {
16071624 if wv.destroyed.Load() {
16081625 return
16091626 }
@@ -1614,7 +1631,11 @@ func (wv *WebView) scheduleZoomRefresh() {
16141631 return
16151632 }
16161633 host.Invalidate(purecef.PaintElementTypePetView)
1617- })), delayMs)
1634+ }))
1635+ if task == nil {
1636+ continue
1637+ }
1638+ cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
16181639 }
16191640 }
16201641
@@ -1627,7 +1648,7 @@ func (wv *WebView) scheduleZoomRefresh() {
16271648 // These values are empirically tuned for CEF's async zoom application.
16281649 func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
16291650 for _, delayMs := range [...]int64{0, 64} {
1630- purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
1651+ task := cefNewTask(cefTaskFunc(func() {
16311652 if wv.destroyed.Load() {
16321653 return
16331654 }
@@ -1637,18 +1658,47 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
16371658 if host == nil {
16381659 return
16391660 }
1661+ backingScale := wv.osrBackingScaleFactor()
16401662 actualLevel := host.GetZoomLevel()
16411663 logging.FromContext(wv.ctx).Debug().
16421664 Int64("delay_ms", delayMs).
16431665 Float64("expected_factor", expectedFactor).
16441666 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)).
16461669 Float64("actual_cef_level", actualLevel).
1670+ Float64("osr_backing_scale", backingScale).
16471671 Msg("cef: zoom level readback")
1648- })), delayMs)
1672+ }))
1673+ if task == nil {
1674+ continue
1675+ }
1676+ cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
16491677 }
16501678 }
16511679
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+
16521702 func (wv *WebView) scheduleStartBeginFrameLoop() {
16531703 if !externalBeginFrameEnabled() {
16541704 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 a470 // 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 errNoBrowser493 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 nil504 return nil
491 }505 }
492 506
@@ -1376,15 +1390,18 @@ func (wv *WebView) selectedTextSnapshot() string {
1376 return wv.selectedText1390 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 11395 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 11402 return 1
1386 }1403 }
1387- return scale1404+ 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 return1625 return
1609 }1626 }
@@ -1614,7 +1631,11 @@ func (wv *WebView) scheduleZoomRefresh() {
1614 return1631 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 return1653 return
1633 }1654 }
@@ -1637,18 +1658,47 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
1637 if host == nil {1658 if host == nil {
1638 return1659 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 return1704 return
M internal/infrastructure/cef/webview_audio_test.go +0 -2
@@ -26,7 +26,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) {
2626
2727 // Verify webViewFactoryOptions can hold the audio factory
2828 opts := webViewFactoryOptions{
29- scale: 1,
3029 windowlessFrameRate: 60,
3130 audioOutputFactory: audioFactory,
3231 }
@@ -44,7 +43,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) {
4443 func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) {
4544 // Arrange
4645 opts := webViewFactoryOptions{
47- scale: 1,
4846 windowlessFrameRate: 60,
4947 audioOutputFactory: nil,
5048 }
@@ -26,7 +26,6 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) {
26 26
27 // Verify webViewFactoryOptions can hold the audio factory27 // 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 // Arrange44 // 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 (
1616
1717 // Console message markers used by injected JavaScript for log filtering.
1818 const (
19- consoleMarkerVideoDiag = "[VIDEO-DIAG]"
20- consoleMarkerAutoCopy = "[AUTO-COPY]"
19+ consoleMarkerVideoDiag = "[VIDEO-DIAG]"
20+ consoleMarkerAutoCopy = "[AUTO-COPY]"
21+ consoleMarkerScaleProbe = "[SCALE-PROBE]"
2122 )
2223
2324 // handlerSet implements all CEF handler interfaces and dispatches events to the
@@ -226,7 +227,8 @@ func (h *handlerSet) OnConsoleMessage(
226227 ) int32 {
227228 if h.wv != nil && h.wv.ctx != nil &&
228229 (strings.Contains(message, consoleMarkerVideoDiag) ||
229- strings.Contains(message, consoleMarkerAutoCopy)) {
230+ strings.Contains(message, consoleMarkerAutoCopy) ||
231+ strings.Contains(message, consoleMarkerScaleProbe)) {
230232 log := logging.FromContext(h.wv.ctx).With().
231233 Str("component", "cef-console").
232234 Str("source", source).
@@ -399,6 +401,14 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
399401 // Inject scripts and styles after page load.
400402 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
401403 // 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+ }
402412 if h.wv.engine != nil && h.wv.engine.contentInj != nil {
403413 h.wv.runOnGTK(func() {
404414 h.wv.engine.contentInj.onLoadEnd(h.wv)
@@ -630,7 +640,7 @@ func (h *handlerSet) attachAfterCreatedBrowser(
630640 return
631641 }
632642 if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{
633- Scale: wv.viewBridgeScale(),
643+ Scale: 0,
634644 OnMiddleClick: func(_, _ float64) bool {
635645 return wv.handleMiddleClickFromBridge()
636646 },
@@ -656,6 +666,9 @@ func (h *handlerSet) attachAfterCreatedBrowser(
656666 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
657667 // painting/caret updates until explicitly told the browser is shown.
658668 host.WasHidden(0)
669+ if h.wv != nil {
670+ h.wv.SyncViewport(h.wv.ctx, "after-created")
671+ }
659672 return state
660673 }
661674
@@ -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 the24 // 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 return640 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 suppresses666 // 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 state672 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
101101
102102 widget := wv.viewBridge.Widget()
103103 visible := widget != nil && widget.IsVisible()
104- notifyBrowserViewportSync(host, visible)
104+ wv.notifyViewportSyncOnCEFUIThread(host, visible)
105105 logging.FromContext(ctx).Debug().
106106 Uint64("webview_id", uint64(wv.id)).
107107 Bool("visible", visible).
@@ -264,6 +264,29 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
264264 wv.viewportRealizeFunc = nil
265265 }
266266
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+
267290 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
268291 if host == nil {
269292 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 = nil264 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 return292 return
M internal/infrastructure/cef/webview_viewport_test.go +102 -1
@@ -10,7 +10,8 @@ import (
1010
1111 type viewportSyncOrderHost struct {
1212 purecef.BrowserHost
13- calls []string
13+ calls []string
14+ zoomLevel float64
1415 }
1516
1617 func (h *viewportSyncOrderHost) WasHidden(state int32) {
@@ -32,6 +33,15 @@ func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) {
3233 h.calls = append(h.calls, "Invalidate")
3334 }
3435
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+
3545 func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) {
3646 host := &viewportSyncOrderHost{}
3747
@@ -48,6 +58,97 @@ func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) {
4858 require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
4959 }
5060
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+
51152 func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
52153 oldNewTask := cefNewTask
53154 oldPostDelayedTask := cefPostDelayedTask
@@ -10,7 +10,8 @@ import (
10 10
11 type viewportSyncOrderHost struct {11 type viewportSyncOrderHost struct {
12 purecef.BrowserHost12 purecef.BrowserHost
13- calls []string13+ 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 := cefNewTask153 oldNewTask := cefNewTask
53 oldPostDelayedTask := cefPostDelayedTask154 oldPostDelayedTask := cefPostDelayedTask
M internal/infrastructure/cef/webview_zoom_test.go +12 -0
@@ -17,3 +17,15 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
1717 })
1818 }
1919 }
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 (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/gtkutil"
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/dumber/internal/ui/component"
1112 "github.com/bnema/dumber/internal/ui/input"
1213 "github.com/bnema/dumber/internal/ui/layout"
13- "github.com/bnema/puregotk/v4/gtk"
1414 )
1515
1616 // 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
167167 return nil
168168 }
169169
170- gtkWidget := &gtk.Widget{}
171- gtkWidget.Ptr = ptr
170+ gtkWidget := gtkutil.WidgetFromNativePointer(ptr)
171+ if gtkWidget == nil {
172+ return nil
173+ }
172174 widget := c.widgetFactory.WrapWidget(gtkWidget)
173175
174176 // 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 nil167 return nil
168 }168 }
169 169
170- gtkWidget := &gtk.Widget{}170+ gtkWidget := gtkutil.WidgetFromNativePointer(ptr)
171- gtkWidget.Ptr = ptr171+ 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 navigation176 // Attach gesture handler for mouse button 8/9 navigation

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close