detached 3c51b4f

Merge pull request #267 from bnema/fix/cef-system-scale-zoom

fix(cef): respect effective HiDPI scale for zoom
commit
3c51b4ff2e6b474d000fb35704956b0d0eb2bd2c
author
bnema · · 2026-05-27 20:09 UTC
committer
GitHub · · 2026-05-27 20:09 UTC
merge of
21b3150 + c2c7597 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +292 -39
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/application/port/engine.go +13 -13
@@ -142,22 +142,22 @@ type Engine interface {
142142 SetHandlerContext(ctx context.Context)
143143 }
144144
145+// EngineSettingsPayload is the engine-facing boundary view of runtime config.
146+// Add fields here only when an engine needs to react to them at runtime.
147+type EngineSettingsPayload struct {
148+ DefaultUIScale float64
149+}
150+
145151 // EngineSettingsUpdate carries a runtime config change to the engine.
146152 //
147-// The Raw field holds the implementation-specific config object; each engine
148-// implementation type-asserts it to its concrete config type (e.g. the WebKit
149-// engine asserts to *infrastructure/config.Config).
150-//
151-// This is the only intentional use of `any` in the Engine port. A fully typed
152-// approach would require either moving the entire config schema into the domain
153-// layer (a large refactor) or defining a SettingsProvider interface — which
154-// would itself reduce to `any` because callers would embed engine-specific data
155-// behind an empty interface. Until the config schema is promoted to domain,
156-// the type assertion in the engine implementation is the least-invasive option.
153+// Settings exposes the typed boundary payload that engine adapters should prefer.
154+// Raw remains for legacy adapters that still consume implementation-specific
155+// config until their settings managers are narrowed to boundary fields.
157156 type EngineSettingsUpdate struct {
158- // Raw holds the implementation-specific config.
159- // WebKit engine expects *infrastructure/config.Config.
160- Raw any //nolint:iface // intentional: see type comment above
157+ Settings EngineSettingsPayload
158+ // Raw holds the implementation-specific config for legacy adapters.
159+ // WebKit engine currently expects *infrastructure/config.Config.
160+ Raw any //nolint:iface // intentional legacy bridge; prefer Settings for new code
161161 }
162162
163163 // WebUIMessageHandler handles a decoded message payload from the JS bridge.
@@ -142,22 +142,22 @@ type Engine interface {
142 SetHandlerContext(ctx context.Context)142 SetHandlerContext(ctx context.Context)
143 }143 }
144 144
145+// EngineSettingsPayload is the engine-facing boundary view of runtime config.
146+// Add fields here only when an engine needs to react to them at runtime.
147+type EngineSettingsPayload struct {
148+ DefaultUIScale float64
149+}
150+
145 // EngineSettingsUpdate carries a runtime config change to the engine.151 // EngineSettingsUpdate carries a runtime config change to the engine.
146 //152 //
147-// The Raw field holds the implementation-specific config object; each engine153+// Settings exposes the typed boundary payload that engine adapters should prefer.
148-// implementation type-asserts it to its concrete config type (e.g. the WebKit154+// Raw remains for legacy adapters that still consume implementation-specific
149-// engine asserts to *infrastructure/config.Config).155+// config until their settings managers are narrowed to boundary fields.
150-//
151-// This is the only intentional use of `any` in the Engine port. A fully typed
152-// approach would require either moving the entire config schema into the domain
153-// layer (a large refactor) or defining a SettingsProvider interface — which
154-// would itself reduce to `any` because callers would embed engine-specific data
155-// behind an empty interface. Until the config schema is promoted to domain,
156-// the type assertion in the engine implementation is the least-invasive option.
157 type EngineSettingsUpdate struct {156 type EngineSettingsUpdate struct {
158- // Raw holds the implementation-specific config.157+ Settings EngineSettingsPayload
159- // WebKit engine expects *infrastructure/config.Config.158+ // Raw holds the implementation-specific config for legacy adapters.
160- Raw any //nolint:iface // intentional: see type comment above159+ // WebKit engine currently expects *infrastructure/config.Config.
160+ Raw any //nolint:iface // intentional legacy bridge; prefer Settings for new code
161 }161 }
162 162
163 // WebUIMessageHandler handles a decoded message payload from the JS bridge.163 // WebUIMessageHandler handles a decoded message payload from the JS bridge.
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 +39 -8
@@ -25,12 +25,14 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
2525 // Engine implements port.Engine for the CEF browser backend.
2626 // It manages the CEF lifecycle and provides access to all engine subsystems.
2727 type Engine struct {
28- ctx context.Context
29- factory *WebViewFactory
30- pool *WebViewPool
31- profileLogDir string
32- runtimeCEFDir string
33- renderStackPlan cef2gtk.RenderStackPlan
28+ ctx context.Context
29+ factory *WebViewFactory
30+ pool *WebViewPool
31+ profileLogDir string
32+ runtimeCEFDir string
33+ renderStackPlan cef2gtk.RenderStackPlan
34+ applicationScaleMu sync.RWMutex
35+ applicationScale float64
3436
3537 messageRouter *MessageRouter
3638 schemeHandler *dumbSchemeHandler
@@ -346,11 +348,40 @@ func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) err
346348 return nil
347349 }
348350
349-// UpdateSettings applies runtime config changes to engine internals (Phase 1 stub).
350-func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) error {
351+// UpdateSettings applies runtime config changes to engine internals.
352+//
353+// CEF view scaling is fixed when each cef2gtk view is constructed. Changes to
354+// default_ui_scale therefore apply to newly-created CEF views in this engine;
355+// existing views continue using their construction-time scale until reload under
356+// a new engine lifetime. Browser zoom remains independently adjustable at
357+// runtime through SetZoomLevel.
358+func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsUpdate) error {
359+ newScale := normalizedApplicationScale(update.Settings.DefaultUIScale)
360+ e.applicationScaleMu.Lock()
361+ oldScale := e.applicationScale
362+ if oldScale != newScale {
363+ e.applicationScale = newScale
364+ }
365+ e.applicationScaleMu.Unlock()
366+
367+ if oldScale != newScale {
368+ logging.FromContext(ctx).Info().
369+ Float64("old_application_scale", oldScale).
370+ Float64("new_application_scale", newScale).
371+ Msg("cef: application scale update will apply to newly-created CEF views")
372+ }
351373 return nil
352374 }
353375
376+func (e *Engine) currentApplicationScale() float64 {
377+ if e == nil {
378+ return 1
379+ }
380+ e.applicationScaleMu.RLock()
381+ defer e.applicationScaleMu.RUnlock()
382+ return normalizedApplicationScale(e.applicationScale)
383+}
384+
354385 func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {
355386 if e == nil || text == "" {
356387 return
@@ -25,12 +25,14 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
25 // Engine implements port.Engine for the CEF browser backend.25 // Engine implements port.Engine for the CEF browser backend.
26 // It manages the CEF lifecycle and provides access to all engine subsystems.26 // It manages the CEF lifecycle and provides access to all engine subsystems.
27 type Engine struct {27 type Engine struct {
28- ctx context.Context28+ ctx context.Context
29- factory *WebViewFactory29+ factory *WebViewFactory
30- pool *WebViewPool30+ pool *WebViewPool
31- profileLogDir string31+ profileLogDir string
32- runtimeCEFDir string32+ runtimeCEFDir string
33- renderStackPlan cef2gtk.RenderStackPlan33+ renderStackPlan cef2gtk.RenderStackPlan
34+ applicationScaleMu sync.RWMutex
35+ applicationScale float64
34 36
35 messageRouter *MessageRouter37 messageRouter *MessageRouter
36 schemeHandler *dumbSchemeHandler38 schemeHandler *dumbSchemeHandler
@@ -346,11 +348,40 @@ func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) err
346 return nil348 return nil
347 }349 }
348 350
349-// UpdateSettings applies runtime config changes to engine internals (Phase 1 stub).351+// UpdateSettings applies runtime config changes to engine internals.
350-func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) error {352+//
353+// CEF view scaling is fixed when each cef2gtk view is constructed. Changes to
354+// default_ui_scale therefore apply to newly-created CEF views in this engine;
355+// existing views continue using their construction-time scale until reload under
356+// a new engine lifetime. Browser zoom remains independently adjustable at
357+// runtime through SetZoomLevel.
358+func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsUpdate) error {
359+ newScale := normalizedApplicationScale(update.Settings.DefaultUIScale)
360+ e.applicationScaleMu.Lock()
361+ oldScale := e.applicationScale
362+ if oldScale != newScale {
363+ e.applicationScale = newScale
364+ }
365+ e.applicationScaleMu.Unlock()
366+
367+ if oldScale != newScale {
368+ logging.FromContext(ctx).Info().
369+ Float64("old_application_scale", oldScale).
370+ Float64("new_application_scale", newScale).
371+ Msg("cef: application scale update will apply to newly-created CEF views")
372+ }
351 return nil373 return nil
352 }374 }
353 375
376+func (e *Engine) currentApplicationScale() float64 {
377+ if e == nil {
378+ return 1
379+ }
380+ e.applicationScaleMu.RLock()
381+ defer e.applicationScaleMu.RUnlock()
382+ return normalizedApplicationScale(e.applicationScale)
383+}
384+
354 func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {385 func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {
355 if e == nil || text == "" {386 if e == nil || text == "" {
356 return387 return
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.currentApplicationScale()).
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.currentApplicationScale()).
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 +52 -0
@@ -0,0 +1,52 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func TestEngineUpdateSettingsUpdatesApplicationScaleFromBoundaryPayload(t *testing.T) {
12+ e := &Engine{applicationScale: 1}
13+
14+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{
15+ Settings: port.EngineSettingsPayload{DefaultUIScale: 1.25},
16+ }); err != nil {
17+ t.Fatalf("UpdateSettings returned error: %v", err)
18+ }
19+ if got := e.currentApplicationScale(); got != 1.25 {
20+ t.Fatalf("applicationScale=%v, want 1.25", got)
21+ }
22+}
23+
24+func TestEngineUpdateSettingsIgnoresLegacyRawForApplicationScale(t *testing.T) {
25+ e := &Engine{applicationScale: 1}
26+
27+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: "wrong"}); err != nil {
28+ t.Fatalf("UpdateSettings returned error: %v", err)
29+ }
30+ if got := e.currentApplicationScale(); got != 1 {
31+ t.Fatalf("applicationScale=%v, want default 1", got)
32+ }
33+}
34+
35+func TestEngineApplicationScaleConcurrentAccess(_ *testing.T) {
36+ e := &Engine{applicationScale: 1}
37+ var wg sync.WaitGroup
38+ for i := 0; i < 16; i++ {
39+ wg.Add(2)
40+ go func() {
41+ defer wg.Done()
42+ _ = e.currentApplicationScale()
43+ }()
44+ go func(scale float64) {
45+ defer wg.Done()
46+ _ = e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{
47+ Settings: port.EngineSettingsPayload{DefaultUIScale: scale},
48+ })
49+ }(1 + float64(i)/10)
50+ }
51+ wg.Wait()
52+}
@@ -0,0 +1,52 @@
1+package cef
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+func TestEngineUpdateSettingsUpdatesApplicationScaleFromBoundaryPayload(t *testing.T) {
12+ e := &Engine{applicationScale: 1}
13+
14+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{
15+ Settings: port.EngineSettingsPayload{DefaultUIScale: 1.25},
16+ }); err != nil {
17+ t.Fatalf("UpdateSettings returned error: %v", err)
18+ }
19+ if got := e.currentApplicationScale(); got != 1.25 {
20+ t.Fatalf("applicationScale=%v, want 1.25", got)
21+ }
22+}
23+
24+func TestEngineUpdateSettingsIgnoresLegacyRawForApplicationScale(t *testing.T) {
25+ e := &Engine{applicationScale: 1}
26+
27+ if err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: "wrong"}); err != nil {
28+ t.Fatalf("UpdateSettings returned error: %v", err)
29+ }
30+ if got := e.currentApplicationScale(); got != 1 {
31+ t.Fatalf("applicationScale=%v, want default 1", got)
32+ }
33+}
34+
35+func TestEngineApplicationScaleConcurrentAccess(_ *testing.T) {
36+ e := &Engine{applicationScale: 1}
37+ var wg sync.WaitGroup
38+ for i := 0; i < 16; i++ {
39+ wg.Add(2)
40+ go func() {
41+ defer wg.Done()
42+ _ = e.currentApplicationScale()
43+ }()
44+ go func(scale float64) {
45+ defer wg.Done()
46+ _ = e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{
47+ Settings: port.EngineSettingsPayload{DefaultUIScale: scale},
48+ })
49+ }(1 + float64(i)/10)
50+ }
51+ wg.Wait()
52+}
M internal/infrastructure/cef/factory.go +3 -2
@@ -106,7 +106,8 @@ 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+ applicationScale := f.engine.currentApplicationScale()
110+ viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan, applicationScale)
110111 if viewBridge == nil {
111112 return nil, fmt.Errorf("create cef2gtk view")
112113 }
@@ -125,7 +126,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
125126 }
126127 wv.runOnGTKSync(func() {
127128 nativeWidget := viewBridge.Widget()
128- popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan)
129+ popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan, applicationScale)
129130 wv.nativeWidget = nativeWidget
130131 if popupSurface != nil {
131132 wv.popupSurface = popupSurface
@@ -106,7 +106,8 @@ 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+ applicationScale := f.engine.currentApplicationScale()
110+ viewBridge := NewCef2gtkAdapter(f.engine.renderStackPlan, applicationScale)
110 if viewBridge == nil {111 if viewBridge == nil {
111 return nil, fmt.Errorf("create cef2gtk view")112 return nil, fmt.Errorf("create cef2gtk view")
112 }113 }
@@ -125,7 +126,7 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
125 }126 }
126 wv.runOnGTKSync(func() {127 wv.runOnGTKSync(func() {
127 nativeWidget := viewBridge.Widget()128 nativeWidget := viewBridge.Widget()
128- popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan)129+ popupSurface := newPopupBridgeSurface(ctx, nativeWidget, f.engine.renderStackPlan, applicationScale)
129 wv.nativeWidget = nativeWidget130 wv.nativeWidget = nativeWidget
130 if popupSurface != nil {131 if popupSurface != nil {
131 wv.popupSurface = popupSurface132 wv.popupSurface = popupSurface
M internal/infrastructure/cef/popup_surface.go +7 -2
@@ -23,7 +23,12 @@ 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(
27+ ctx context.Context,
28+ mainWidget *gtk.Widget,
29+ plan cef2gtk.RenderStackPlan,
30+ applicationScale float64,
31+) *popupBridgeSurface {
2732 mainContext := glib.MainContextDefault()
2833 if mainContext == nil || !mainContext.IsOwner() {
2934 logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")
@@ -32,7 +37,7 @@ func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef
3237 if mainWidget == nil {
3338 return nil
3439 }
35- bridge := NewCef2gtkAdapter(plan)
40+ bridge := NewCef2gtkAdapter(plan, applicationScale)
3641 if bridge == nil {
3742 return nil
3843 }
@@ -23,7 +23,12 @@ 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(
27+ ctx context.Context,
28+ mainWidget *gtk.Widget,
29+ plan cef2gtk.RenderStackPlan,
30+ applicationScale float64,
31+) *popupBridgeSurface {
27 mainContext := glib.MainContextDefault()32 mainContext := glib.MainContextDefault()
28 if mainContext == nil || !mainContext.IsOwner() {33 if mainContext == nil || !mainContext.IsOwner() {
29 logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")34 logging.FromContext(popupSurfaceLogContext(ctx)).Error().Msg("cef: newPopupBridgeSurface called off GTK thread")
@@ -32,7 +37,7 @@ func newPopupBridgeSurface(ctx context.Context, mainWidget *gtk.Widget, plan cef
32 if mainWidget == nil {37 if mainWidget == nil {
33 return nil38 return nil
34 }39 }
35- bridge := NewCef2gtkAdapter(plan)40+ bridge := NewCef2gtkAdapter(plan, applicationScale)
36 if bridge == nil {41 if bridge == nil {
37 return nil42 return nil
38 }43 }
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 +67 -0
@@ -1,5 +1,72 @@
11 package cef
22
3+import (
4+ "math"
5+ "strings"
6+)
7+
8+const scaleProbeRoundFactor = 1_000_000
9+
10+type cefScaleProbeMetrics struct {
11+ SurfaceWidth int32
12+ SurfaceHeight int32
13+ SurfaceScale float64
14+ OSRBackingScale float64
15+ UserZoom float64
16+ InternalCEFFactor float64
17+}
18+
19+func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool {
20+ url := strings.ToLower(strings.TrimSpace(frameURL))
21+ if url == "" || url == "about:blank" {
22+ return false
23+ }
24+ if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
25+ return httpStatusCode >= 200 && httpStatusCode < 400
26+ }
27+ return httpStatusCode >= 0
28+}
29+
30+func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
31+ m := cefScaleProbeMetrics{
32+ SurfaceWidth: 1,
33+ SurfaceHeight: 1,
34+ SurfaceScale: 1,
35+ OSRBackingScale: 1,
36+ UserZoom: 1,
37+ InternalCEFFactor: 1,
38+ }
39+ if wv == nil {
40+ return m
41+ }
42+ m.UserZoom = normalizeScale(wv.GetZoomLevel())
43+ m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
44+ m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale
45+ if wv.viewBridge != nil {
46+ m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
47+ m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
48+ }
49+ return m
50+}
51+
52+func (m cefScaleProbeMetrics) logFields() map[string]interface{} {
53+ return map[string]interface{}{
54+ "surface_width": m.SurfaceWidth,
55+ "surface_height": m.SurfaceHeight,
56+ "surface_scale": roundedScale(m.SurfaceScale),
57+ "osr_backing_scale": roundedScale(m.OSRBackingScale),
58+ "user_zoom": roundedScale(m.UserZoom),
59+ "internal_cef_factor": roundedScale(m.InternalCEFFactor),
60+ }
61+}
62+
63+func roundedScale(v float64) float64 {
64+ if math.IsNaN(v) || math.IsInf(v, 0) {
65+ return 1
66+ }
67+ return math.Round(v*scaleProbeRoundFactor) / scaleProbeRoundFactor
68+}
69+
370 const cefScaleProbeScript = `(function(){
471 try {
572 var vv = window.visualViewport;
@@ -1,5 +1,72 @@
1 package cef1 package cef
2 2
3+import (
4+ "math"
5+ "strings"
6+)
7+
8+const scaleProbeRoundFactor = 1_000_000
9+
10+type cefScaleProbeMetrics struct {
11+ SurfaceWidth int32
12+ SurfaceHeight int32
13+ SurfaceScale float64
14+ OSRBackingScale float64
15+ UserZoom float64
16+ InternalCEFFactor float64
17+}
18+
19+func shouldRunCEFScaleProbe(frameURL string, httpStatusCode int32) bool {
20+ url := strings.ToLower(strings.TrimSpace(frameURL))
21+ if url == "" || url == "about:blank" {
22+ return false
23+ }
24+ if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
25+ return httpStatusCode >= 200 && httpStatusCode < 400
26+ }
27+ return httpStatusCode >= 0
28+}
29+
30+func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics {
31+ m := cefScaleProbeMetrics{
32+ SurfaceWidth: 1,
33+ SurfaceHeight: 1,
34+ SurfaceScale: 1,
35+ OSRBackingScale: 1,
36+ UserZoom: 1,
37+ InternalCEFFactor: 1,
38+ }
39+ if wv == nil {
40+ return m
41+ }
42+ m.UserZoom = normalizeScale(wv.GetZoomLevel())
43+ m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor())
44+ m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale
45+ if wv.viewBridge != nil {
46+ m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size()
47+ m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
48+ }
49+ return m
50+}
51+
52+func (m cefScaleProbeMetrics) logFields() map[string]interface{} {
53+ return map[string]interface{}{
54+ "surface_width": m.SurfaceWidth,
55+ "surface_height": m.SurfaceHeight,
56+ "surface_scale": roundedScale(m.SurfaceScale),
57+ "osr_backing_scale": roundedScale(m.OSRBackingScale),
58+ "user_zoom": roundedScale(m.UserZoom),
59+ "internal_cef_factor": roundedScale(m.InternalCEFFactor),
60+ }
61+}
62+
63+func roundedScale(v float64) float64 {
64+ if math.IsNaN(v) || math.IsInf(v, 0) {
65+ return 1
66+ }
67+ return math.Round(v*scaleProbeRoundFactor) / scaleProbeRoundFactor
68+}
69+
3 const cefScaleProbeScript = `(function(){70 const cefScaleProbeScript = `(function(){
4 try {71 try {
5 var vv = window.visualViewport;72 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 }
M internal/ui/app.go +4 -1
@@ -4827,7 +4827,10 @@ func (a *App) applyAppearanceConfig(ctx context.Context) {
48274827 cfg := a.deps.Config
48284828
48294829 if a.engine != nil {
4830- if err := a.engine.UpdateSettings(ctx, port.EngineSettingsUpdate{Raw: cfg}); err != nil {
4830+ if err := a.engine.UpdateSettings(ctx, port.EngineSettingsUpdate{
4831+ Settings: port.EngineSettingsPayload{DefaultUIScale: cfg.DefaultUIScale},
4832+ Raw: cfg,
4833+ }); err != nil {
48314834 log.Warn().Err(err).Msg("failed to apply engine settings update")
48324835 }
48334836 }
@@ -4827,7 +4827,10 @@ func (a *App) applyAppearanceConfig(ctx context.Context) {
4827 cfg := a.deps.Config4827 cfg := a.deps.Config
4828 4828
4829 if a.engine != nil {4829 if a.engine != nil {
4830- if err := a.engine.UpdateSettings(ctx, port.EngineSettingsUpdate{Raw: cfg}); err != nil {4830+ if err := a.engine.UpdateSettings(ctx, port.EngineSettingsUpdate{
4831+ Settings: port.EngineSettingsPayload{DefaultUIScale: cfg.DefaultUIScale},
4832+ Raw: cfg,
4833+ }); err != nil {
4831 log.Warn().Err(err).Msg("failed to apply engine settings update")4834 log.Warn().Err(err).Msg("failed to apply engine settings update")
4832 }4835 }
4833 }4836 }

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