detached 206243c

fix(cef): respect effective HiDPI scale for zoom

commit
206243cfc7dafb5d670fd35abcfee96f9375fb07
author
brice · · 2026-05-27 18:24 UTC
parent
21b3150
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +233 -25
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.3
99 github.com/bnema/purego-cef v0.13.2
10- github.com/bnema/purego-cef2gtk v0.5.5
10+ github.com/bnema/purego-cef2gtk v0.5.6
1111 github.com/bnema/purego-pipewire v0.1.4
1212 github.com/bnema/purego-sqlite v0.1.3
1313 github.com/bnema/puregotk v0.5.4
@@ -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.38 github.com/bnema/purego v0.11.0-bnema.3
9 github.com/bnema/purego-cef v0.13.29 github.com/bnema/purego-cef v0.13.2
10- github.com/bnema/purego-cef2gtk v0.5.510+ github.com/bnema/purego-cef2gtk v0.5.6
11 github.com/bnema/purego-pipewire v0.1.411 github.com/bnema/purego-pipewire v0.1.4
12 github.com/bnema/purego-sqlite v0.1.312 github.com/bnema/purego-sqlite v0.1.3
13 github.com/bnema/puregotk v0.5.413 github.com/bnema/puregotk v0.5.4
M go.sum +2 -2 generated file: collapsed
M internal/bootstrap/engine.go +1 -0
@@ -86,6 +86,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
8686 WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),
8787 EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
8888 TraceHandlers: cfg.Engine.CEF.TraceHandlers,
89+ ApplicationScale: cfg.DefaultUIScale,
8990 }
9091 deps := cef.EngineDependencies{
9192 RegisterHandlers: handlers.RegisterAll,
@@ -86,6 +86,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
86 WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),86 WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),
87 EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,87 EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
88 TraceHandlers: cfg.Engine.CEF.TraceHandlers,88 TraceHandlers: cfg.Engine.CEF.TraceHandlers,
89+ ApplicationScale: cfg.DefaultUIScale,
89 }90 }
90 deps := cef.EngineDependencies{91 deps := cef.EngineDependencies{
91 RegisterHandlers: handlers.RegisterAll,92 RegisterHandlers: handlers.RegisterAll,
M internal/infrastructure/cef/cef2gtk_adapter.go +2 -2
@@ -34,8 +34,8 @@ type Cef2gtkAdapter struct {
3434
3535 // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin
3636 // Dumber adapter.
37-func NewCef2gtkAdapter(renderStackPlan cef2gtk.RenderStackPlan) *Cef2gtkAdapter {
38- v := cef2gtk.NewViewWithOptions(cef2gtk.ViewOptions{RenderStackPlan: renderStackPlan})
37+func NewCef2gtkAdapter(renderStackPlan cef2gtk.RenderStackPlan, applicationScale float64) *Cef2gtkAdapter {
38+ v := cef2gtk.NewViewWithOptions(cef2gtk.ViewOptions{RenderStackPlan: renderStackPlan, ScaleMultiplier: applicationScale})
3939 if v == nil {
4040 return nil
4141 }
@@ -34,8 +34,8 @@ type Cef2gtkAdapter struct {
34 34
35 // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin35 // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin
36 // Dumber adapter.36 // Dumber adapter.
37-func NewCef2gtkAdapter(renderStackPlan cef2gtk.RenderStackPlan) *Cef2gtkAdapter {37+func NewCef2gtkAdapter(renderStackPlan cef2gtk.RenderStackPlan, applicationScale float64) *Cef2gtkAdapter {
38- v := cef2gtk.NewViewWithOptions(cef2gtk.ViewOptions{RenderStackPlan: renderStackPlan})38+ v := cef2gtk.NewViewWithOptions(cef2gtk.ViewOptions{RenderStackPlan: renderStackPlan, ScaleMultiplier: applicationScale})
39 if v == nil {39 if v == nil {
40 return nil40 return nil
41 }41 }
M internal/infrastructure/cef/engine.go +29 -8
@@ -10,6 +10,8 @@ import (
1010 "time"
1111 "unicode/utf8"
1212
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+
1315 purecef "github.com/bnema/purego-cef/cef"
1416 cef2gtk "github.com/bnema/purego-cef2gtk"
1517
@@ -25,12 +27,13 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
2527 // Engine implements port.Engine for the CEF browser backend.
2628 // It manages the CEF lifecycle and provides access to all engine subsystems.
2729 type Engine struct {
28- ctx context.Context
29- factory *WebViewFactory
30- pool *WebViewPool
31- profileLogDir string
32- runtimeCEFDir string
33- renderStackPlan cef2gtk.RenderStackPlan
30+ ctx context.Context
31+ factory *WebViewFactory
32+ pool *WebViewPool
33+ profileLogDir string
34+ runtimeCEFDir string
35+ renderStackPlan cef2gtk.RenderStackPlan
36+ applicationScale float64
3437
3538 messageRouter *MessageRouter
3639 schemeHandler *dumbSchemeHandler
@@ -346,8 +349,26 @@ func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) err
346349 return nil
347350 }
348351
349-// UpdateSettings applies runtime config changes to engine internals (Phase 1 stub).
350-func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) error {
352+// UpdateSettings applies runtime config changes to engine internals.
353+//
354+// CEF view scaling is fixed when each cef2gtk view is constructed. Changes to
355+// default_ui_scale therefore apply to newly-created CEF views in this engine;
356+// existing views continue using their construction-time scale until reload under
357+// a new engine lifetime. Browser zoom remains independently adjustable at
358+// runtime through SetZoomLevel.
359+func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsUpdate) error {
360+ cfg, ok := update.Raw.(*config.Config)
361+ if !ok {
362+ return fmt.Errorf("UpdateSettings: expected *config.Config, got %T", update.Raw)
363+ }
364+ newScale := normalizedApplicationScale(cfg.DefaultUIScale)
365+ if e.applicationScale != newScale {
366+ logging.FromContext(ctx).Info().
367+ Float64("old_application_scale", e.applicationScale).
368+ Float64("new_application_scale", newScale).
369+ Msg("cef: application scale update will apply to newly-created CEF views")
370+ e.applicationScale = newScale
371+ }
351372 return nil
352373 }
353374
@@ -10,6 +10,8 @@ import (
10 "time"10 "time"
11 "unicode/utf8"11 "unicode/utf8"
12 12
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+
13 purecef "github.com/bnema/purego-cef/cef"15 purecef "github.com/bnema/purego-cef/cef"
14 cef2gtk "github.com/bnema/purego-cef2gtk"16 cef2gtk "github.com/bnema/purego-cef2gtk"
15 17
@@ -25,12 +27,13 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
25 // Engine implements port.Engine for the CEF browser backend.27 // Engine implements port.Engine for the CEF browser backend.
26 // It manages the CEF lifecycle and provides access to all engine subsystems.28 // It manages the CEF lifecycle and provides access to all engine subsystems.
27 type Engine struct {29 type Engine struct {
28- ctx context.Context30+ ctx context.Context
29- factory *WebViewFactory31+ factory *WebViewFactory
30- pool *WebViewPool32+ pool *WebViewPool
31- profileLogDir string33+ profileLogDir string
32- runtimeCEFDir string34+ runtimeCEFDir string
33- renderStackPlan cef2gtk.RenderStackPlan35+ renderStackPlan cef2gtk.RenderStackPlan
36+ applicationScale float64
34 37
35 messageRouter *MessageRouter38 messageRouter *MessageRouter
36 schemeHandler *dumbSchemeHandler39 schemeHandler *dumbSchemeHandler
@@ -346,8 +349,26 @@ func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) err
346 return nil349 return nil
347 }350 }
348 351
349-// UpdateSettings applies runtime config changes to engine internals (Phase 1 stub).352+// UpdateSettings applies runtime config changes to engine internals.
350-func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) error {353+//
354+// CEF view scaling is fixed when each cef2gtk view is constructed. Changes to
355+// default_ui_scale therefore apply to newly-created CEF views in this engine;
356+// existing views continue using their construction-time scale until reload under
357+// a new engine lifetime. Browser zoom remains independently adjustable at
358+// runtime through SetZoomLevel.
359+func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsUpdate) error {
360+ cfg, ok := update.Raw.(*config.Config)
361+ if !ok {
362+ return fmt.Errorf("UpdateSettings: expected *config.Config, got %T", update.Raw)
363+ }
364+ newScale := normalizedApplicationScale(cfg.DefaultUIScale)
365+ if e.applicationScale != newScale {
366+ logging.FromContext(ctx).Info().
367+ Float64("old_application_scale", e.applicationScale).
368+ Float64("new_application_scale", newScale).
369+ Msg("cef: application scale update will apply to newly-created CEF views")
370+ e.applicationScale = newScale
371+ }
351 return nil372 return nil
352 }373 }
353 374
M internal/infrastructure/cef/engine_init.go +10 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "math"
78 "os"
89 "path/filepath"
910 "strconv"
@@ -66,6 +67,7 @@ func NewEngine(
6667 profileLogDir: paths.ProfileLogDir,
6768 runtimeCEFDir: settings.CEFDir,
6869 renderStackPlan: renderStackPlan,
70+ applicationScale: normalizedApplicationScale(cfg.ApplicationScale),
6971 registerHandlers: deps.RegisterHandlers,
7072 registerAccentHandlers: deps.RegisterAccentHandlers,
7173 currentConfigPayload: deps.CurrentConfigPayload,
@@ -86,6 +88,7 @@ func NewEngine(
8688 Bool("external_begin_frame", externalBeginFrameEnabled()).
8789 Bool("trace_handlers", cfg.TraceHandlers).
8890 Bool("enable_audio_handler", cfg.EnableAudioHandler).
91+ Float64("application_scale", eng.applicationScale).
8992 Msg("cef: configured engine")
9093
9194 if err := initializeCEF(eng, settings, logger); err != nil {
@@ -109,6 +112,13 @@ func NewEngine(
109112 )
110113 }
111114
115+func normalizedApplicationScale(scale float64) float64 {
116+ if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 {
117+ return 1
118+ }
119+ return scale
120+}
121+
112122 func normalizedWindowlessFrameRate(frameRate int32) int32 {
113123 if frameRate > 0 {
114124 return frameRate
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "math"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "strconv"10 "strconv"
@@ -66,6 +67,7 @@ func NewEngine(
66 profileLogDir: paths.ProfileLogDir,67 profileLogDir: paths.ProfileLogDir,
67 runtimeCEFDir: settings.CEFDir,68 runtimeCEFDir: settings.CEFDir,
68 renderStackPlan: renderStackPlan,69 renderStackPlan: renderStackPlan,
70+ applicationScale: normalizedApplicationScale(cfg.ApplicationScale),
69 registerHandlers: deps.RegisterHandlers,71 registerHandlers: deps.RegisterHandlers,
70 registerAccentHandlers: deps.RegisterAccentHandlers,72 registerAccentHandlers: deps.RegisterAccentHandlers,
71 currentConfigPayload: deps.CurrentConfigPayload,73 currentConfigPayload: deps.CurrentConfigPayload,
@@ -86,6 +88,7 @@ func NewEngine(
86 Bool("external_begin_frame", externalBeginFrameEnabled()).88 Bool("external_begin_frame", externalBeginFrameEnabled()).
87 Bool("trace_handlers", cfg.TraceHandlers).89 Bool("trace_handlers", cfg.TraceHandlers).
88 Bool("enable_audio_handler", cfg.EnableAudioHandler).90 Bool("enable_audio_handler", cfg.EnableAudioHandler).
91+ Float64("application_scale", eng.applicationScale).
89 Msg("cef: configured engine")92 Msg("cef: configured engine")
90 93
91 if err := initializeCEF(eng, settings, logger); err != nil {94 if err := initializeCEF(eng, settings, logger); err != nil {
@@ -109,6 +112,13 @@ func NewEngine(
109 )112 )
110 }113 }
111 114
115+func normalizedApplicationScale(scale float64) float64 {
116+ if math.IsNaN(scale) || math.IsInf(scale, 0) || scale <= 0 {
117+ return 1
118+ }
119+ return scale
120+}
121+
112 func normalizedWindowlessFrameRate(frameRate int32) int32 {122 func normalizedWindowlessFrameRate(frameRate int32) int32 {
113 if frameRate > 0 {123 if frameRate > 0 {
114 return frameRate124 return frameRate
A internal/infrastructure/cef/engine_settings_test.go +28 -0
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+)
10+
11+func TestEngineUpdateSettingsUpdatesApplicationScaleForNewViews(t *testing.T) {
12+ e := &Engine{applicationScale: 1}
13+ cfg := &config.Config{DefaultUIScale: 1.25}
14+
15+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: cfg}); err != nil {
16+ t.Fatalf("UpdateSettings returned error: %v", err)
17+ }
18+ if e.applicationScale != 1.25 {
19+ t.Fatalf("applicationScale=%v, want 1.25", e.applicationScale)
20+ }
21+}
22+
23+func TestEngineUpdateSettingsRejectsUnexpectedConfig(t *testing.T) {
24+ e := &Engine{}
25+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: "wrong"}); err == nil {
26+ t.Fatal("expected error for unexpected config type")
27+ }
28+}
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+)
10+
11+func TestEngineUpdateSettingsUpdatesApplicationScaleForNewViews(t *testing.T) {
12+ e := &Engine{applicationScale: 1}
13+ cfg := &config.Config{DefaultUIScale: 1.25}
14+
15+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: cfg}); err != nil {
16+ t.Fatalf("UpdateSettings returned error: %v", err)
17+ }
18+ if e.applicationScale != 1.25 {
19+ t.Fatalf("applicationScale=%v, want 1.25", e.applicationScale)
20+ }
21+}
22+
23+func TestEngineUpdateSettingsRejectsUnexpectedConfig(t *testing.T) {
24+ e := &Engine{}
25+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: "wrong"}); err == nil {
26+ t.Fatal("expected error for unexpected config type")
27+ }
28+}
M internal/infrastructure/cef/factory.go +2 -2
@@ -106,7 +106,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
106106
107107 func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
108108 id := port.WebViewID(f.nextID.Add(1))
109- viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan)
109+ viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan, f.engine.applicationScale)
110110 if viewBridge == nil {
111111 return nil, fmt.Errorf("create cef2gtk view")
112112 }
@@ -125,7 +125,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
125125 }
126126 wv.runOnGTKSync(func() {
127127 nativeWidget := viewBridge.Widget()
128- popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan)
128+ popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan, f.engine.applicationScale)
129129 wv.nativeWidget = nativeWidget
130130 if popupSurface != nil {
131131 wv.popupSurface = popupSurface
@@ -106,7 +106,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
106 106
107 func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {107 func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
108 id := port.WebViewID(f.nextID.Add(1))108 id := port.WebViewID(f.nextID.Add(1))
109- viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan)109+ viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan, f.engine.applicationScale)
110 if viewBridge == nil {110 if viewBridge == nil {
111 return nil, fmt.Errorf("create cef2gtk view")111 return nil, fmt.Errorf("create cef2gtk view")
112 }112 }
@@ -125,7 +125,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
125 }125 }
126 wv.runOnGTKSync(func() {126 wv.runOnGTKSync(func() {
127 nativeWidget := viewBridge.Widget()127 nativeWidget := viewBridge.Widget()
128- popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan)128+ popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan, f.engine.applicationScale)
129 wv.nativeWidget = nativeWidget129 wv.nativeWidget = nativeWidget
130 if popupSurface != nil {130 if popupSurface != nil {
131 wv.popupSurface = popupSurface131 wv.popupSurface = popupSurface
M internal/infrastructure/cef/popup_surface.go +2 -2
@@ -23,7 +23,7 @@ type popupBridgeSurface struct {
2323
2424 // newPopupBridgeSurface must be called on the GTK thread because it creates
2525 // and packs GTK widgets around the primary CEF widget.
26-func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef2gtk.RenderStackPlan) *popupBridgeSurface {
26+func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef2gtk.RenderStackPlan, applicationScale float64) *popupBridgeSurface {
2727 mainContext := glib.MainContextDefault()
2828 if mainContext == nil || !mainContext.IsOwner() {
2929 logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")
@@ -32,7 +32,7 @@ func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef
3232 if mainWidget == nil {
3333 return nil
3434 }
35- bridge := NewCef2gtkAdapter(plan)
35+ bridge := NewCef2gtkAdapter(plan, applicationScale)
3636 if bridge == nil {
3737 return nil
3838 }
@@ -23,7 +23,7 @@ type popupBridgeSurface struct {
23 23
24 // newPopupBridgeSurface must be called on the GTK thread because it creates24 // newPopupBridgeSurface must be called on the GTK thread because it creates
25 // and packs GTK widgets around the primary CEF widget.25 // and packs GTK widgets around the primary CEF widget.
26-func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef2gtk.RenderStackPlan) *popupBridgeSurface {26+func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef2gtk.RenderStackPlan, applicationScale float64) *popupBridgeSurface {
27 mainContext := glib.MainContextDefault()27 mainContext := glib.MainContextDefault()
28 if mainContext == nil || !mainContext.IsOwner() {28 if mainContext == nil || !mainContext.IsOwner() {
29 logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")29 logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")
@@ -32,7 +32,7 @@ func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef
32 if mainWidget == nil {32 if mainWidget == nil {
33 return nil33 return nil
34 }34 }
35- bridge := NewCef2gtkAdapter(plan)35+ bridge := NewCef2gtkAdapter(plan, applicationScale)
36 if bridge == nil {36 if bridge == nil {
37 return nil37 return nil
38 }38 }
M internal/infrastructure/cef/runtime_config.go +1 -0
@@ -24,6 +24,7 @@ type RuntimeConfig struct {
2424 WindowlessFrameRateMax int32
2525 EnableAudioHandler bool
2626 TraceHandlers bool
27+ ApplicationScale float64
2728 }
2829
2930 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
@@ -24,6 +24,7 @@ type RuntimeConfig struct {
24 WindowlessFrameRateMax int3224 WindowlessFrameRateMax int32
25 EnableAudioHandler bool25 EnableAudioHandler bool
26 TraceHandlers bool26 TraceHandlers bool
27+ ApplicationScale float64
27 }28 }
28 29
29 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error30 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
M internal/infrastructure/cef/scale_probe.go +65 -0
@@ -1,5 +1,70 @@
11 package cef
22
3+import (
4+ "math"
5+ "strings"
6+)
7+
8+type cefScaleProbeMetrics struct {
9+ SurfaceWidth int32
10+ SurfaceHeight int32
11+ SurfaceScale float64
12+ OSRBackingScale float64
13+ UserZoom float64
14+ InternalCEFFactor float64
15+}
16+
17+func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool {
18+ url := strings.ToLower(strings.TrimSpace(frameURL))
19+ if url == "" || url == "about:blank" {
20+ return false
21+ }
22+ if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
23+ return httpStatusCode >= 200 && httpStatusCode < 400
24+ }
25+ return httpStatusCode >= 0
26+}
27+
28+func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
29+ m := cefScaleProbeMetrics{
30+ SurfaceWidth: 1,
31+ SurfaceHeight: 1,
32+ SurfaceScale: 1,
33+ OSRBackingScale: 1,
34+ UserZoom: 1,
35+ InternalCEFFactor: 1,
36+ }
37+ if wv == nil {
38+ return m
39+ }
40+ m.UserZoom = normalizeScale(wv.GetZoomLevel())
41+ m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
42+ m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale
43+ if wv.viewBridge != nil {
44+ m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
45+ m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
46+ }
47+ return m
48+}
49+
50+func (m cefScaleProbeMetrics) logFields() map[string]interface{} {
51+ return map[string]interface{}{
52+ "surface_width": m.SurfaceWidth,
53+ "surface_height": m.SurfaceHeight,
54+ "surface_scale": roundedScale(m.SurfaceScale),
55+ "osr_backing_scale": roundedScale(m.OSRBackingScale),
56+ "user_zoom": roundedScale(m.UserZoom),
57+ "internal_cef_factor": roundedScale(m.InternalCEFFactor),
58+ }
59+}
60+
61+func roundedScale(v float64) float64 {
62+ if math.IsNaN(v) || math.IsInf(v, 0) {
63+ return 1
64+ }
65+ return math.Round(v*1_000_000) / 1_000_000
66+}
67+
368 const cefScaleProbeScript = `(function(){
469 try {
570 var vv = window.visualViewport;
@@ -1,5 +1,70 @@
1 package cef1 package cef
2 2
3+import (
4+ "math"
5+ "strings"
6+)
7+
8+type cefScaleProbeMetrics struct {
9+ SurfaceWidth int32
10+ SurfaceHeight int32
11+ SurfaceScale float64
12+ OSRBackingScale float64
13+ UserZoom float64
14+ InternalCEFFactor float64
15+}
16+
17+func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool {
18+ url := strings.ToLower(strings.TrimSpace(frameURL))
19+ if url == "" || url == "about:blank" {
20+ return false
21+ }
22+ if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
23+ return httpStatusCode >= 200 && httpStatusCode < 400
24+ }
25+ return httpStatusCode >= 0
26+}
27+
28+func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
29+ m := cefScaleProbeMetrics{
30+ SurfaceWidth: 1,
31+ SurfaceHeight: 1,
32+ SurfaceScale: 1,
33+ OSRBackingScale: 1,
34+ UserZoom: 1,
35+ InternalCEFFactor: 1,
36+ }
37+ if wv == nil {
38+ return m
39+ }
40+ m.UserZoom = normalizeScale(wv.GetZoomLevel())
41+ m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
42+ m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale
43+ if wv.viewBridge != nil {
44+ m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
45+ m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
46+ }
47+ return m
48+}
49+
50+func (m cefScaleProbeMetrics) logFields() map[string]interface{} {
51+ return map[string]interface{}{
52+ "surface_width": m.SurfaceWidth,
53+ "surface_height": m.SurfaceHeight,
54+ "surface_scale": roundedScale(m.SurfaceScale),
55+ "osr_backing_scale": roundedScale(m.OSRBackingScale),
56+ "user_zoom": roundedScale(m.UserZoom),
57+ "internal_cef_factor": roundedScale(m.InternalCEFFactor),
58+ }
59+}
60+
61+func roundedScale(v float64) float64 {
62+ if math.IsNaN(v) || math.IsInf(v, 0) {
63+ return 1
64+ }
65+ return math.Round(v*1_000_000) / 1_000_000
66+}
67+
3 const cefScaleProbeScript = `(function(){68 const cefScaleProbeScript = `(function(){
4 try {69 try {
5 var vv = window.visualViewport;70 var vv = window.visualViewport;
A internal/infrastructure/cef/scale_probe_test.go +49 -0
@@ -0,0 +1,49 @@
1+package cef
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestCEFScaleProbeScriptCapturesViewportMetrics(t *testing.T) {
9+ for _, want := range []string{
10+ "window.devicePixelRatio",
11+ "window.innerWidth",
12+ "document.documentElement.clientWidth",
13+ "window.visualViewport",
14+ "width:100px;height:100px",
15+ "[SCALE-PROBE]",
16+ } {
17+ if !strings.Contains(cefScaleProbeScript, want) {
18+ t.Fatalf("cefScaleProbeScript missing %q", want)
19+ }
20+ }
21+}
22+
23+func TestShouldRunCEFScaleProbeSkipsIntermediateHTTPStatusZero(t *testing.T) {
24+ if shouldRunCEFScaleProbe("https://example.com", 0) {
25+ t.Fatal("probe should skip intermediate HTTP status 0 for http URLs")
26+ }
27+ if !shouldRunCEFScaleProbe("https://example.com", 200) {
28+ t.Fatal("probe should run for successful HTTP load")
29+ }
30+ if shouldRunCEFScaleProbe("about:blank", 200) {
31+ t.Fatal("probe should skip about:blank")
32+ }
33+ if !shouldRunCEFScaleProbe("file:///tmp/probe.html", 0) {
34+ t.Fatal("probe should allow non-HTTP status 0 loads")
35+ }
36+}
37+
38+func TestCEFScaleProbeSnapshotNormalizesMissingBridge(t *testing.T) {
39+ s := cefScaleProbeSnapshot(nil)
40+ if s.SurfaceWidth != 1 || s.SurfaceHeight != 1 {
41+ t.Fatalf("surface size = %dx%d, want 1x1", s.SurfaceWidth, s.SurfaceHeight)
42+ }
43+ if s.SurfaceScale != 1 || s.OSRBackingScale != 1 {
44+ t.Fatalf("scales = surface %v backing %v, want 1/1", s.SurfaceScale, s.OSRBackingScale)
45+ }
46+ if s.UserZoom != 1 || s.InternalCEFFactor != 1 {
47+ t.Fatalf("zoom = user %v internal %v, want 1/1", s.UserZoom, s.InternalCEFFactor)
48+ }
49+}
@@ -0,0 +1,49 @@
1+package cef
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestCEFScaleProbeScriptCapturesViewportMetrics(t *testing.T) {
9+ for _, want := range []string{
10+ "window.devicePixelRatio",
11+ "window.innerWidth",
12+ "document.documentElement.clientWidth",
13+ "window.visualViewport",
14+ "width:100px;height:100px",
15+ "[SCALE-PROBE]",
16+ } {
17+ if !strings.Contains(cefScaleProbeScript, want) {
18+ t.Fatalf("cefScaleProbeScript missing %q", want)
19+ }
20+ }
21+}
22+
23+func TestShouldRunCEFScaleProbeSkipsIntermediateHTTPStatusZero(t *testing.T) {
24+ if shouldRunCEFScaleProbe("https://example.com", 0) {
25+ t.Fatal("probe should skip intermediate HTTP status 0 for http URLs")
26+ }
27+ if !shouldRunCEFScaleProbe("https://example.com", 200) {
28+ t.Fatal("probe should run for successful HTTP load")
29+ }
30+ if shouldRunCEFScaleProbe("about:blank", 200) {
31+ t.Fatal("probe should skip about:blank")
32+ }
33+ if !shouldRunCEFScaleProbe("file:///tmp/probe.html", 0) {
34+ t.Fatal("probe should allow non-HTTP status 0 loads")
35+ }
36+}
37+
38+func TestCEFScaleProbeSnapshotNormalizesMissingBridge(t *testing.T) {
39+ s := cefScaleProbeSnapshot(nil)
40+ if s.SurfaceWidth != 1 || s.SurfaceHeight != 1 {
41+ t.Fatalf("surface size = %dx%d, want 1x1", s.SurfaceWidth, s.SurfaceHeight)
42+ }
43+ if s.SurfaceScale != 1 || s.OSRBackingScale != 1 {
44+ t.Fatalf("scales = surface %v backing %v, want 1/1", s.SurfaceScale, s.OSRBackingScale)
45+ }
46+ if s.UserZoom != 1 || s.InternalCEFFactor != 1 {
47+ t.Fatalf("zoom = user %v internal %v, want 1/1", s.UserZoom, s.InternalCEFFactor)
48+ }
49+}
A internal/infrastructure/cef/ui_scale_test.go +28 -0
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestNormalizedApplicationScale(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ in float64
12+ want float64
13+ }{
14+ {name: "default zero", in: 0, want: 1},
15+ {name: "negative", in: -1, want: 1},
16+ {name: "nan", in: math.NaN(), want: 1},
17+ {name: "inf", in: math.Inf(1), want: 1},
18+ {name: "valid", in: 1.2, want: 1.2},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ if got := normalizedApplicationScale(tt.in); got != tt.want {
24+ t.Fatalf("normalizedApplicationScale(%v) = %v, want %v", tt.in, got, tt.want)
25+ }
26+ })
27+ }
28+}
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "math"
5+ "testing"
6+)
7+
8+func TestNormalizedApplicationScale(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ in float64
12+ want float64
13+ }{
14+ {name: "default zero", in: 0, want: 1},
15+ {name: "negative", in: -1, want: 1},
16+ {name: "nan", in: math.NaN(), want: 1},
17+ {name: "inf", in: math.Inf(1), want: 1},
18+ {name: "valid", in: 1.2, want: 1.2},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ if got := normalizedApplicationScale(tt.in); got != tt.want {
24+ t.Fatalf("normalizedApplicationScale(%v) = %v, want %v", tt.in, got, tt.want)
25+ }
26+ })
27+ }
28+}
M internal/infrastructure/cef/webview.go +3 -3
@@ -459,11 +459,11 @@ func cefZoomFromFactor(factor float64) float64 {
459459 }
460460
461461 func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {
462- return cefZoomFromFactor(pageZoom / normalizeScale(backingScale))
462+ return cefZoomFromFactor(pageZoom * normalizeScale(backingScale))
463463 }
464464
465465 func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
466- return factorFromCEFZoom(level) * normalizeScale(backingScale)
466+ return factorFromCEFZoom(level) / normalizeScale(backingScale)
467467 }
468468
469469 func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) {
@@ -1765,7 +1765,7 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
17651765 Float64("factor", factor).
17661766 Float64("cef_level", cefLevel).
17671767 Float64("osr_backing_scale", backingScale).
1768- Msg("cef: reapplied zoom for OSR backing scale")
1768+ Msg("cef: reapplied zoom after OSR backing scale change")
17691769 }
17701770
17711771 func (wv *WebView) scheduleStartBeginFrameLoop() {
@@ -459,11 +459,11 @@ func cefZoomFromFactor(factor float64) float64 {
459 }459 }
460 460
461 func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {461 func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 {
462- return cefZoomFromFactor(pageZoom / normalizeScale(backingScale))462+ return cefZoomFromFactor(pageZoom * normalizeScale(backingScale))
463 }463 }
464 464
465 func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {465 func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 {
466- return factorFromCEFZoom(level) * normalizeScale(backingScale)466+ return factorFromCEFZoom(level) / normalizeScale(backingScale)
467 }467 }
468 468
469 func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) {469 func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) {
@@ -1765,7 +1765,7 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
1765 Float64("factor", factor).1765 Float64("factor", factor).
1766 Float64("cef_level", cefLevel).1766 Float64("cef_level", cefLevel).
1767 Float64("osr_backing_scale", backingScale).1767 Float64("osr_backing_scale", backingScale).
1768- Msg("cef: reapplied zoom for OSR backing scale")1768+ Msg("cef: reapplied zoom after OSR backing scale change")
1769 }1769 }
1770 1770
1771 func (wv *WebView) scheduleStartBeginFrameLoop() {1771 func (wv *WebView) scheduleStartBeginFrameLoop() {
M internal/infrastructure/cef/webview_handlers.go +7 -2
@@ -400,11 +400,16 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
400400 // Inject scripts and styles after page load.
401401 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
402402 // and JavaScript injection requires the main thread.
403- if cefScaleProbeEnabled() && httpStatusCode >= 0 && !strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
403+ if cefScaleProbeEnabled() && shouldRunCEFScaleProbe(frameURL, httpStatusCode) {
404404 // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.
405405 // Re-read the current main frame through WebView.RunJavaScript instead.
406406 h.wv.runOnGTK(func() {
407- logging.FromContext(h.wv.ctx).Debug().Msg("cef: injecting scale probe")
407+ snapshot := cefScaleProbeSnapshot(h.wv)
408+ event := logging.FromContext(h.wv.ctx).Debug()
409+ for key, value := range snapshot.logFields() {
410+ event = event.Interface(key, value)
411+ }
412+ event.Msg("cef: injecting scale probe")
408413 h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)
409414 })
410415 }
@@ -400,11 +400,16 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
400 // Inject scripts and styles after page load.400 // Inject scripts and styles after page load.
401 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,401 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
402 // and JavaScript injection requires the main thread.402 // and JavaScript injection requires the main thread.
403- if cefScaleProbeEnabled() && httpStatusCode >= 0 && !strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {403+ if cefScaleProbeEnabled() && shouldRunCEFScaleProbe(frameURL, httpStatusCode) {
404 // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.404 // Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.
405 // Re-read the current main frame through WebView.RunJavaScript instead.405 // Re-read the current main frame through WebView.RunJavaScript instead.
406 h.wv.runOnGTK(func() {406 h.wv.runOnGTK(func() {
407- logging.FromContext(h.wv.ctx).Debug().Msg("cef: injecting scale probe")407+ snapshot := cefScaleProbeSnapshot(h.wv)
408+ event := logging.FromContext(h.wv.ctx).Debug()
409+ for key, value := range snapshot.logFields() {
410+ event = event.Interface(key, value)
411+ }
412+ event.Msg("cef: injecting scale probe")
408 h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)413 h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)
409 })414 })
410 }415 }
M internal/infrastructure/cef/webview_zoom_test.go +3 -3
@@ -18,12 +18,12 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
1818 }
1919 }
2020
21-func TestZoomConversionsCompensateOSRBackingScale(t *testing.T) {
22- for _, backing := range []float64{1.0, 1.25, 1.5, 2.0} {
21+func TestZoomConversionsCompensateDeviceSizedOSRBackingInternally(t *testing.T) {
22+ for _, backing := range []float64{1.0, 1.25, 1.44, 2.0} {
2323 for _, pageZoom := range []float64{1.0, 1.75} {
2424 t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) {
2525 level := cefZoomFromPageAndBackingFactor(pageZoom, backing)
26- assert.InDelta(t, pageZoom/backing, factorFromCEFZoom(level), 1e-9)
26+ assert.InDelta(t, pageZoom*backing, factorFromCEFZoom(level), 1e-9)
2727 assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9)
2828 })
2929 }
@@ -18,12 +18,12 @@ func TestZoomConversionsRoundTrip(t *testing.T) {
18 }18 }
19 }19 }
20 20
21-func TestZoomConversionsCompensateOSRBackingScale(t *testing.T) {21+func TestZoomConversionsCompensateDeviceSizedOSRBackingInternally(t *testing.T) {
22- for _, backing := range []float64{1.0, 1.25, 1.5, 2.0} {22+ for _, backing := range []float64{1.0, 1.25, 1.44, 2.0} {
23 for _, pageZoom := range []float64{1.0, 1.75} {23 for _, pageZoom := range []float64{1.0, 1.75} {
24 t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) {24 t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) {
25 level := cefZoomFromPageAndBackingFactor(pageZoom, backing)25 level := cefZoomFromPageAndBackingFactor(pageZoom, backing)
26- assert.InDelta(t, pageZoom/backing, factorFromCEFZoom(level), 1e-9)26+ assert.InDelta(t, pageZoom*backing, factorFromCEFZoom(level), 1e-9)
27 assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9)27 assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9)
28 })28 })
29 }29 }

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