detached b6c23e2

fix: support fractional scale CEF OSR

commit
b6c23e2b56b61f76d778b67ea744bbdc7a220f58
author
brice · · 2026-05-16 14:58 UTC
committer
brice · · 2026-05-16 15:01 UTC
parent
53b9850
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +239 -86
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 +7 -6
@@ -8,6 +8,7 @@ import (
88 purecef "github.com/bnema/purego-cef/cef"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/infrastructure/gtkutil"
1112 "github.com/bnema/dumber/internal/logging"
1213 "github.com/bnema/puregotk/v4/gtk"
1314 )
@@ -79,7 +80,7 @@ func (h *handlerSet) RunContextMenu(
7980 return 0
8081 }
8182
82- x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale())
83+ x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor())
8384 executor := h.contextMenuExecutor()
8485 wv := h.wv
8586 wv.runOnGTK(func() {
@@ -126,14 +127,14 @@ func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
126127 )
127128 }
128129
129-func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) {
130+func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) {
130131 if params == nil {
131132 return 0, 0
132133 }
133- if scale <= 0 {
134- scale = 1
134+ if backingScale <= 1 {
135+ return params.GetXcoord(), params.GetYcoord()
135136 }
136- return params.GetXcoord() / scale, params.GetYcoord() / scale
137+ return gtkutil.DeviceToLogical(params.GetXcoord(), scale), gtkutil.DeviceToLogical(params.GetYcoord(), scale)
137138 }
138139
139140 func logContextMenuPopupRequest(
@@ -152,7 +153,7 @@ func logContextMenuPopupRequest(
152153 Int32("raw_y", rawY).
153154 Int32("popup_x", x).
154155 Int32("popup_y", y).
155- Int32("scale", h.wv.viewBridgeScale()).
156+ Float64("scale", h.wv.viewBridgeScale()).
156157 Int("anchor_width", widget.GetAllocatedWidth()).
157158 Int("anchor_height", widget.GetAllocatedHeight()).
158159 Int("parent_width", cefWidgetAllocatedWidth(parent)).
@@ -8,6 +8,7 @@ import (
8 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/infrastructure/gtkutil"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gtk"13 "github.com/bnema/puregotk/v4/gtk"
13 )14 )
@@ -79,7 +80,7 @@ func (h *handlerSet) RunContextMenu(
79 return 080 return 0
80 }81 }
81 82
82- x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale())83+ x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale(), h.wv.osrBackingScaleFactor())
83 executor := h.contextMenuExecutor()84 executor := h.contextMenuExecutor()
84 wv := h.wv85 wv := h.wv
85 wv.runOnGTK(func() {86 wv.runOnGTK(func() {
@@ -126,14 +127,14 @@ func (h *handlerSet) contextMenuExecutor() port.ContextMenuActionExecutor {
126 )127 )
127 }128 }
128 129
129-func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (int32, int32) {130+func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale, backingScale float64) (int32, int32) {
130 if params == nil {131 if params == nil {
131 return 0, 0132 return 0, 0
132 }133 }
133- if scale <= 0 {134+ if backingScale <= 1 {
134- scale = 1135+ return params.GetXcoord(), params.GetYcoord()
135 }136 }
136- return params.GetXcoord() / scale, params.GetYcoord() / scale137+ return gtkutil.DeviceToLogical(params.GetXcoord(), scale), gtkutil.DeviceToLogical(params.GetYcoord(), scale)
137 }138 }
138 139
139 func logContextMenuPopupRequest(140 func logContextMenuPopupRequest(
@@ -152,7 +153,7 @@ func logContextMenuPopupRequest(
152 Int32("raw_y", rawY).153 Int32("raw_y", rawY).
153 Int32("popup_x", x).154 Int32("popup_x", x).
154 Int32("popup_y", y).155 Int32("popup_y", y).
155- Int32("scale", h.wv.viewBridgeScale()).156+ Float64("scale", h.wv.viewBridgeScale()).
156 Int("anchor_width", widget.GetAllocatedWidth()).157 Int("anchor_width", widget.GetAllocatedWidth()).
157 Int("anchor_height", widget.GetAllocatedHeight()).158 Int("anchor_height", widget.GetAllocatedHeight()).
158 Int("parent_width", cefWidgetAllocatedWidth(parent)).159 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_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 +45 -6
@@ -20,6 +20,7 @@ import (
2020
2121 "github.com/bnema/dumber/internal/application/dto"
2222 "github.com/bnema/dumber/internal/application/port"
23+ "github.com/bnema/dumber/internal/infrastructure/gtkutil"
2324 "github.com/bnema/dumber/internal/logging"
2425 )
2526
@@ -443,6 +444,14 @@ func cefZoomFromFactor(factor float64) float64 {
443444 return math.Log(factor) / math.Log(chromiumZoomBase)
444445 }
445446
447+func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {
448+ return cefZoomFromFactor(pageZoom / gtkutil.NormalizeScale(backingScale))
449+}
450+
451+func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
452+ return factorFromCEFZoom(level) * gtkutil.NormalizeScale(backingScale)
453+}
454+
446455 // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
447456 // linear zoom factor.
448457 func factorFromCEFZoom(level float64) float64 {
@@ -468,10 +477,12 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
468477 if host == nil {
469478 return errNoBrowser
470479 }
471- cefLevel := cefZoomFromFactor(factor)
480+ backingScale := wv.osrBackingScaleFactor()
481+ cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
472482 logging.FromContext(wv.ctx).Debug().
473483 Float64("factor", factor).
474484 Float64("cef_level", cefLevel).
485+ Float64("osr_backing_scale", backingScale).
475486 Msg("cef: SetZoomLevel")
476487 host.SetZoomLevel(cefLevel)
477488 wv.zoomFactor.Store(factor)
@@ -1376,15 +1387,18 @@ func (wv *WebView) selectedTextSnapshot() string {
13761387 return wv.selectedText
13771388 }
13781389
1379-func (wv *WebView) viewBridgeScale() int32 {
1390+func (wv *WebView) viewBridgeScale() float64 {
13801391 if wv == nil || wv.viewBridge == nil {
13811392 return 1
13821393 }
1383- scale := int32(math.Round(float64(wv.viewBridge.DeviceScaleFactor())))
1384- if scale < 1 {
1394+ return gtkutil.NormalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
1395+}
1396+
1397+func (wv *WebView) osrBackingScaleFactor() float64 {
1398+ if wv == nil || wv.viewBridge == nil {
13851399 return 1
13861400 }
1387- return scale
1401+ return gtkutil.NormalizeScale(wv.viewBridge.OSRBackingScaleFactor())
13881402 }
13891403
13901404 func (wv *WebView) handleMiddleClickFromBridge() bool {
@@ -1637,18 +1651,43 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
16371651 if host == nil {
16381652 return
16391653 }
1654+ backingScale := wv.osrBackingScaleFactor()
16401655 actualLevel := host.GetZoomLevel()
16411656 logging.FromContext(wv.ctx).Debug().
16421657 Int64("delay_ms", delayMs).
16431658 Float64("expected_factor", expectedFactor).
16441659 Float64("expected_cef_level", expectedLevel).
1645- Float64("actual_factor", factorFromCEFZoom(actualLevel)).
1660+ Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)).
1661+ Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).
16461662 Float64("actual_cef_level", actualLevel).
1663+ Float64("osr_backing_scale", backingScale).
16471664 Msg("cef: zoom level readback")
16481665 })), delayMs)
16491666 }
16501667 }
16511668
1669+func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
1670+ if wv == nil || wv.destroyed.Load() {
1671+ return
1672+ }
1673+ wv.mu.RLock()
1674+ host := wv.host
1675+ wv.mu.RUnlock()
1676+ if host == nil {
1677+ return
1678+ }
1679+ factor := wv.GetZoomLevel()
1680+ backingScale := wv.osrBackingScaleFactor()
1681+ cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
1682+ host.SetZoomLevel(cefLevel)
1683+ logging.FromContext(wv.ctx).Debug().
1684+ Str("reason", reason).
1685+ Float64("factor", factor).
1686+ Float64("cef_level", cefLevel).
1687+ Float64("osr_backing_scale", backingScale).
1688+ Msg("cef: reapplied zoom for OSR backing scale")
1689+}
1690+
16521691 func (wv *WebView) scheduleStartBeginFrameLoop() {
16531692 if !externalBeginFrameEnabled() {
16541693 return
@@ -20,6 +20,7 @@ import (
20 20
21 "github.com/bnema/dumber/internal/application/dto"21 "github.com/bnema/dumber/internal/application/dto"
22 "github.com/bnema/dumber/internal/application/port"22 "github.com/bnema/dumber/internal/application/port"
23+ "github.com/bnema/dumber/internal/infrastructure/gtkutil"
23 "github.com/bnema/dumber/internal/logging"24 "github.com/bnema/dumber/internal/logging"
24 )25 )
25 26
@@ -443,6 +444,14 @@ func cefZoomFromFactor(factor float64) float64 {
443 return math.Log(factor) / math.Log(chromiumZoomBase)444 return math.Log(factor) / math.Log(chromiumZoomBase)
444 }445 }
445 446
447+func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {
448+ return cefZoomFromFactor(pageZoom / gtkutil.NormalizeScale(backingScale))
449+}
450+
451+func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
452+ return factorFromCEFZoom(level) * gtkutil.NormalizeScale(backingScale)
453+}
454+
446 // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a455 // factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
447 // linear zoom factor.456 // linear zoom factor.
448 func factorFromCEFZoom(level float64) float64 {457 func factorFromCEFZoom(level float64) float64 {
@@ -468,10 +477,12 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
468 if host == nil {477 if host == nil {
469 return errNoBrowser478 return errNoBrowser
470 }479 }
471- cefLevel := cefZoomFromFactor(factor)480+ backingScale := wv.osrBackingScaleFactor()
481+ cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
472 logging.FromContext(wv.ctx).Debug().482 logging.FromContext(wv.ctx).Debug().
473 Float64("factor", factor).483 Float64("factor", factor).
474 Float64("cef_level", cefLevel).484 Float64("cef_level", cefLevel).
485+ Float64("osr_backing_scale", backingScale).
475 Msg("cef: SetZoomLevel")486 Msg("cef: SetZoomLevel")
476 host.SetZoomLevel(cefLevel)487 host.SetZoomLevel(cefLevel)
477 wv.zoomFactor.Store(factor)488 wv.zoomFactor.Store(factor)
@@ -1376,15 +1387,18 @@ func (wv *WebView) selectedTextSnapshot() string {
1376 return wv.selectedText1387 return wv.selectedText
1377 }1388 }
1378 1389
1379-func (wv *WebView) viewBridgeScale() int32 {1390+func (wv *WebView) viewBridgeScale() float64 {
1380 if wv == nil || wv.viewBridge == nil {1391 if wv == nil || wv.viewBridge == nil {
1381 return 11392 return 1
1382 }1393 }
1383- scale := int32(math.Round(float64(wv.viewBridge.DeviceScaleFactor())))1394+ return gtkutil.NormalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
1384- if scale < 1 {1395+}
1396+
1397+func (wv *WebView) osrBackingScaleFactor() float64 {
1398+ if wv == nil || wv.viewBridge == nil {
1385 return 11399 return 1
1386 }1400 }
1387- return scale1401+ return gtkutil.NormalizeScale(wv.viewBridge.OSRBackingScaleFactor())
1388 }1402 }
1389 1403
1390 func (wv *WebView) handleMiddleClickFromBridge() bool {1404 func (wv *WebView) handleMiddleClickFromBridge() bool {
@@ -1637,18 +1651,43 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
1637 if host == nil {1651 if host == nil {
1638 return1652 return
1639 }1653 }
1654+ backingScale := wv.osrBackingScaleFactor()
1640 actualLevel := host.GetZoomLevel()1655 actualLevel := host.GetZoomLevel()
1641 logging.FromContext(wv.ctx).Debug().1656 logging.FromContext(wv.ctx).Debug().
1642 Int64("delay_ms", delayMs).1657 Int64("delay_ms", delayMs).
1643 Float64("expected_factor", expectedFactor).1658 Float64("expected_factor", expectedFactor).
1644 Float64("expected_cef_level", expectedLevel).1659 Float64("expected_cef_level", expectedLevel).
1645- Float64("actual_factor", factorFromCEFZoom(actualLevel)).1660+ Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)).
1661+ Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).
1646 Float64("actual_cef_level", actualLevel).1662 Float64("actual_cef_level", actualLevel).
1663+ Float64("osr_backing_scale", backingScale).
1647 Msg("cef: zoom level readback")1664 Msg("cef: zoom level readback")
1648 })), delayMs)1665 })), delayMs)
1649 }1666 }
1650 }1667 }
1651 1668
1669+func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
1670+ if wv == nil || wv.destroyed.Load() {
1671+ return
1672+ }
1673+ wv.mu.RLock()
1674+ host := wv.host
1675+ wv.mu.RUnlock()
1676+ if host == nil {
1677+ return
1678+ }
1679+ factor := wv.GetZoomLevel()
1680+ backingScale := wv.osrBackingScaleFactor()
1681+ cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale)
1682+ host.SetZoomLevel(cefLevel)
1683+ logging.FromContext(wv.ctx).Debug().
1684+ Str("reason", reason).
1685+ Float64("factor", factor).
1686+ Float64("cef_level", cefLevel).
1687+ Float64("osr_backing_scale", backingScale).
1688+ Msg("cef: reapplied zoom for OSR backing scale")
1689+}
1690+
1652 func (wv *WebView) scheduleStartBeginFrameLoop() {1691 func (wv *WebView) scheduleStartBeginFrameLoop() {
1653 if !externalBeginFrameEnabled() {1692 if !externalBeginFrameEnabled() {
1654 return1693 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 +8 -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,13 @@ func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
264264 wv.viewportRealizeFunc = nil
265265 }
266266
267+func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, visible bool) {
268+ notifyBrowserViewportSync(host, visible)
269+ if wv != nil {
270+ wv.reapplyCurrentZoomForBackingScale("viewport-sync")
271+ }
272+}
273+
267274 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
268275 if host == nil {
269276 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,13 @@ 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+ notifyBrowserViewportSync(host, visible)
269+ if wv != nil {
270+ wv.reapplyCurrentZoomForBackingScale("viewport-sync")
271+ }
272+}
273+
267 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {274 func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
268 if host == nil {275 if host == nil {
269 return276 return
M internal/infrastructure/cef/webview_zoom_test.go +10 -0
@@ -17,3 +17,13 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
1717 })
1818 }
1919 }
20+
21+func TestZoomConversionsCompensateOSRBackingScale(t *testing.T) {
22+ level := cefZoomFromPageAndBackingFactor(1.0, 1.2)
23+ assert.InDelta(t, 1.0/1.2, factorFromCEFZoom(level), 1e-9)
24+ assert.InDelta(t, 1.0, pageZoomFromCEFAndBackingLevel(level, 1.2), 1e-9)
25+
26+ level = cefZoomFromPageAndBackingFactor(1.75, 1.2)
27+ assert.InDelta(t, 1.75/1.2, factorFromCEFZoom(level), 1e-9)
28+ assert.InDelta(t, 1.75, pageZoomFromCEFAndBackingLevel(level, 1.2), 1e-9)
29+}
@@ -17,3 +17,13 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
17 })17 })
18 }18 }
19 }19 }
20+
21+func TestZoomConversionsCompensateOSRBackingScale(t *testing.T) {
22+ level := cefZoomFromPageAndBackingFactor(1.0, 1.2)
23+ assert.InDelta(t, 1.0/1.2, factorFromCEFZoom(level), 1e-9)
24+ assert.InDelta(t, 1.0, pageZoomFromCEFAndBackingLevel(level, 1.2), 1e-9)
25+
26+ level = cefZoomFromPageAndBackingFactor(1.75, 1.2)
27+ assert.InDelta(t, 1.75/1.2, factorFromCEFZoom(level), 1e-9)
28+ assert.InDelta(t, 1.75, pageZoomFromCEFAndBackingLevel(level, 1.2), 1e-9)
29+}
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 +18 -0
@@ -0,0 +1,18 @@
1+package gtkutil
2+
3+import "testing"
4+
5+func TestNormalizeScale(t *testing.T) {
6+ if got := NormalizeScale(0); got != 1 {
7+ t.Fatalf("NormalizeScale(0) = %v, want 1", got)
8+ }
9+ if got := NormalizeScale(1.2); got != 1.2 {
10+ t.Fatalf("NormalizeScale(1.2) = %v, want 1.2", got)
11+ }
12+}
13+
14+func TestDeviceLogicalConversions(t *testing.T) {
15+ if got := DeviceToLogical(320, 1.25); got != 256 {
16+ t.Fatalf("DeviceToLogical(320, 1.25) = %d, want 256", got)
17+ }
18+}
@@ -0,0 +1,18 @@
1+package gtkutil
2+
3+import "testing"
4+
5+func TestNormalizeScale(t *testing.T) {
6+ if got := NormalizeScale(0); got != 1 {
7+ t.Fatalf("NormalizeScale(0) = %v, want 1", got)
8+ }
9+ if got := NormalizeScale(1.2); got != 1.2 {
10+ t.Fatalf("NormalizeScale(1.2) = %v, want 1.2", got)
11+ }
12+}
13+
14+func TestDeviceLogicalConversions(t *testing.T) {
15+ if got := DeviceToLogical(320, 1.25); got != 256 {
16+ t.Fatalf("DeviceToLogical(320, 1.25) = %d, want 256", got)
17+ }
18+}
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