detached 45b07b3

feat(cef): adapt osr frame rate to monitor

commit
45b07b3ef353afecb6b97ff9c9204c46bf48ade4
author
brice · · 2026-05-20 05:55 UTC
parent
f52de71
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +355 -54
M README.md +1 -1
@@ -101,7 +101,7 @@ CEF_DIR=/custom/path dumber browse
101101
102102 Then set `engine.type = "cef"` in your config.
103103
104-CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`.
104+CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. CEF OSR frame rate adapts to the active Wayland monitor refresh rate by default, capped by `engine.cef.windowless_frame_rate_max = 240`; set `engine.cef.windowless_frame_rate` to a positive value for an explicit fixed cap.
105105
106106 ## Keyboard-Driven
107107
@@ -101,7 +101,7 @@ CEF_DIR=/custom/path dumber browse
101 101
102 Then set `engine.type = "cef"` in your config.102 Then set `engine.type = "cef"` in your config.
103 103
104-CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`.104+CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. For driver compatibility, switch to the EGL/OpenGL stack with `engine.cef.render_stack = "egl"`; the default is `"vulkan"`. CEF OSR frame rate adapts to the active Wayland monitor refresh rate by default, capped by `engine.cef.windowless_frame_rate_max = 240`; set `engine.cef.windowless_frame_rate` to a positive value for an explicit fixed cap.
105 105
106 ## Keyboard-Driven106 ## Keyboard-Driven
107 107
M docs/config/index.md +3 -0
@@ -190,6 +190,9 @@ border = "#363636"
190190 | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |
191191 | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |
192192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |
193+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Adapt CEF OSR FPS to the active Wayland monitor when `windowless_frame_rate = 0` |
194+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 lets adaptive mode choose |
195+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 | Hard cap for adaptive CEF OSR FPS |
193196 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |
194197 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |
195198
@@ -190,6 +190,9 @@ border = "#363636"
190 | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |190 | `rendering.sample_memory` | bool | `false` | - | Enable WebKit memory sampling (`WEBKIT_SAMPLE_MEMORY`) |
191 | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |191 | `rendering.debug_frames` | bool | `false` | - | Enable GTK frame timing debug (`GDK_DEBUG=frames`) |
192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |192 | `engine.cef.render_stack` | string | `"vulkan"` | `vulkan`, `egl` | CEF GPU render stack |
193+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | - | Adapt CEF OSR FPS to the active Wayland monitor when `windowless_frame_rate = 0` |
194+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 | Explicit CEF OSR FPS cap; 0 lets adaptive mode choose |
195+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 | Hard cap for adaptive CEF OSR FPS |
193 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |196 | `default_ui_scale` | float | `1.0` | > 0 | GTK widget UI scale (1.0=100%, 2.0=200%) |
194 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |197 | `default_webpage_zoom` | float | `1.2` | > 0 | Default page zoom (1.0=100%, 1.2=120%) |
195 198
M docs/reference/configuration.md +3 -0
@@ -60,6 +60,9 @@
6060 | `rendering.sample_memory` | bool | `false` | |
6161 | `rendering.debug_frames` | bool | `false` | |
6262 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |
63+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
64+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
65+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 |
6366 | `default_ui_scale` | float | `1.0` | > 0 |
6467 | `default_webpage_zoom` | float | `1.2` | > 0 |
6568 | `workspace.new_pane_url` | string | `about:blank` | |
@@ -60,6 +60,9 @@
60 | `rendering.sample_memory` | bool | `false` | |60 | `rendering.sample_memory` | bool | `false` | |
61 | `rendering.debug_frames` | bool | `false` | |61 | `rendering.debug_frames` | bool | `false` | |
62 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |62 | `engine.cef.render_stack` | string | `vulkan` | `vulkan`, `egl` |
63+| `engine.cef.adaptive_windowless_frame_rate` | bool | `true` | |
64+| `engine.cef.windowless_frame_rate` | int32 | `0` | >= 0 |
65+| `engine.cef.windowless_frame_rate_max` | int32 | `240` | > 0 |
63 | `default_ui_scale` | float | `1.0` | > 0 |66 | `default_ui_scale` | float | `1.0` | > 0 |
64 | `default_webpage_zoom` | float | `1.2` | > 0 |67 | `default_webpage_zoom` | float | `1.2` | > 0 |
65 | `workspace.new_pane_url` | string | `about:blank` | |68 | `workspace.new_pane_url` | string | `about:blank` | |
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.3.0
10+ github.com/bnema/purego-cef2gtk v0.4.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.3.010+ github.com/bnema/purego-cef2gtk v0.4.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/bootstrap/engine.go +9 -7
@@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
7777 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
7878 }
7979 cefCfg := cef.RuntimeConfig{
80- CEFDir: cfg.Engine.CEF.CEFDir,
81- LogFile: cfg.Engine.CEF.LogFile,
82- LogSeverity: cfg.Engine.CEF.LogSeverity,
83- RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),
84- WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
85- EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
86- TraceHandlers: cfg.Engine.CEF.TraceHandlers,
80+ CEFDir: cfg.Engine.CEF.CEFDir,
81+ LogFile: cfg.Engine.CEF.LogFile,
82+ LogSeverity: cfg.Engine.CEF.LogSeverity,
83+ RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),
84+ AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(),
85+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
86+ WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),
87+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
88+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
8789 }
8890 deps := cef.EngineDependencies{
8991 RegisterHandlers: handlers.RegisterAll,
@@ -77,13 +77,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
77 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),77 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
78 }78 }
79 cefCfg := cef.RuntimeConfig{79 cefCfg := cef.RuntimeConfig{
80- CEFDir: cfg.Engine.CEF.CEFDir,80+ CEFDir: cfg.Engine.CEF.CEFDir,
81- LogFile: cfg.Engine.CEF.LogFile,81+ LogFile: cfg.Engine.CEF.LogFile,
82- LogSeverity: cfg.Engine.CEF.LogSeverity,82+ LogSeverity: cfg.Engine.CEF.LogSeverity,
83- RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),83+ RenderStack: string(cfg.Engine.CEF.CEFRenderStack()),
84- WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,84+ AdaptiveWindowlessFrameRate: cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate(),
85- EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,85+ WindowlessFrameRate: cfg.Engine.CEF.WindowlessFrameRate,
86- TraceHandlers: cfg.Engine.CEF.TraceHandlers,86+ WindowlessFrameRateMax: cfg.Engine.CEF.CEFWindowlessFrameRateMax(),
87+ EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler,
88+ TraceHandlers: cfg.Engine.CEF.TraceHandlers,
87 }89 }
88 deps := cef.EngineDependencies{90 deps := cef.EngineDependencies{
89 RegisterHandlers: handlers.RegisterAll,91 RegisterHandlers: handlers.RegisterAll,
A internal/infrastructure/cef/adaptive_frame_rate.go +133 -0
@@ -0,0 +1,133 @@
1+package cef
2+
3+import (
4+ "strings"
5+
6+ cef2gtk "github.com/bnema/purego-cef2gtk"
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const adaptiveFrameRatePollIntervalMS = 1000
14+
15+func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 {
16+ return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{
17+ DefaultFPS: fallbackFPS,
18+ MinFPS: fallbackFPS,
19+ MaxFPS: maxFPS,
20+ })
21+}
22+
23+func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() {
24+ if wv == nil || !wv.adaptiveWindowlessFrameRate {
25+ return
26+ }
27+ wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() })
28+}
29+
30+func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() {
31+ if wv == nil {
32+ return
33+ }
34+ wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() })
35+}
36+
37+func (wv *WebView) startAdaptiveFrameRatePolling() {
38+ if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
39+ return
40+ }
41+
42+ wv.mu.Lock()
43+ if wv.adaptiveFrameRatePollID != 0 || wv.host == nil {
44+ wv.mu.Unlock()
45+ return
46+ }
47+ cb := new(glib.SourceFunc)
48+ *cb = func(uintptr) bool {
49+ if wv.destroyed.Load() {
50+ return false
51+ }
52+ wv.applyAdaptiveWindowlessFrameRate()
53+ return true
54+ }
55+ wv.adaptiveFrameRatePoll = cb
56+ wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0)
57+ wv.mu.Unlock()
58+
59+ wv.applyAdaptiveWindowlessFrameRate()
60+}
61+
62+func (wv *WebView) stopAdaptiveFrameRatePolling() {
63+ if wv == nil {
64+ return
65+ }
66+ wv.mu.Lock()
67+ pollID := wv.adaptiveFrameRatePollID
68+ wv.adaptiveFrameRatePollID = 0
69+ wv.adaptiveFrameRatePoll = nil
70+ wv.mu.Unlock()
71+ if pollID != 0 {
72+ glib.SourceRemove(pollID)
73+ }
74+}
75+
76+func (wv *WebView) applyAdaptiveWindowlessFrameRate() {
77+ if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil {
78+ return
79+ }
80+ widget := wv.viewBridge.Widget()
81+ refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget)
82+ if !ok || !isWaylandDisplayName(displayName) {
83+ return
84+ }
85+ fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax)
86+
87+ wv.mu.Lock()
88+ host := wv.host
89+ if host == nil || fps == wv.lastAdaptiveFrameRate {
90+ wv.mu.Unlock()
91+ return
92+ }
93+ old := wv.lastAdaptiveFrameRate
94+ wv.lastAdaptiveFrameRate = fps
95+ wv.mu.Unlock()
96+
97+ host.SetWindowlessFrameRate(fps)
98+ if wv.ctx != nil {
99+ logging.FromContext(wv.ctx).Info().
100+ Int32("old_windowless_frame_rate", old).
101+ Int32("windowless_frame_rate", fps).
102+ Int("monitor_refresh_millihz", refreshRateMilliHz).
103+ Str("display", displayName).
104+ Msg("cef: adaptive windowless frame rate updated")
105+ }
106+}
107+
108+func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) {
109+ if widget == nil {
110+ return 0, "", false
111+ }
112+ native := widget.GetNative()
113+ if native == nil {
114+ return 0, "", false
115+ }
116+ surface := native.GetSurface()
117+ if surface == nil {
118+ return 0, "", false
119+ }
120+ display := surface.GetDisplay()
121+ if display == nil {
122+ return 0, "", false
123+ }
124+ monitor := display.GetMonitorAtSurface(surface)
125+ if monitor == nil {
126+ return 0, display.GetName(), false
127+ }
128+ return monitor.GetRefreshRate(), display.GetName(), true
129+}
130+
131+func isWaylandDisplayName(name string) bool {
132+ return strings.Contains(strings.ToLower(name), "wayland")
133+}
@@ -0,0 +1,133 @@
1+package cef
2+
3+import (
4+ "strings"
5+
6+ cef2gtk "github.com/bnema/purego-cef2gtk"
7+ "github.com/bnema/puregotk/v4/glib"
8+ "github.com/bnema/puregotk/v4/gtk"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const adaptiveFrameRatePollIntervalMS = 1000
14+
15+func adaptiveFrameRateForRefresh(refreshRateMilliHz int, fallbackFPS, maxFPS int32) int32 {
16+ return cef2gtk.WindowlessFrameRateForMonitorRefresh(refreshRateMilliHz, cef2gtk.RefreshRateOptions{
17+ DefaultFPS: fallbackFPS,
18+ MinFPS: fallbackFPS,
19+ MaxFPS: maxFPS,
20+ })
21+}
22+
23+func (wv *WebView) scheduleStartAdaptiveFrameRatePolling() {
24+ if wv == nil || !wv.adaptiveWindowlessFrameRate {
25+ return
26+ }
27+ wv.runOnGTK(func() { wv.startAdaptiveFrameRatePolling() })
28+}
29+
30+func (wv *WebView) scheduleStopAdaptiveFrameRatePolling() {
31+ if wv == nil {
32+ return
33+ }
34+ wv.runOnGTK(func() { wv.stopAdaptiveFrameRatePolling() })
35+}
36+
37+func (wv *WebView) startAdaptiveFrameRatePolling() {
38+ if wv == nil || wv.destroyed.Load() || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
39+ return
40+ }
41+
42+ wv.mu.Lock()
43+ if wv.adaptiveFrameRatePollID != 0 || wv.host == nil {
44+ wv.mu.Unlock()
45+ return
46+ }
47+ cb := new(glib.SourceFunc)
48+ *cb = func(uintptr) bool {
49+ if wv.destroyed.Load() {
50+ return false
51+ }
52+ wv.applyAdaptiveWindowlessFrameRate()
53+ return true
54+ }
55+ wv.adaptiveFrameRatePoll = cb
56+ wv.adaptiveFrameRatePollID = glib.TimeoutAdd(adaptiveFrameRatePollIntervalMS, cb, 0)
57+ wv.mu.Unlock()
58+
59+ wv.applyAdaptiveWindowlessFrameRate()
60+}
61+
62+func (wv *WebView) stopAdaptiveFrameRatePolling() {
63+ if wv == nil {
64+ return
65+ }
66+ wv.mu.Lock()
67+ pollID := wv.adaptiveFrameRatePollID
68+ wv.adaptiveFrameRatePollID = 0
69+ wv.adaptiveFrameRatePoll = nil
70+ wv.mu.Unlock()
71+ if pollID != 0 {
72+ glib.SourceRemove(pollID)
73+ }
74+}
75+
76+func (wv *WebView) applyAdaptiveWindowlessFrameRate() {
77+ if wv == nil || !wv.adaptiveWindowlessFrameRate || wv.viewBridge == nil {
78+ return
79+ }
80+ widget := wv.viewBridge.Widget()
81+ refreshRateMilliHz, displayName, ok := widgetMonitorRefreshRateMilliHz(widget)
82+ if !ok || !isWaylandDisplayName(displayName) {
83+ return
84+ }
85+ fps := adaptiveFrameRateForRefresh(refreshRateMilliHz, wv.windowlessFrameRate, wv.windowlessFrameRateMax)
86+
87+ wv.mu.Lock()
88+ host := wv.host
89+ if host == nil || fps == wv.lastAdaptiveFrameRate {
90+ wv.mu.Unlock()
91+ return
92+ }
93+ old := wv.lastAdaptiveFrameRate
94+ wv.lastAdaptiveFrameRate = fps
95+ wv.mu.Unlock()
96+
97+ host.SetWindowlessFrameRate(fps)
98+ if wv.ctx != nil {
99+ logging.FromContext(wv.ctx).Info().
100+ Int32("old_windowless_frame_rate", old).
101+ Int32("windowless_frame_rate", fps).
102+ Int("monitor_refresh_millihz", refreshRateMilliHz).
103+ Str("display", displayName).
104+ Msg("cef: adaptive windowless frame rate updated")
105+ }
106+}
107+
108+func widgetMonitorRefreshRateMilliHz(widget *gtk.Widget) (int, string, bool) {
109+ if widget == nil {
110+ return 0, "", false
111+ }
112+ native := widget.GetNative()
113+ if native == nil {
114+ return 0, "", false
115+ }
116+ surface := native.GetSurface()
117+ if surface == nil {
118+ return 0, "", false
119+ }
120+ display := surface.GetDisplay()
121+ if display == nil {
122+ return 0, "", false
123+ }
124+ monitor := display.GetMonitorAtSurface(surface)
125+ if monitor == nil {
126+ return 0, display.GetName(), false
127+ }
128+ return monitor.GetRefreshRate(), display.GetName(), true
129+}
130+
131+func isWaylandDisplayName(name string) bool {
132+ return strings.Contains(strings.ToLower(name), "wayland")
133+}
A internal/infrastructure/cef/adaptive_frame_rate_test.go +35 -0
@@ -0,0 +1,35 @@
1+package cef
2+
3+import "testing"
4+
5+func TestAdaptiveFrameRateForRefresh(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ refreshMilliHz int
9+ fallbackFPS int32
10+ maxFPS int32
11+ want int32
12+ }{
13+ {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60},
14+ {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144},
15+ {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS)
21+ if got != tt.want {
22+ t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want)
23+ }
24+ })
25+ }
26+}
27+
28+func TestIsWaylandDisplayName(t *testing.T) {
29+ if !isWaylandDisplayName("Wayland") || !isWaylandDisplayName("wayland-0") {
30+ t.Fatal("expected wayland display names to match")
31+ }
32+ if isWaylandDisplayName("x11") || isWaylandDisplayName("") {
33+ t.Fatal("unexpected non-wayland display match")
34+ }
35+}
@@ -0,0 +1,35 @@
1+package cef
2+
3+import "testing"
4+
5+func TestAdaptiveFrameRateForRefresh(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ refreshMilliHz int
9+ fallbackFPS int32
10+ maxFPS int32
11+ want int32
12+ }{
13+ {name: "fallback unknown", refreshMilliHz: 0, fallbackFPS: 60, maxFPS: 240, want: 60},
14+ {name: "uses monitor refresh", refreshMilliHz: 144000, fallbackFPS: 60, maxFPS: 240, want: 144},
15+ {name: "hard caps extreme monitor", refreshMilliHz: 500000, fallbackFPS: 60, maxFPS: 240, want: 240},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ got := adaptiveFrameRateForRefresh(tt.refreshMilliHz, tt.fallbackFPS, tt.maxFPS)
21+ if got != tt.want {
22+ t.Fatalf("adaptiveFrameRateForRefresh() = %d, want %d", got, tt.want)
23+ }
24+ })
25+ }
26+}
27+
28+func TestIsWaylandDisplayName(t *testing.T) {
29+ if !isWaylandDisplayName("Wayland") || !isWaylandDisplayName("wayland-0") {
30+ t.Fatal("expected wayland display names to match")
31+ }
32+ if isWaylandDisplayName("x11") || isWaylandDisplayName("") {
33+ t.Fatal("unexpected non-wayland display match")
34+ }
35+}
M internal/infrastructure/cef/cef2gtk_adapter.go +14 -0
@@ -209,6 +209,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
209209 return a.view.ConfigureProfiling(opts)
210210 }
211211
212+// RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in
213+// the bridge profiler when profiling is enabled. It is a no-op otherwise.
214+func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() {
215+ if a == nil || a.destroyed.Load() {
216+ return
217+ }
218+ a.viewMu.RLock()
219+ defer a.viewMu.RUnlock()
220+ if a.view == nil {
221+ return
222+ }
223+ a.view.RecordExternalBeginFrameSent()
224+}
225+
212226 // AttachInput attaches GTK event controllers to the view and forwards input
213227 // to the given CEF browser host. Must be called on the GTK main thread.
214228 //
@@ -209,6 +209,20 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
209 return a.view.ConfigureProfiling(opts)209 return a.view.ConfigureProfiling(opts)
210 }210 }
211 211
212+// RecordExternalBeginFrameSent records one externally-driven CEF BeginFrame in
213+// the bridge profiler when profiling is enabled. It is a no-op otherwise.
214+func (a *Cef2gtkAdapter) RecordExternalBeginFrameSent() {
215+ if a == nil || a.destroyed.Load() {
216+ return
217+ }
218+ a.viewMu.RLock()
219+ defer a.viewMu.RUnlock()
220+ if a.view == nil {
221+ return
222+ }
223+ a.view.RecordExternalBeginFrameSent()
224+}
225+
212 // AttachInput attaches GTK event controllers to the view and forwards input226 // AttachInput attaches GTK event controllers to the view and forwards input
213 // to the given CEF browser host. Must be called on the GTK main thread.227 // to the given CEF browser host. Must be called on the GTK main thread.
214 //228 //
M internal/infrastructure/cef/engine_init.go +8 -7
@@ -97,8 +97,12 @@ func NewEngine(
9797 return wireEngine(
9898 ctx,
9999 eng,
100- windowlessFrameRate,
101- audioFactory,
100+ webViewFactoryOptions{
101+ adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate,
102+ windowlessFrameRate: windowlessFrameRate,
103+ windowlessFrameRateMax: cfg.WindowlessFrameRateMax,
104+ audioOutputFactory: audioFactory,
105+ },
102106 logger,
103107 deps.CurrentConfigPayload,
104108 deps.DefaultConfigPayload,
@@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string {
215219 // wireEngine creates factory, pool, and scheme handler after CEF init.
216220 func wireEngine(
217221 ctx context.Context, eng *Engine,
218- windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
222+ factoryOptions webViewFactoryOptions, logger *zerolog.Logger,
219223 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
220224 ) (*Engine, error) {
221- eng.factory = newWebViewFactory(eng, webViewFactoryOptions{
222- windowlessFrameRate: windowlessFrameRate,
223- audioOutputFactory: audioFactory,
224- })
225+ eng.factory = newWebViewFactory(eng, factoryOptions)
225226 eng.pool = newWebViewPool(eng.factory)
226227 eng.contentInj = newContentInjector(eng, nil)
227228
@@ -97,8 +97,12 @@ func NewEngine(
97 return wireEngine(97 return wireEngine(
98 ctx,98 ctx,
99 eng,99 eng,
100- windowlessFrameRate,100+ webViewFactoryOptions{
101- audioFactory,101+ adaptiveWindowlessFrameRate: cfg.AdaptiveWindowlessFrameRate,
102+ windowlessFrameRate: windowlessFrameRate,
103+ windowlessFrameRateMax: cfg.WindowlessFrameRateMax,
104+ audioOutputFactory: audioFactory,
105+ },
102 logger,106 logger,
103 deps.CurrentConfigPayload,107 deps.CurrentConfigPayload,
104 deps.DefaultConfigPayload,108 deps.DefaultConfigPayload,
@@ -215,13 +219,10 @@ func parseLoadedLibCEFPath(maps string) string {
215 // wireEngine creates factory, pool, and scheme handler after CEF init.219 // wireEngine creates factory, pool, and scheme handler after CEF init.
216 func wireEngine(220 func wireEngine(
217 ctx context.Context, eng *Engine,221 ctx context.Context, eng *Engine,
218- windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,222+ factoryOptions webViewFactoryOptions, logger *zerolog.Logger,
219 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),223 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
220 ) (*Engine, error) {224 ) (*Engine, error) {
221- eng.factory = newWebViewFactory(eng, webViewFactoryOptions{225+ eng.factory = newWebViewFactory(eng, factoryOptions)
222- windowlessFrameRate: windowlessFrameRate,
223- audioOutputFactory: audioFactory,
224- })
225 eng.pool = newWebViewPool(eng.factory)226 eng.pool = newWebViewPool(eng.factory)
226 eng.contentInj = newContentInjector(eng, nil)227 eng.contentInj = newContentInjector(eng, nil)
227 228
M internal/infrastructure/cef/factory.go +29 -18
@@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
2020 // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created
2121 // CEF browser (via BrowserHostCreateBrowser).
2222 type WebViewFactory struct {
23- engine *Engine
24- nextID atomic.Uint64
25- windowlessFrameRate int32
26- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
27- audioOutputFactory port.AudioOutputFactory
23+ engine *Engine
24+ nextID atomic.Uint64
25+ adaptiveWindowlessFrameRate bool
26+ windowlessFrameRate int32
27+ windowlessFrameRateMax int32
28+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
29+ audioOutputFactory port.AudioOutputFactory
2830 }
2931
3032 type webViewFactoryOptions struct {
31- windowlessFrameRate int32
32- audioOutputFactory port.AudioOutputFactory
33+ adaptiveWindowlessFrameRate bool
34+ windowlessFrameRate int32
35+ windowlessFrameRateMax int32
36+ audioOutputFactory port.AudioOutputFactory
3337 }
3438
3539 type resizeNotifiableBrowserHost interface {
@@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto
4953 if opts.windowlessFrameRate < 1 {
5054 opts.windowlessFrameRate = 60
5155 }
56+ if opts.windowlessFrameRateMax < 1 {
57+ opts.windowlessFrameRateMax = 240
58+ }
5259 return &WebViewFactory{
53- engine: engine,
54- windowlessFrameRate: opts.windowlessFrameRate,
55- audioOutputFactory: opts.audioOutputFactory,
60+ engine: engine,
61+ adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate,
62+ windowlessFrameRate: opts.windowlessFrameRate,
63+ windowlessFrameRateMax: opts.windowlessFrameRateMax,
64+ audioOutputFactory: opts.audioOutputFactory,
5665 }
5766 }
5867
@@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
103112 }
104113
105114 wv := &WebView{
106- id: id,
107- ctx: ctx,
108- engine: f.engine,
109- factory: f,
110- viewBridge: viewBridge,
111- audioOutputFactory: f.audioOutputFactory,
112- windowlessFrameRate: f.windowlessFrameRate,
113- backgroundColor: f.bgColor.Load(),
115+ id: id,
116+ ctx: ctx,
117+ engine: f.engine,
118+ factory: f,
119+ viewBridge: viewBridge,
120+ audioOutputFactory: f.audioOutputFactory,
121+ adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
122+ windowlessFrameRate: f.windowlessFrameRate,
123+ windowlessFrameRateMax: f.windowlessFrameRateMax,
124+ backgroundColor: f.bgColor.Load(),
114125 }
115126
116127 handlers := &handlerSet{wv: wv}
@@ -20,16 +20,20 @@ var _ port.WebViewFactory = (*WebViewFactory)(nil)
20 // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created20 // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created
21 // CEF browser (via BrowserHostCreateBrowser).21 // CEF browser (via BrowserHostCreateBrowser).
22 type WebViewFactory struct {22 type WebViewFactory struct {
23- engine *Engine23+ engine *Engine
24- nextID atomic.Uint6424+ nextID atomic.Uint64
25- windowlessFrameRate int3225+ adaptiveWindowlessFrameRate bool
26- bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor26+ windowlessFrameRate int32
27- audioOutputFactory port.AudioOutputFactory27+ windowlessFrameRateMax int32
28+ bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor
29+ audioOutputFactory port.AudioOutputFactory
28 }30 }
29 31
30 type webViewFactoryOptions struct {32 type webViewFactoryOptions struct {
31- windowlessFrameRate int3233+ adaptiveWindowlessFrameRate bool
32- audioOutputFactory port.AudioOutputFactory34+ windowlessFrameRate int32
35+ windowlessFrameRateMax int32
36+ audioOutputFactory port.AudioOutputFactory
33 }37 }
34 38
35 type resizeNotifiableBrowserHost interface {39 type resizeNotifiableBrowserHost interface {
@@ -49,10 +53,15 @@ func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFacto
49 if opts.windowlessFrameRate < 1 {53 if opts.windowlessFrameRate < 1 {
50 opts.windowlessFrameRate = 6054 opts.windowlessFrameRate = 60
51 }55 }
56+ if opts.windowlessFrameRateMax < 1 {
57+ opts.windowlessFrameRateMax = 240
58+ }
52 return &WebViewFactory{59 return &WebViewFactory{
53- engine: engine,60+ engine: engine,
54- windowlessFrameRate: opts.windowlessFrameRate,61+ adaptiveWindowlessFrameRate: opts.adaptiveWindowlessFrameRate,
55- audioOutputFactory: opts.audioOutputFactory,62+ windowlessFrameRate: opts.windowlessFrameRate,
63+ windowlessFrameRateMax: opts.windowlessFrameRateMax,
64+ audioOutputFactory: opts.audioOutputFactory,
56 }65 }
57 }66 }
58 67
@@ -103,14 +112,16 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
103 }112 }
104 113
105 wv := &WebView{114 wv := &WebView{
106- id: id,115+ id: id,
107- ctx: ctx,116+ ctx: ctx,
108- engine: f.engine,117+ engine: f.engine,
109- factory: f,118+ factory: f,
110- viewBridge: viewBridge,119+ viewBridge: viewBridge,
111- audioOutputFactory: f.audioOutputFactory,120+ audioOutputFactory: f.audioOutputFactory,
112- windowlessFrameRate: f.windowlessFrameRate,121+ adaptiveWindowlessFrameRate: f.adaptiveWindowlessFrameRate,
113- backgroundColor: f.bgColor.Load(),122+ windowlessFrameRate: f.windowlessFrameRate,
123+ windowlessFrameRateMax: f.windowlessFrameRateMax,
124+ backgroundColor: f.bgColor.Load(),
114 }125 }
115 126
116 handlers := &handlerSet{wv: wv}127 handlers := &handlerSet{wv: wv}
M internal/infrastructure/cef/runtime_config.go +9 -7
@@ -15,13 +15,15 @@ var (
1515 )
1616
1717 type RuntimeConfig struct {
18- CEFDir string
19- LogFile string
20- LogSeverity int32
21- RenderStack string
22- WindowlessFrameRate int32
23- EnableAudioHandler bool
24- TraceHandlers bool
18+ CEFDir string
19+ LogFile string
20+ LogSeverity int32
21+ RenderStack string
22+ AdaptiveWindowlessFrameRate bool
23+ WindowlessFrameRate int32
24+ WindowlessFrameRateMax int32
25+ EnableAudioHandler bool
26+ TraceHandlers bool
2527 }
2628
2729 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
@@ -15,13 +15,15 @@ var (
15 )15 )
16 16
17 type RuntimeConfig struct {17 type RuntimeConfig struct {
18- CEFDir string18+ CEFDir string
19- LogFile string19+ LogFile string
20- LogSeverity int3220+ LogSeverity int32
21- RenderStack string21+ RenderStack string
22- WindowlessFrameRate int3222+ AdaptiveWindowlessFrameRate bool
23- EnableAudioHandler bool23+ WindowlessFrameRate int32
24- TraceHandlers bool24+ WindowlessFrameRateMax int32
25+ EnableAudioHandler bool
26+ TraceHandlers bool
25 }27 }
26 28
27 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error29 type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error
M internal/infrastructure/cef/webview.go +13 -0
@@ -103,6 +103,12 @@ type WebView struct {
103103 viewportRealizeSignalID uint
104104 viewportResizePulseSeq atomic.Uint64
105105
106+ adaptiveWindowlessFrameRate bool
107+ windowlessFrameRateMax int32
108+ adaptiveFrameRatePoll *glib.SourceFunc
109+ adaptiveFrameRatePollID uint
110+ lastAdaptiveFrameRate int32
111+
106112 // beginFrameTick drives CEF external BeginFrame requests while the GTK
107113 // widget is visible. Access is guarded by mu.
108114 beginFrameTick *gtk.TickCallback
@@ -1093,6 +1099,7 @@ func (wv *WebView) Destroy() {
10931099 wv.stopRenderStallWatchdog()
10941100 wv.cancelSelectionDebounce()
10951101 wv.closeAudioStream()
1102+ wv.scheduleStopAdaptiveFrameRatePolling()
10961103 wv.scheduleStopBeginFrameLoop()
10971104 wv.mu.RLock()
10981105 host := wv.host
@@ -1736,6 +1743,9 @@ func (wv *WebView) startBeginFrameLoop() {
17361743 return false
17371744 }
17381745 host.SendExternalBeginFrame()
1746+ if wv.viewBridge != nil {
1747+ wv.viewBridge.RecordExternalBeginFrameSent()
1748+ }
17391749 return true
17401750 }
17411751 wv.beginFrameTick = cb
@@ -1745,6 +1755,9 @@ func (wv *WebView) startBeginFrameLoop() {
17451755
17461756 if host != nil {
17471757 host.SendExternalBeginFrame()
1758+ if wv.viewBridge != nil {
1759+ wv.viewBridge.RecordExternalBeginFrameSent()
1760+ }
17481761 }
17491762 }
17501763
@@ -103,6 +103,12 @@ type WebView struct {
103 viewportRealizeSignalID uint103 viewportRealizeSignalID uint
104 viewportResizePulseSeq atomic.Uint64104 viewportResizePulseSeq atomic.Uint64
105 105
106+ adaptiveWindowlessFrameRate bool
107+ windowlessFrameRateMax int32
108+ adaptiveFrameRatePoll *glib.SourceFunc
109+ adaptiveFrameRatePollID uint
110+ lastAdaptiveFrameRate int32
111+
106 // beginFrameTick drives CEF external BeginFrame requests while the GTK112 // beginFrameTick drives CEF external BeginFrame requests while the GTK
107 // widget is visible. Access is guarded by mu.113 // widget is visible. Access is guarded by mu.
108 beginFrameTick *gtk.TickCallback114 beginFrameTick *gtk.TickCallback
@@ -1093,6 +1099,7 @@ func (wv *WebView) Destroy() {
1093 wv.stopRenderStallWatchdog()1099 wv.stopRenderStallWatchdog()
1094 wv.cancelSelectionDebounce()1100 wv.cancelSelectionDebounce()
1095 wv.closeAudioStream()1101 wv.closeAudioStream()
1102+ wv.scheduleStopAdaptiveFrameRatePolling()
1096 wv.scheduleStopBeginFrameLoop()1103 wv.scheduleStopBeginFrameLoop()
1097 wv.mu.RLock()1104 wv.mu.RLock()
1098 host := wv.host1105 host := wv.host
@@ -1736,6 +1743,9 @@ func (wv *WebView) startBeginFrameLoop() {
1736 return false1743 return false
1737 }1744 }
1738 host.SendExternalBeginFrame()1745 host.SendExternalBeginFrame()
1746+ if wv.viewBridge != nil {
1747+ wv.viewBridge.RecordExternalBeginFrameSent()
1748+ }
1739 return true1749 return true
1740 }1750 }
1741 wv.beginFrameTick = cb1751 wv.beginFrameTick = cb
@@ -1745,6 +1755,9 @@ func (wv *WebView) startBeginFrameLoop() {
1745 1755
1746 if host != nil {1756 if host != nil {
1747 host.SendExternalBeginFrame()1757 host.SendExternalBeginFrame()
1758+ if wv.viewBridge != nil {
1759+ wv.viewBridge.RecordExternalBeginFrameSent()
1760+ }
1748 }1761 }
1749 }1762 }
1750 1763
M internal/infrastructure/cef/webview_handlers.go +7 -0
@@ -667,6 +667,11 @@ func (h *handlerSet) attachAfterCreatedBrowser(
667667 // painting/caret updates until explicitly told the browser is shown.
668668 host.WasHidden(0)
669669 if h.wv != nil {
670+ if h.wv.ctx != nil {
671+ logging.FromContext(h.wv.ctx).Info().
672+ Int32("windowless_frame_rate_runtime", host.GetWindowlessFrameRate()).
673+ Msg("cef: browser runtime windowless frame rate")
674+ }
670675 h.wv.SyncViewport(h.wv.ctx, "after-created")
671676 }
672677 return state
@@ -709,6 +714,7 @@ func (h *handlerSet) finishAfterCreated(
709714 if state.nativePopupParent != nil && state.nativePopupID != 0 {
710715 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
711716 }
717+ h.wv.scheduleStartAdaptiveFrameRatePolling()
712718 h.wv.scheduleStartBeginFrameLoop()
713719 h.wv.fireReadyToShow()
714720 }
@@ -757,6 +763,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
757763 }
758764 })
759765 }
766+ h.wv.scheduleStopAdaptiveFrameRatePolling()
760767 h.wv.scheduleStopBeginFrameLoop()
761768
762769 h.wv.mu.RLock()
@@ -667,6 +667,11 @@ func (h *handlerSet) attachAfterCreatedBrowser(
667 // painting/caret updates until explicitly told the browser is shown.667 // painting/caret updates until explicitly told the browser is shown.
668 host.WasHidden(0)668 host.WasHidden(0)
669 if h.wv != nil {669 if h.wv != nil {
670+ if h.wv.ctx != nil {
671+ logging.FromContext(h.wv.ctx).Info().
672+ Int32("windowless_frame_rate_runtime", host.GetWindowlessFrameRate()).
673+ Msg("cef: browser runtime windowless frame rate")
674+ }
670 h.wv.SyncViewport(h.wv.ctx, "after-created")675 h.wv.SyncViewport(h.wv.ctx, "after-created")
671 }676 }
672 return state677 return state
@@ -709,6 +714,7 @@ func (h *handlerSet) finishAfterCreated(
709 if state.nativePopupParent != nil && state.nativePopupID != 0 {714 if state.nativePopupParent != nil && state.nativePopupID != 0 {
710 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)715 state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
711 }716 }
717+ h.wv.scheduleStartAdaptiveFrameRatePolling()
712 h.wv.scheduleStartBeginFrameLoop()718 h.wv.scheduleStartBeginFrameLoop()
713 h.wv.fireReadyToShow()719 h.wv.fireReadyToShow()
714 }720 }
@@ -757,6 +763,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
757 }763 }
758 })764 })
759 }765 }
766+ h.wv.scheduleStopAdaptiveFrameRatePolling()
760 h.wv.scheduleStopBeginFrameLoop()767 h.wv.scheduleStopBeginFrameLoop()
761 768
762 h.wv.mu.RLock()769 h.wv.mu.RLock()
A internal/infrastructure/config/cef_frame_rate_test.go +34 -0
@@ -0,0 +1,34 @@
1+package config
2+
3+import "testing"
4+
5+func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) {
6+ cfg := DefaultConfig().Engine.CEF
7+
8+ if !cfg.AdaptiveWindowlessFrameRate {
9+ t.Fatal("adaptive windowless frame-rate should be enabled by default")
10+ }
11+ if cfg.WindowlessFrameRate != 0 {
12+ t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate)
13+ }
14+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
15+ t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate)
16+ }
17+ if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) {
18+ t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax)
19+ }
20+ if !cfg.CEFAdaptiveWindowlessFrameRate() {
21+ t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set")
22+ }
23+}
24+
25+func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) {
26+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144}
27+
28+ if cfg.CEFAdaptiveWindowlessFrameRate() {
29+ t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate")
30+ }
31+ if got := cfg.CEFWindowlessFrameRate(); got != 144 {
32+ t.Fatalf("explicit frame-rate = %d, want 144", got)
33+ }
34+}
@@ -0,0 +1,34 @@
1+package config
2+
3+import "testing"
4+
5+func TestCEFAdaptiveWindowlessFrameRateDefaults(t *testing.T) {
6+ cfg := DefaultConfig().Engine.CEF
7+
8+ if !cfg.AdaptiveWindowlessFrameRate {
9+ t.Fatal("adaptive windowless frame-rate should be enabled by default")
10+ }
11+ if cfg.WindowlessFrameRate != 0 {
12+ t.Fatalf("default explicit windowless frame-rate = %d, want 0 for adaptive", cfg.WindowlessFrameRate)
13+ }
14+ if got := cfg.CEFWindowlessFrameRate(); got != int32(defaultCEFWindowlessFrameRate) {
15+ t.Fatalf("initial/fallback frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRate)
16+ }
17+ if got := cfg.CEFWindowlessFrameRateMax(); got != int32(defaultCEFWindowlessFrameRateMax) {
18+ t.Fatalf("max frame-rate = %d, want %d", got, defaultCEFWindowlessFrameRateMax)
19+ }
20+ if !cfg.CEFAdaptiveWindowlessFrameRate() {
21+ t.Fatal("adaptive should be effective when enabled and no explicit frame-rate is set")
22+ }
23+}
24+
25+func TestCEFAdaptiveWindowlessFrameRateDisabledByExplicitFrameRate(t *testing.T) {
26+ cfg := CEFEngineConfig{AdaptiveWindowlessFrameRate: true, WindowlessFrameRate: 144}
27+
28+ if cfg.CEFAdaptiveWindowlessFrameRate() {
29+ t.Fatal("explicit windowless_frame_rate should disable adaptive frame-rate")
30+ }
31+ if got := cfg.CEFWindowlessFrameRate(); got != 144 {
32+ t.Fatalf("explicit frame-rate = %d, want 144", got)
33+ }
34+}
M internal/infrastructure/config/defaults.go +10 -7
@@ -67,9 +67,10 @@ const (
6767 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
6868
6969 // Performance defaults
70- defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
71- defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72- defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF
70+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
71+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72+ defaultCEFWindowlessFrameRate = 60 // fallback OSR frame rate for CEF
73+ defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap
7374
7475 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
7576 defaultSkiaCPUPaintingThreads = 0
@@ -171,10 +172,12 @@ func DefaultConfig() *Config {
171172 // matches Epiphany's model and avoids a misleading setting.
172173 CookiePolicy: CookiePolicyAlways,
173174 CEF: CEFEngineConfig{
174- RenderStack: CEFRenderStackVulkan,
175- WindowlessFrameRate: defaultCEFWindowlessFrameRate,
176- LogFile: "",
177- EnableAudioHandler: true,
175+ RenderStack: CEFRenderStackVulkan,
176+ AdaptiveWindowlessFrameRate: true,
177+ WindowlessFrameRate: 0,
178+ WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax,
179+ LogFile: "",
180+ EnableAudioHandler: true,
178181 },
179182 WebKit: WebKitEngineConfig{
180183 ITPEnabled: true,
@@ -67,9 +67,10 @@ const (
67 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)67 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
68 68
69 // Performance defaults69 // Performance defaults
70- defaultZoomCacheSize = 256 // domains to cache (~20KB memory)70+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
71- defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup71+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
72- defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF72+ defaultCEFWindowlessFrameRate = 60 // fallback OSR frame rate for CEF
73+ defaultCEFWindowlessFrameRateMax = 240 // adaptive OSR frame-rate hard cap
73 74
74 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)75 // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
75 defaultSkiaCPUPaintingThreads = 076 defaultSkiaCPUPaintingThreads = 0
@@ -171,10 +172,12 @@ func DefaultConfig() *Config {
171 // matches Epiphany's model and avoids a misleading setting.172 // matches Epiphany's model and avoids a misleading setting.
172 CookiePolicy: CookiePolicyAlways,173 CookiePolicy: CookiePolicyAlways,
173 CEF: CEFEngineConfig{174 CEF: CEFEngineConfig{
174- RenderStack: CEFRenderStackVulkan,175+ RenderStack: CEFRenderStackVulkan,
175- WindowlessFrameRate: defaultCEFWindowlessFrameRate,176+ AdaptiveWindowlessFrameRate: true,
176- LogFile: "",177+ WindowlessFrameRate: 0,
177- EnableAudioHandler: true,178+ WindowlessFrameRateMax: defaultCEFWindowlessFrameRateMax,
179+ LogFile: "",
180+ EnableAudioHandler: true,
178 },181 },
179 WebKit: WebKitEngineConfig{182 WebKit: WebKitEngineConfig{
180 ITPEnabled: true,183 ITPEnabled: true,
M internal/infrastructure/config/defaults_test.go +2 -0
@@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1919 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
2020 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
2121 assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack())
22+ assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate())
2223 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24+ assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax())
2325 assert.Empty(t, cfg.Engine.CEF.LogFile)
2426 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
2527 assert.False(t, cfg.Engine.CEF.TraceHandlers)
@@ -19,7 +19,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
19 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)19 assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
20 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)20 assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy)
21 assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack())21 assert.Equal(t, CEFRenderStackVulkan, cfg.Engine.CEF.CEFRenderStack())
22+ assert.True(t, cfg.Engine.CEF.CEFAdaptiveWindowlessFrameRate())
22 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())23 assert.Equal(t, int32(defaultCEFWindowlessFrameRate), cfg.Engine.CEF.CEFWindowlessFrameRate())
24+ assert.Equal(t, int32(defaultCEFWindowlessFrameRateMax), cfg.Engine.CEF.CEFWindowlessFrameRateMax())
23 assert.Empty(t, cfg.Engine.CEF.LogFile)25 assert.Empty(t, cfg.Engine.CEF.LogFile)
24 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)26 assert.True(t, cfg.Engine.CEF.EnableAudioHandler)
25 assert.False(t, cfg.Engine.CEF.TraceHandlers)27 assert.False(t, cfg.Engine.CEF.TraceHandlers)
M internal/infrastructure/config/engine.go +19 -1
@@ -100,9 +100,16 @@ type CEFEngineConfig struct {
100100 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
101101 // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan.
102102 RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"`
103+ // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate
104+ // and updates CEF's OSR frame-rate while the browser moves across monitors.
105+ // It is ignored when WindowlessFrameRate is explicitly set.
106+ AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"`
103107 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
104- // Default: 60. Higher values increase CPU usage.
108+ // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the
109+ // active Wayland monitor refresh rate.
105110 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
111+ // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates.
112+ WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"`
106113 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
107114 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
108115 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
@@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
127134 return defaultCEFWindowlessFrameRate
128135 }
129136
137+func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 {
138+ if c.WindowlessFrameRateMax > 0 {
139+ return c.WindowlessFrameRateMax
140+ }
141+ return defaultCEFWindowlessFrameRateMax
142+}
143+
144+func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool {
145+ return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0
146+}
147+
130148 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
131149 // new [engine] config section, for use with ResolvePerformanceProfile.
132150 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {
@@ -100,9 +100,16 @@ type CEFEngineConfig struct {
100 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`100 LogSeverity int32 `mapstructure:"log_severity" toml:"log_severity" yaml:"log_severity"`
101 // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan.101 // RenderStack selects the CEF GPU render stack. Empty falls back to vulkan.
102 RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"`102 RenderStack CEFRenderStack `mapstructure:"render_stack" toml:"render_stack" yaml:"render_stack"`
103+ // AdaptiveWindowlessFrameRate polls the active Wayland monitor refresh rate
104+ // and updates CEF's OSR frame-rate while the browser moves across monitors.
105+ // It is ignored when WindowlessFrameRate is explicitly set.
106+ AdaptiveWindowlessFrameRate bool `mapstructure:"adaptive_windowless_frame_rate" toml:"adaptive_windowless_frame_rate" yaml:"adaptive_windowless_frame_rate"`
103 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.107 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
104- // Default: 60. Higher values increase CPU usage.108+ // When 0 and adaptive_windowless_frame_rate is true, Dumber adapts to the
109+ // active Wayland monitor refresh rate.
105 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`110 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
111+ // WindowlessFrameRateMax is the hard cap for adaptive monitor refresh rates.
112+ WindowlessFrameRateMax int32 `mapstructure:"windowless_frame_rate_max" toml:"windowless_frame_rate_max" yaml:"windowless_frame_rate_max"`
106 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.113 // EnableAudioHandler opts into the experimental CEF AudioHandler bridge.
107 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`114 EnableAudioHandler bool `mapstructure:"enable_audio_handler" toml:"enable_audio_handler" yaml:"enable_audio_handler"`
108 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.115 // TraceHandlers enables purego-cef handler/refcount tracing for diagnostics.
@@ -127,6 +134,17 @@ func (c CEFEngineConfig) CEFWindowlessFrameRate() int32 {
127 return defaultCEFWindowlessFrameRate134 return defaultCEFWindowlessFrameRate
128 }135 }
129 136
137+func (c CEFEngineConfig) CEFWindowlessFrameRateMax() int32 {
138+ if c.WindowlessFrameRateMax > 0 {
139+ return c.WindowlessFrameRateMax
140+ }
141+ return defaultCEFWindowlessFrameRateMax
142+}
143+
144+func (c CEFEngineConfig) CEFAdaptiveWindowlessFrameRate() bool {
145+ return c.AdaptiveWindowlessFrameRate && c.WindowlessFrameRate <= 0
146+}
147+
130 // PerformanceConfigFromEngine constructs a PerformanceConfig from the148 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
131 // new [engine] config section, for use with ResolvePerformanceProfile.149 // new [engine] config section, for use with ResolvePerformanceProfile.
132 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {150 func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {
M internal/infrastructure/config/loader.go +3 -1
@@ -694,7 +694,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
694694 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
695695 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
696696 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
697- m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())
697+ m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
698+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
699+ m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
698700 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
699701 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
700702
@@ -694,7 +694,9 @@ func (m *Manager) setEngineDefaults(defaults *Config) {
694 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))694 m.viper.SetDefault("engine.cef.render_stack", string(ce.CEFRenderStack()))
695 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)695 m.viper.SetDefault("engine.cef.log_file", ce.LogFile)
696 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)696 m.viper.SetDefault("engine.cef.log_severity", ce.LogSeverity)
697- m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.CEFWindowlessFrameRate())697+ m.viper.SetDefault("engine.cef.adaptive_windowless_frame_rate", ce.AdaptiveWindowlessFrameRate)
698+ m.viper.SetDefault("engine.cef.windowless_frame_rate", ce.WindowlessFrameRate)
699+ m.viper.SetDefault("engine.cef.windowless_frame_rate_max", ce.CEFWindowlessFrameRateMax())
698 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)700 m.viper.SetDefault("engine.cef.enable_audio_handler", ce.EnableAudioHandler)
699 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)701 m.viper.SetDefault("engine.cef.trace_handlers", ce.TraceHandlers)
700 702
M internal/infrastructure/config/schema_provider.go +17 -2
@@ -939,14 +939,29 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
939939 Values: []string{"vulkan", "egl"},
940940 Section: SectionPerformance,
941941 },
942+ {
943+ Key: "engine.cef.adaptive_windowless_frame_rate",
944+ Type: "bool",
945+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate),
946+ Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set",
947+ Section: SectionPerformance,
948+ },
942949 {
943950 Key: "engine.cef.windowless_frame_rate",
944951 Type: "int32",
945- Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),
946- Description: "Maximum off-screen frame rate for the CEF engine",
952+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate),
953+ Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured",
947954 Range: ">=0",
948955 Section: SectionPerformance,
949956 },
957+ {
958+ Key: "engine.cef.windowless_frame_rate_max",
959+ Type: "int32",
960+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()),
961+ Description: "Hard cap for adaptive CEF OSR frame rate",
962+ Range: ">0",
963+ Section: SectionPerformance,
964+ },
950965 {
951966 Key: "engine.webkit.skia_cpu_painting_threads",
952967 Type: "int",
@@ -939,14 +939,29 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
939 Values: []string{"vulkan", "egl"},939 Values: []string{"vulkan", "egl"},
940 Section: SectionPerformance,940 Section: SectionPerformance,
941 },941 },
942+ {
943+ Key: "engine.cef.adaptive_windowless_frame_rate",
944+ Type: "bool",
945+ Default: fmt.Sprintf("%t", defaults.Engine.CEF.AdaptiveWindowlessFrameRate),
946+ Description: "Adapt CEF OSR frame rate to the active Wayland monitor refresh rate when no explicit windowless_frame_rate is set",
947+ Section: SectionPerformance,
948+ },
942 {949 {
943 Key: "engine.cef.windowless_frame_rate",950 Key: "engine.cef.windowless_frame_rate",
944 Type: "int32",951 Type: "int32",
945- Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()),952+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.WindowlessFrameRate),
946- Description: "Maximum off-screen frame rate for the CEF engine",953+ Description: "Explicit maximum off-screen frame rate for the CEF engine; 0 enables adaptive mode when configured",
947 Range: ">=0",954 Range: ">=0",
948 Section: SectionPerformance,955 Section: SectionPerformance,
949 },956 },
957+ {
958+ Key: "engine.cef.windowless_frame_rate_max",
959+ Type: "int32",
960+ Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRateMax()),
961+ Description: "Hard cap for adaptive CEF OSR frame rate",
962+ Range: ">0",
963+ Section: SectionPerformance,
964+ },
950 {965 {
951 Key: "engine.webkit.skia_cpu_painting_threads",966 Key: "engine.webkit.skia_cpu_painting_threads",
952 Type: "int",967 Type: "int",
M internal/infrastructure/config/validation.go +3 -0
@@ -358,6 +358,9 @@ func validateCEF(config *Config) []string {
358358 if config.Engine.CEF.WindowlessFrameRate < 0 {
359359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
360360 }
361+ if config.Engine.CEF.WindowlessFrameRateMax < 0 {
362+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
363+ }
361364
362365 return validationErrors
363366 }
@@ -358,6 +358,9 @@ func validateCEF(config *Config) []string {
358 if config.Engine.CEF.WindowlessFrameRate < 0 {358 if config.Engine.CEF.WindowlessFrameRate < 0 {
359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")359 validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
360 }360 }
361+ if config.Engine.CEF.WindowlessFrameRateMax < 0 {
362+ validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
363+ }
361 364
362 return validationErrors365 return validationErrors
363 }366 }

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