detached 5eb26e1

Use purego-cef2gtk for CEF webviews

commit
5eb26e149fe794599101813d87fb078ee34871e0
author
brice · · 2026-04-29 19:43 UTC
parent
e77e492
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +465 -3718
M internal/infrastructure/cef/cef2gtk_adapter.go +4 -3
@@ -56,7 +56,7 @@ func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
56 }56 }
57 57
58 // NativeWidget returns the uintptr of the GLArea for embedding via58 // NativeWidget returns the uintptr of the GLArea for embedding via
59-// port.NativeWidgetProvider.59+// port.NativeWidgetProvider. Call on the GTK main thread.
60 func (a *Cef2gtkAdapter) NativeWidget() uintptr {60 func (a *Cef2gtkAdapter) NativeWidget() uintptr {
61 area := a.GLArea()61 area := a.GLArea()
62 if area == nil {62 if area == nil {
@@ -173,8 +173,9 @@ func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics {
173 173
174 // Destroy releases GL resources owned by purego-cef2gtk and disconnects GTK174 // Destroy releases GL resources owned by purego-cef2gtk and disconnects GTK
175 // signal handlers. Must be called on the GTK main thread. Not safe for175 // signal handlers. Must be called on the GTK main thread. Not safe for
176-// concurrent use. After Destroy, the adapter is marked destroyed and all176+// concurrent use; callers must ensure no concurrent adapter reads are in flight.
177-// subsequent operations return ErrAdapterDestroyed.177+// After Destroy, the adapter is marked destroyed and all subsequent operations
178+// return ErrAdapterDestroyed.
178 func (a *Cef2gtkAdapter) Destroy() error {179 func (a *Cef2gtkAdapter) Destroy() error {
179 if a == nil {180 if a == nil {
180 return ErrAdapterDestroyed181 return ErrAdapterDestroyed
A internal/infrastructure/cef/cef2gtk_boundary_test.go +27 -0
@@ -0,0 +1,27 @@
1+package cef
2+
3+import (
4+ "os/exec"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestCEF2GTKImportStaysInInfrastructureCEF(t *testing.T) {
10+ cmd := exec.Command("go", "list", "-mod=mod", "-f", "{{.ImportPath}} {{join .Imports \" \"}}", "./...")
11+ // Tests in this package run from internal/infrastructure/cef; ../../.. is the
12+ // repository root for go list ./....
13+ cmd.Dir = "../../.."
14+ out, err := cmd.Output()
15+ if err != nil {
16+ t.Fatalf("go list imports: %v", err)
17+ }
18+ for _, line := range strings.Split(string(out), "\n") {
19+ if !strings.Contains(line, "github.com/bnema/purego-cef2gtk") {
20+ continue
21+ }
22+ if strings.HasPrefix(line, "github.com/bnema/dumber/internal/infrastructure/cef ") {
23+ continue
24+ }
25+ t.Fatalf("purego-cef2gtk imported outside cef infrastructure: %s", line)
26+ }
27+}
M internal/infrastructure/cef/context_menu.go +28 -22
@@ -67,31 +67,37 @@ func (h *handlerSet) RunContextMenu(
67 commandIDByAction[item.Action] = cmdID67 commandIDByAction[item.Action] = cmdID
68 }68 }
69 }69 }
70- if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil {70+ if h.wv == nil || h.wv.viewBridge == nil {
71 return 071 return 0
72 }72 }
73 73
74- x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale)74+ x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale())
75- glArea := h.wv.pipeline.glArea
76- logContextMenuPopupRequest(h, glArea, params, x, y)
77 executor := h.contextMenuExecutor()75 executor := h.contextMenuExecutor()
78-76+ wv := h.wv
79- NewRenderer(h.wv.runOnGTK).Show(77+ wv.runOnGTK(func() {
80- items,78+ if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {
81- &glArea.Widget,
82- x,
83- y,
84- func(item port.MenuItem) {
85- var copiedCallback func(string)
86- if h.wv.engine != nil {
87- copiedCallback = h.wv.engine.notifyClipboardCopied
88- }
89- dispatchContextMenuSelection(h.wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
90- },
91- func() {
92 callback.Cancel()79 callback.Cancel()
93- },80+ return
94- )81+ }
82+ glArea := wv.viewBridge.GLArea()
83+ logContextMenuPopupRequest(h, glArea, params, x, y)
84+ NewRenderer(nil).Show(
85+ items,
86+ &glArea.Widget,
87+ x,
88+ y,
89+ func(item port.MenuItem) {
90+ var copiedCallback func(string)
91+ if wv.engine != nil {
92+ copiedCallback = wv.engine.notifyClipboardCopied
93+ }
94+ dispatchContextMenuSelection(wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
95+ },
96+ func() {
97+ callback.Cancel()
98+ },
99+ )
100+ })
95 return 1101 return 1
96 }102 }
97 103
@@ -123,7 +129,7 @@ func logContextMenuPopupRequest(
123 params purecef.ContextMenuParams,129 params purecef.ContextMenuParams,
124 x, y int32,130 x, y int32,
125 ) {131 ) {
126- if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {132+ if h == nil || h.wv == nil || h.wv.ctx == nil || glArea == nil {
127 return133 return
128 }134 }
129 rawX, rawY := contextMenuRawPosition(params)135 rawX, rawY := contextMenuRawPosition(params)
@@ -133,7 +139,7 @@ func logContextMenuPopupRequest(
133 Int32("raw_y", rawY).139 Int32("raw_y", rawY).
134 Int32("popup_x", x).140 Int32("popup_x", x).
135 Int32("popup_y", y).141 Int32("popup_y", y).
136- Int32("scale", h.wv.pipeline.scale).142+ Int32("scale", h.wv.viewBridgeScale()).
137 Int("anchor_width", glArea.Widget.GetAllocatedWidth()).143 Int("anchor_width", glArea.Widget.GetAllocatedWidth()).
138 Int("anchor_height", glArea.Widget.GetAllocatedHeight()).144 Int("anchor_height", glArea.Widget.GetAllocatedHeight()).
139 Int("parent_width", cefWidgetAllocatedWidth(parent)).145 Int("parent_width", cefWidgetAllocatedWidth(parent)).
M internal/infrastructure/cef/engine.go +0 -1
@@ -24,7 +24,6 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
24 // It manages the CEF lifecycle and provides access to all engine subsystems.24 // It manages the CEF lifecycle and provides access to all engine subsystems.
25 type Engine struct {25 type Engine struct {
26 ctx context.Context26 ctx context.Context
27- gl *glLoader
28 factory *WebViewFactory27 factory *WebViewFactory
29 pool *WebViewPool28 pool *WebViewPool
30 29
M internal/infrastructure/cef/engine_init.go +2 -9
@@ -159,20 +159,13 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge
159 return nil159 return nil
160 }160 }
161 161
162-// wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.162+// wireEngine creates factory, pool, and scheme handler after CEF init.
163 func wireEngine(163 func wireEngine(
164 ctx context.Context, eng *Engine,164 ctx context.Context, eng *Engine,
165 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,165 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
166 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),166 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
167 ) (*Engine, error) {167 ) (*Engine, error) {
168- gl, err := newGLLoader()168+ eng.factory = newWebViewFactory(eng, webViewFactoryOptions{
169- if err != nil {
170- purecef.Shutdown()
171- return nil, fmt.Errorf("GL loader: %w", err)
172- }
173-
174- eng.gl = gl
175- eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{
176 scale: detectHiDPIScale(logger),169 scale: detectHiDPIScale(logger),
177 windowlessFrameRate: windowlessFrameRate,170 windowlessFrameRate: windowlessFrameRate,
178 audioOutputFactory: audioFactory,171 audioOutputFactory: audioFactory,
M internal/infrastructure/cef/factory.go +64 -86
@@ -17,11 +17,10 @@ import (
17 var _ port.WebViewFactory = (*WebViewFactory)(nil)17 var _ port.WebViewFactory = (*WebViewFactory)(nil)
18 18
19 // WebViewFactory creates new CEF off-screen browser WebViews. Each WebView19 // WebViewFactory creates new CEF off-screen browser WebViews. Each WebView
20-// gets a unique ID, its own renderPipeline and inputBridge, and an20+// gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created
21-// asynchronously-created CEF browser (via BrowserHostCreateBrowser).21+// CEF browser (via BrowserHostCreateBrowser).
22 type WebViewFactory struct {22 type WebViewFactory struct {
23 engine *Engine23 engine *Engine
24- gl *glLoader
25 nextID atomic.Uint6424 nextID atomic.Uint64
26 scale int3225 scale int32
27 windowlessFrameRate int3226 windowlessFrameRate int32
@@ -48,8 +47,8 @@ const (
48 )47 )
49 48
50 // newWebViewFactory returns a factory that will create WebViews using the49 // newWebViewFactory returns a factory that will create WebViews using the
51-// given GL loader and HiDPI scale factor.50+// given HiDPI scale factor.
52-func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory {51+func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory {
53 if opts.scale < 1 {52 if opts.scale < 1 {
54 opts.scale = 153 opts.scale = 1
55 }54 }
@@ -58,10 +57,9 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
58 }57 }
59 return &WebViewFactory{58 return &WebViewFactory{
60 engine: engine,59 engine: engine,
61- gl: gl,
62 scale: opts.scale,60 scale: opts.scale,
63 windowlessFrameRate: opts.windowlessFrameRate,61 windowlessFrameRate: opts.windowlessFrameRate,
64- audioOutputFactory: opts.audioOutputFactory,62+ audioOutputFactory: opts.audioOutputFactory,
65 }63 }
66 }64 }
67 65
@@ -93,57 +91,37 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
93 settings.BackgroundColor = bg91 settings.BackgroundColor = bg
94 }92 }
95 93
96- // Defer browser creation until the GL area has a non-zero size.94+ // Defer browser creation until the bridge view has a non-zero size.
97 // CEF requires GetViewRect to return a non-empty rect, but the GL area95 // CEF requires GetViewRect to return a non-empty rect, but the GL area
98 // is not yet realized at this point.96 // is not yet realized at this point.
99- f.configureInitialBrowserCreation(ctx, wv, wv.pipeline, wv.client, &windowInfo, &settings)97+ f.configureInitialBrowserCreation(ctx, wv, wv.client, &windowInfo, &settings, nil)
100 98
101 return wv, nil99 return wv, nil
102 }100 }
103 101
104 func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {102 func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
105 id := port.WebViewID(f.nextID.Add(1))103 id := port.WebViewID(f.nextID.Add(1))
106- pipeline := newRenderPipeline(ctx, f.gl, f.scale, id)104+ viewBridge := NewCef2gtkAdapter()
107 105
108 wv := &WebView{106 wv := &WebView{
109 id: id,107 id: id,
110 ctx: ctx,108 ctx: ctx,
111 engine: f.engine,109 engine: f.engine,
112- pipeline: pipeline,110+ viewBridge: viewBridge,
113 audioOutputFactory: f.audioOutputFactory,111 audioOutputFactory: f.audioOutputFactory,
114- resizeReconciler: newResizeReconciler(ctx, id),
115 windowlessFrameRate: f.windowlessFrameRate,112 windowlessFrameRate: f.windowlessFrameRate,
116 backgroundColor: f.bgColor.Load(),113 backgroundColor: f.bgColor.Load(),
117 }114 }
118 115
119- handlers := &handlerSet{116+ handlers := &handlerSet{wv: wv}
120- wv: wv,
121- }
122 wv.handlers = handlers117 wv.handlers = handlers
123 wv.findCtrl = newFindController()118 wv.findCtrl = newFindController()
124 119
125- input := newInputBridge(ctx, f.scale)120+ if viewBridge != nil {
126- input.selectionText = wv.selectedTextSnapshot121+ wv.runOnGTKSync(func() {
127- input.explicitCopyText = func(action, text string) {122+ if err := viewBridge.PrepareOnGTKThread(); err != nil && ctx != nil {
128- if f.engine != nil {123+ logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef: failed to prepare cef2gtk view")
129- f.engine.handleExplicitClipboardBridgeText(wv.id, action, text)124+ }
130- }
131- }
132- input.attachTo(pipeline.glArea)
133- wv.input = input
134-
135- // Wire middle-click → new tab using the cached hover URI.
136- input.onMiddleClick = func(_ string) {
137- wv.mu.RLock()
138- hoverURI := wv.lastHoverURI
139- cb := wv.callbacks
140- wv.mu.RUnlock()
141- if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil {
142- return
143- }
144- uri := hoverURI
145- wv.runOnGTK(func() {
146- cb.OnLinkMiddleClick(uri)
147 })125 })
148 }126 }
149 127
@@ -154,64 +132,65 @@ func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
154 }132 }
155 133
156 func (f *WebViewFactory) configureInitialBrowserCreation(134 func (f *WebViewFactory) configureInitialBrowserCreation(
157- ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.RawClient,135+ ctx context.Context, wv *WebView, client purecef.RawClient,
158 windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,136 windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,
137+ onFirstResize func(w, h int32),
159 ) {138 ) {
160 wv.pendingCreate = &pendingBrowserCreate{139 wv.pendingCreate = &pendingBrowserCreate{
161 windowInfo: windowInfo,140 windowInfo: windowInfo,
162 client: client,141 client: client,
163 settings: settings,142 settings: settings,
164 }143 }
165-144+ if onFirstResize == nil {
166- pipeline.onFirstResize = func(w, h int32) {145+ onFirstResize = func(w, h int32) {
167- logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")146+ logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
168- f.postPendingBrowserCreate(ctx, wv, w, h)147+ f.postPendingBrowserCreate(ctx, wv, w, h)
148+ }
169 }149 }
170-150+ if wv.viewBridge == nil {
171- // On subsequent resizes, notify CEF so it re-queries GetViewRect.151+ return
172- pipeline.onResizeCB = func(w, h int32) {152+ }
173- log := logging.FromContext(ctx)153+ firstResize := true
174- wv.mu.RLock()154+ wv.runOnGTKSync(func() {
175- host := wv.host155+ wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) {
176- wv.mu.RUnlock()156+ if firstResize {
177- if host == nil {157+ firstResize = false
158+ onFirstResize(w, h)
159+ return
160+ }
161+
162+ // On subsequent resizes, notify CEF so it re-queries GetViewRect.
163+ log := logging.FromContext(ctx)
164+ wv.mu.RLock()
165+ host := wv.host
166+ wv.mu.RUnlock()
167+ if host == nil {
168+ log.Debug().
169+ Uint64("webview_id", uint64(wv.id)).
170+ Int32("resize_width", w).
171+ Int32("resize_height", h).
172+ Bool("host_nil", true).
173+ Bool("browser_ready", false).
174+ Msg("cef: resize observed before browser host existed")
175+ return
176+ }
178 log.Debug().177 log.Debug().
179 Uint64("webview_id", uint64(wv.id)).178 Uint64("webview_id", uint64(wv.id)).
180 Int32("resize_width", w).179 Int32("resize_width", w).
181 Int32("resize_height", h).180 Int32("resize_height", h).
182- Bool("host_nil", true).181+ Bool("host_nil", false).
183- Bool("browser_ready", false).182+ Bool("browser_ready", true).
184- Msg("cef: resize observed before browser host existed")183+ Msg("cef: browser resize observed before WasResized")
185- return184+ notifyBrowserResize(host)
186- }185+ log.Debug().
187- log.Debug().186+ Uint64("webview_id", uint64(wv.id)).
188- Uint64("webview_id", uint64(wv.id)).187+ Int32("resize_width", w).
189- Int32("resize_width", w).188+ Int32("resize_height", h).
190- Int32("resize_height", h).189+ Bool("host_nil", false).
191- Bool("host_nil", false).190+ Bool("browser_ready", true).
192- Bool("browser_ready", true).191+ Msg("cef: browser host WasResized invoked")
193- Msg("cef: browser resize observed before WasResized")192+ })
194- notifyBrowserResize(host)193+ })
195- log.Debug().
196- Uint64("webview_id", uint64(wv.id)).
197- Int32("resize_width", w).
198- Int32("resize_height", h).
199- Bool("host_nil", false).
200- Bool("browser_ready", true).
201- Msg("cef: browser host WasResized invoked")
202- if wv.resizeReconciler != nil {
203- resizeSeq, _ := wv.pipeline.latestResizeDiagnostics()
204- wv.resizeReconciler.start(
205- resizeSeq,
206- func() resizeNotifiableBrowserHost {
207- wv.mu.RLock()
208- defer wv.mu.RUnlock()
209- return wv.host
210- },
211- func() bool { return wv.destroyed.Load() },
212- )
213- }
214- }
215 }194 }
216 195
217 func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {196 func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
@@ -336,8 +315,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
336 settings.BackgroundColor = bg315 settings.BackgroundColor = bg
337 }316 }
338 317
339- f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings)318+ f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
340- popupWV.pipeline.onFirstResize = func(w, h int32) {
341 log := logging.FromContext(ctx)319 log := logging.FromContext(ctx)
342 if popupWV.awaitsNativePopupAttachment() {320 if popupWV.awaitsNativePopupAttachment() {
343 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")321 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")
@@ -358,6 +336,6 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
358 }336 }
359 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")337 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
360 f.postPendingBrowserCreate(ctx, popupWV, w, h)338 f.postPendingBrowserCreate(ctx, popupWV, w, h)
361- }339+ })
362 return popupWV, nil340 return popupWV, nil
363 }341 }
A internal/infrastructure/cef/focus_bridge.go +12 -0
@@ -0,0 +1,12 @@
1+package cef
2+
3+import purecef "github.com/bnema/purego-cef/cef"
4+
5+func syncWindowlessBrowserFocus(host purecef.BrowserHost) {
6+ if host == nil {
7+ return
8+ }
9+ host.WasHidden(0)
10+ host.SetFocus(1)
11+ host.Invalidate(purecef.PaintElementTypePetView)
12+}
D internal/infrastructure/cef/gl_loader.go +0 -184
@@ -1,184 +0,0 @@
1-package cef
2-
3-import (
4- "fmt"
5- "unsafe"
6-
7- "github.com/bnema/purego"
8-)
9-
10-// OpenGL constants for PBO-based texture rendering.
11-const (
12- glTexture2D = 0x0DE1
13- glBGRA = 0x80E1
14- glRGBA = 0x1908
15- glUnsignedByte = 0x1401
16-
17- glPixelUnpackBuffer = 0x88EC
18- glStreamDraw = 0x88E0
19- glWriteOnly = 0x88B9
20-
21- glColorBufferBit = 0x00004000
22- glTriangleStrip = 0x0005
23-
24- glFragmentShader = 0x8B30
25- glVertexShader = 0x8B31
26- glFloat = 0x1406
27-
28- glArrayBuffer = 0x8892
29- glStaticDraw = 0x88E4
30-
31- glLinear = 0x2601
32- glTextureMinFilter = 0x2801
33- glTextureMagFilter = 0x2800
34- glClampToEdge = 0x812F
35- glTextureWrapS = 0x2802
36- glTextureWrapT = 0x2803
37-
38- glUnpackRowLength = 0x0CF2
39-)
40-
41-// glLoader holds dynamically loaded OpenGL function pointers.
42-// All functions are loaded from libGL.so.1 via purego at runtime (no CGo).
43-type glLoader struct {
44- handle uintptr
45-
46- // Texture functions
47- genTextures func(n int32, textures *uint32)
48- deleteTextures func(n int32, textures *uint32)
49- bindTexture func(target uint32, texture uint32)
50- texImage2D func(
51- target uint32, level, internalformat, width, height, border int32,
52- format, xtype uint32, pixels unsafe.Pointer,
53- )
54- texSubImage2D func(
55- target uint32, level, xoffset, yoffset, width, height int32,
56- format, xtype uint32, pixels unsafe.Pointer,
57- )
58- texParameteri func(target uint32, pname uint32, param int32)
59-
60- // PBO / buffer functions
61- genBuffers func(n int32, buffers *uint32)
62- deleteBuffers func(n int32, buffers *uint32)
63- bindBuffer func(target uint32, buffer uint32)
64- bufferData func(target uint32, size int64, data unsafe.Pointer, usage uint32)
65- mapBuffer func(target uint32, access uint32) unsafe.Pointer
66- unmapBuffer func(target uint32) bool
67-
68- // Shader functions
69- createShader func(shaderType uint32) uint32
70- shaderSource func(shader uint32, count int32, source **byte, length *int32)
71- compileShader func(shader uint32)
72- deleteShader func(shader uint32)
73- createProgram func() uint32
74- attachShader func(program uint32, shader uint32)
75- linkProgram func(program uint32)
76- useProgram func(program uint32)
77- getUniformLocation func(program uint32, name *byte) int32
78- uniform1i func(location int32, v0 int32)
79-
80- // VAO / VBO functions
81- genVertexArrays func(n int32, arrays *uint32)
82- bindVertexArray func(array uint32)
83- deleteVertexArrays func(n int32, arrays *uint32)
84- vertexAttribPointer func(index uint32, size int32, xtype uint32, normalized bool, stride int32, pointer uintptr)
85- enableVertexAttribArray func(index uint32)
86-
87- // Pixel store
88- pixelStorei func(pname uint32, param int32)
89-
90- // Draw / state functions
91- clear func(mask uint32)
92- clearColor func(red float32, green float32, blue float32, alpha float32)
93- viewport func(x int32, y int32, width int32, height int32)
94- drawArrays func(mode uint32, first int32, count int32)
95- enable func(cap uint32)
96- disable func(cap uint32)
97-}
98-
99-// Close releases the OpenGL library handle.
100-func (gl *glLoader) Close() error {
101- if gl == nil {
102- return nil
103- }
104- if gl.handle != 0 {
105- err := purego.Dlclose(gl.handle)
106- gl.handle = 0
107- return err
108- }
109- return nil
110-}
111-
112-// newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers.
113-func newGLLoader() (*glLoader, error) {
114- handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY)
115- if err != nil {
116- return nil, fmt.Errorf("failed to open libGL.so.1: %w", err)
117- }
118-
119- gl := &glLoader{handle: handle}
120-
121- if err := gl.registerAll(); err != nil {
122- return nil, fmt.Errorf("failed to register GL functions: %w", err)
123- }
124-
125- return gl, nil
126-}
127-
128-// registerAll loads every GL function pointer from the shared library.
129-// It wraps RegisterLibFunc panics into errors for graceful handling.
130-func (gl *glLoader) registerAll() (retErr error) {
131- defer func() {
132- if r := recover(); r != nil {
133- retErr = fmt.Errorf("GL symbol lookup failed: %v", r)
134- }
135- }()
136-
137- // Texture
138- purego.RegisterLibFunc(&gl.genTextures, gl.handle, "glGenTextures")
139- purego.RegisterLibFunc(&gl.deleteTextures, gl.handle, "glDeleteTextures")
140- purego.RegisterLibFunc(&gl.bindTexture, gl.handle, "glBindTexture")
141- purego.RegisterLibFunc(&gl.texImage2D, gl.handle, "glTexImage2D")
142- purego.RegisterLibFunc(&gl.texSubImage2D, gl.handle, "glTexSubImage2D")
143- purego.RegisterLibFunc(&gl.texParameteri, gl.handle, "glTexParameteri")
144-
145- // PBO / buffers
146- purego.RegisterLibFunc(&gl.genBuffers, gl.handle, "glGenBuffers")
147- purego.RegisterLibFunc(&gl.deleteBuffers, gl.handle, "glDeleteBuffers")
148- purego.RegisterLibFunc(&gl.bindBuffer, gl.handle, "glBindBuffer")
149- purego.RegisterLibFunc(&gl.bufferData, gl.handle, "glBufferData")
150- purego.RegisterLibFunc(&gl.mapBuffer, gl.handle, "glMapBuffer")
151- purego.RegisterLibFunc(&gl.unmapBuffer, gl.handle, "glUnmapBuffer")
152-
153- // Shaders
154- purego.RegisterLibFunc(&gl.createShader, gl.handle, "glCreateShader")
155- purego.RegisterLibFunc(&gl.shaderSource, gl.handle, "glShaderSource")
156- purego.RegisterLibFunc(&gl.compileShader, gl.handle, "glCompileShader")
157- purego.RegisterLibFunc(&gl.deleteShader, gl.handle, "glDeleteShader")
158- purego.RegisterLibFunc(&gl.createProgram, gl.handle, "glCreateProgram")
159- purego.RegisterLibFunc(&gl.attachShader, gl.handle, "glAttachShader")
160- purego.RegisterLibFunc(&gl.linkProgram, gl.handle, "glLinkProgram")
161- purego.RegisterLibFunc(&gl.useProgram, gl.handle, "glUseProgram")
162- purego.RegisterLibFunc(&gl.getUniformLocation, gl.handle, "glGetUniformLocation")
163- purego.RegisterLibFunc(&gl.uniform1i, gl.handle, "glUniform1i")
164-
165- // VAO / VBO
166- purego.RegisterLibFunc(&gl.genVertexArrays, gl.handle, "glGenVertexArrays")
167- purego.RegisterLibFunc(&gl.bindVertexArray, gl.handle, "glBindVertexArray")
168- purego.RegisterLibFunc(&gl.deleteVertexArrays, gl.handle, "glDeleteVertexArrays")
169- purego.RegisterLibFunc(&gl.vertexAttribPointer, gl.handle, "glVertexAttribPointer")
170- purego.RegisterLibFunc(&gl.enableVertexAttribArray, gl.handle, "glEnableVertexAttribArray")
171-
172- // Pixel store
173- purego.RegisterLibFunc(&gl.pixelStorei, gl.handle, "glPixelStorei")
174-
175- // Draw / state
176- purego.RegisterLibFunc(&gl.clear, gl.handle, "glClear")
177- purego.RegisterLibFunc(&gl.clearColor, gl.handle, "glClearColor")
178- purego.RegisterLibFunc(&gl.viewport, gl.handle, "glViewport")
179- purego.RegisterLibFunc(&gl.drawArrays, gl.handle, "glDrawArrays")
180- purego.RegisterLibFunc(&gl.enable, gl.handle, "glEnable")
181- purego.RegisterLibFunc(&gl.disable, gl.handle, "glDisable")
182-
183- return nil
184-}
D internal/infrastructure/cef/input_bridge.go +0 -780
@@ -1,780 +0,0 @@
1-package cef
2-
3-import (
4- "context"
5- "sync"
6- "sync/atomic"
7- "unicode/utf8"
8-
9- purecef "github.com/bnema/purego-cef/cef"
10- "github.com/bnema/puregotk/v4/gdk"
11- "github.com/bnema/puregotk/v4/gio"
12- "github.com/bnema/puregotk/v4/gtk"
13-
14- "github.com/bnema/dumber/internal/infrastructure/webutil"
15- "github.com/bnema/dumber/internal/logging"
16-)
17-
18-// GDK dead key range boundaries. Dead keys (0xfe50–0xfe8c) are compose
19-// modifiers (dead_grave, dead_acute, …) that should not generate CEF key
20-// events by themselves — the IMContext absorbs them and emits a commit
21-// with the composed character.
22-const (
23- gdkDeadKeyStart = 0xfe50
24- gdkDeadKeyEnd = 0xfe8c
25-
26- // maxBMPCodepoint is the highest Unicode codepoint in the Basic
27- // Multilingual Plane that fits in a single uint16 / UTF-16 code unit.
28- maxBMPCodepoint = 0xFFFF
29- // minPrintable is the lowest printable Unicode codepoint (space).
30- minPrintable = 0x20
31- // maxSingleByteKeyval is the upper bound for GDK keyvals that can be
32- // used directly as Windows VK codes (when no explicit mapping exists).
33- maxSingleByteKeyval = 0x100
34-)
35-
36-// inputBridge translates GDK events from GTK event controllers into CEF input
37-// events and forwards them to the CEF BrowserHost. The host field is nil until
38-// the browser is created asynchronously (OnAfterCreated), so all dispatch
39-// methods guard against a nil host.
40-//
41-// Dead key / compose support: a GtkIMContextSimple is attached to the key
42-// controller. When the user types a compose sequence (e.g. dead_acute + e),
43-// the IMContext absorbs the individual key events and emits a "commit" signal
44-// with the composed string ("é"). The bridge sends that as CHAR events to CEF.
45-// Keys not consumed by the IMContext flow through key-pressed as before.
46-type inputBridge struct {
47- host purecef.BrowserHost
48- scale int32
49- ctx context.Context
50- mu sync.Mutex
51-
52- // Last known pointer position, used for scroll events which don't
53- // carry their own coordinates from GDK.
54- lastX, lastY float64
55-
56- // GDK clipboard for paste support. CEF in OSR mode cannot access the
57- // Wayland clipboard directly, so we read from GDK and inject via
58- // ImeCommitText. Set once during attachTo, read-only afterwards.
59- clipboard *gdk.Clipboard
60-
61- // selectionText returns the browser's latest known text selection so Ctrl+C
62- // / Ctrl+X can route the selection through the shared explicit-copy path.
63- selectionText func() string
64-
65- // explicitCopyText routes explicit copy/cut text through the shared
66- // clipboard orchestration path.
67- explicitCopyText func(action, text string)
68-
69- // IMContext for dead key / compose sequence support. Held as a field
70- // to prevent garbage collection while the controller is alive.
71- imContext *gtk.IMContextSimple
72- // Prevent GC from collecting signal callbacks.
73- commitCb func(gtk.IMContext, string)
74-
75- // glArea is stored so we can GrabFocus on click. Without explicit
76- // focus grab, GTK may not fire the focus-enter signal and CEF won't
77- // show the text caret.
78- glArea *gtk.GLArea
79-
80- // gtkFocused mirrors the latest GTK focus-controller state so non-GTK
81- // threads (CEF callbacks, scheme handlers) can safely know whether this
82- // widget is still the active focused view.
83- gtkFocused atomic.Bool
84-
85- // onMiddleClick is called when button 2 (middle) is pressed on a link.
86- // The callback receives the hovered URI. Set by the factory.
87- onMiddleClick func(uri string)
88-}
89-
90-// newInputBridge creates an input bridge with the given HiDPI scale factor.
91-type browserFocusSyncHost interface {
92- WasHidden(hidden int32)
93- SetFocus(focus int32)
94- Invalidate(elementType purecef.PaintElementType)
95-}
96-
97-func syncWindowlessBrowserFocus(host browserFocusSyncHost) {
98- if host == nil {
99- return
100- }
101- host.WasHidden(0)
102- host.SetFocus(1)
103- host.Invalidate(purecef.PaintElementTypePetView)
104-}
105-
106-func newInputBridge(ctx context.Context, scale int32) *inputBridge {
107- return &inputBridge{ctx: ctx, scale: scale}
108-}
109-
110-// setHost is called once the browser is created to enable event dispatch.
111-func (ib *inputBridge) setHost(host purecef.BrowserHost) {
112- ib.mu.Lock()
113- defer ib.mu.Unlock()
114- ib.host = host
115-}
116-
117-// attachTo creates GDK event controllers and connects them to the given GLArea.
118-func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
119- ib.glArea = glArea
120-
121- // Acquire GDK clipboard for paste support. CEF OSR can't access the
122- // Wayland clipboard, so we bridge it via GDK → ImeCommitText.
123- if display := gdk.DisplayGetDefault(); display != nil {
124- ib.clipboard = display.GetClipboard()
125- if ib.clipboard != nil {
126- logging.FromContext(ib.ctx).Debug().Msg("cef: GDK clipboard acquired for paste bridge")
127- } else {
128- logging.FromContext(ib.ctx).Warn().Msg("cef: GDK display found but GetClipboard returned nil")
129- }
130- } else {
131- logging.FromContext(ib.ctx).Warn().Msg("cef: no GDK display — clipboard paste will not work")
132- }
133-
134- // Motion (mouse move + enter/leave)
135- motion := gtk.NewEventControllerMotion()
136- motionCb := func(g gtk.EventControllerMotion, x, y float64) {
137- mods := uint(g.GetCurrentEventState())
138- ib.onMouseMove(x, y, mods, false)
139- }
140- motion.ConnectMotion(&motionCb)
141-
142- leaveCb := func(g gtk.EventControllerMotion) {
143- mods := uint(g.GetCurrentEventState())
144- ib.onMouseMove(0, 0, mods, true)
145- }
146- motion.ConnectLeave(&leaveCb)
147-
148- glArea.AddController(&motion.EventController)
149-
150- // Click (press / release)
151- click := gtk.NewGestureClick()
152- click.SetButton(0) // listen to all buttons
153-
154- pressedCb := func(g gtk.GestureClick, nPress int, x, y float64) {
155- // Ensure the GLArea has GTK focus so CEF receives SetFocus(1)
156- // and renders the text caret. Without this, clicking an input
157- // field may not show the blinking cursor.
158- if ib.glArea != nil {
159- ib.glArea.GrabFocus()
160- }
161- btn := g.GetCurrentButton()
162- mods := uint(g.GetCurrentEventState())
163- ib.onMousePress(x, y, btn, mods, nPress)
164- }
165- click.ConnectPressed(&pressedCb)
166-
167- releasedCb := func(g gtk.GestureClick, nPress int, x, y float64) {
168- btn := g.GetCurrentButton()
169- mods := uint(g.GetCurrentEventState())
170- ib.onMouseRelease(x, y, btn, mods, nPress)
171- }
172- click.ConnectReleased(&releasedCb)
173-
174- glArea.AddController(&click.EventController)
175-
176- // Scroll
177- scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue)
178- scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool {
179- ib.onScroll(dx, dy)
180- return true
181- }
182- scroll.ConnectScroll(&scrollCb)
183-
184- glArea.AddController(&scroll.EventController)
185-
186- // Focus, keyboard, and IMContext.
187- ib.attachFocusAndKeyboard(glArea)
188-}
189-
190-// attachFocusAndKeyboard wires focus, keyboard, and IMContext controllers
191-// to the GLArea. Extracted from attachTo to keep function length manageable.
192-func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
193- // Focus — forward to CEF and IMContext.
194- focus := gtk.NewEventControllerFocus()
195- focusEnterCb := func(_ gtk.EventControllerFocus) {
196- ib.gtkFocused.Store(true)
197- if ib.imContext != nil {
198- ib.imContext.FocusIn()
199- }
200- ib.onFocusIn()
201- }
202- focus.ConnectEnter(&focusEnterCb)
203- focusLeaveCb := func(_ gtk.EventControllerFocus) {
204- ib.gtkFocused.Store(false)
205- if ib.imContext != nil {
206- ib.imContext.Reset()
207- ib.imContext.FocusOut()
208- }
209- ib.onFocusOut()
210- }
211- focus.ConnectLeave(&focusLeaveCb)
212-
213- glArea.AddController(&focus.EventController)
214-
215- // Keyboard + IMContext for dead key / compose support.
216- key := gtk.NewEventControllerKey()
217-
218- // Set up IMContext for dead key / compose support.
219- // When set on the controller, GTK calls FilterKeypress on every key
220- // event. If the IM absorbs the key (dead key, compose in-progress),
221- // key-pressed is NOT emitted; the IM emits "commit" with the result.
222- ib.imContext = gtk.NewIMContextSimple()
223- if ib.imContext != nil {
224- ib.commitCb = func(_ gtk.IMContext, text string) {
225- ib.onIMCommit(text)
226- }
227- ib.imContext.ConnectCommit(&ib.commitCb)
228- key.SetImContext(&ib.imContext.IMContext)
229- ib.imContext.SetClientWidget(&glArea.Widget)
230- }
231-
232- keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
233- mods := uint(state)
234-
235- ib.maybeMirrorClipboardShortcut(keyval, mods)
236-
237- // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
238- // Wayland/X11 clipboard, so we read from GDK and inject via
239- // ImeCommitText instead of letting CEF handle the paste.
240- if mods&uint(gdk.ControlMaskValue) != 0 &&
241- (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) {
242- logging.FromContext(ib.ctx).Debug().
243- Uint("keyval", keyval).
244- Uint("mods", mods).
245- Msg("cef: clipboard paste intercepted (Ctrl+V)")
246- ib.pasteFromClipboard()
247- return true
248- }
249-
250- ib.onKeyPress(keyval, keycode, mods)
251-
252- // Let modifier combos and function keys propagate to the window's
253- // ShortcutController so that app shortcuts (Ctrl+L, F12, Escape, …)
254- // still fire. Only consume plain text input keys.
255- if mods&uint(gdk.ControlMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
256- return false
257- }
258- // F-keys (F1–F12) and Escape are app shortcuts, not text input.
259- if keyval >= gdkKeyF1Start && keyval <= gdkKeyF12End {
260- return false
261- }
262- if keyval == gdkKeyEscape {
263- return false
264- }
265- return true
266- }
267- key.ConnectKeyPressed(&keyPressCb)
268-
269- keyReleaseCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) {
270- ib.onKeyRelease(keyval, keycode, uint(state))
271- }
272- key.ConnectKeyReleased(&keyReleaseCb)
273-
274- glArea.AddController(&key.EventController)
275-
276- // Make the GLArea focusable so it can receive key events.
277- glArea.SetFocusable(true)
278- glArea.SetCanFocus(true)
279-}
280-
281-// ---------------------------------------------------------------------------
282-// Event handlers
283-// ---------------------------------------------------------------------------
284-
285-func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) {
286- ib.mu.Lock()
287- host := ib.host
288- if !leave {
289- ib.lastX = x
290- ib.lastY = y
291- }
292- ib.mu.Unlock()
293- if host == nil {
294- return
295- }
296-
297- evt := buildMouseEvent(x, y, mods, ib.scale)
298- mouseLeave := int32(0)
299- if leave {
300- mouseLeave = 1
301- }
302- host.SendMouseMoveEvent(&evt, mouseLeave)
303-}
304-
305-func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount int) {
306- ib.mu.Lock()
307- host := ib.host
308- middleCB := ib.onMiddleClick
309- ib.mu.Unlock()
310- if host == nil {
311- return
312- }
313-
314- // Middle-click on a link opens in a new tab instead of sending to CEF.
315- // The empty string parameter is unused — the callback closure resolves
316- // the actual URI via wv.lastHoverURI at invocation time.
317- if button == 2 && middleCB != nil {
318- go middleCB("")
319- return
320- }
321-
322- // Reassert windowless browser focus before forwarding left-clicks. GTK may
323- // still consider the GLArea focused while Chromium forgot that state after a
324- // navigation or DOM transition, which prevents the caret from painting.
325- if button == 1 {
326- syncWindowlessBrowserFocus(host)
327- }
328-
329- evt := buildMouseEvent(x, y, mods, ib.scale)
330- btn := translateMouseButton(button)
331- host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
332-}
333-
334-func (ib *inputBridge) onMouseRelease(x, y float64, button, mods uint, clickCount int) {
335- ib.mu.Lock()
336- host := ib.host
337- ib.mu.Unlock()
338- if host == nil {
339- return
340- }
341-
342- evt := buildMouseEvent(x, y, mods, ib.scale)
343- btn := translateMouseButton(button)
344- host.SendMouseClickEvent(&evt, btn, 1, int32(clickCount))
345-}
346-
347-func (ib *inputBridge) onScroll(dx, dy float64) {
348- ib.mu.Lock()
349- host := ib.host
350- x, y := ib.lastX, ib.lastY
351- ib.mu.Unlock()
352- if host == nil {
353- return
354- }
355-
356- evt := buildMouseEvent(x, y, 0, ib.scale)
357- deltaX, deltaY := translateScrollDeltas(dx, dy)
358- host.SendMouseWheelEvent(&evt, deltaX, deltaY)
359-}
360-
361-func (ib *inputBridge) onFocusIn() {
362- ib.mu.Lock()
363- host := ib.host
364- ib.mu.Unlock()
365- syncWindowlessBrowserFocus(host)
366-}
367-
368-func (ib *inputBridge) onFocusOut() {
369- ib.mu.Lock()
370- host := ib.host
371- ib.mu.Unlock()
372- if host == nil {
373- return
374- }
375- host.SetFocus(0)
376-}
377-
378-func (ib *inputBridge) hasGTKFocus() bool {
379- if ib == nil {
380- return false
381- }
382- return ib.gtkFocused.Load()
383-}
384-
385-func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) {
386- action, ok := clipboardShortcutAction(keyval, mods)
387- if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil {
388- return
389- }
390- text := ib.selectionText()
391- if text == "" {
392- return
393- }
394- logging.FromContext(ib.ctx).Debug().
395- Str("action", action).
396- Int("text_len", utf8.RuneCountInString(text)).
397- Msg("cef: clipboard shortcut routed through explicit copy handler")
398- ib.explicitCopyText(action, text)
399-}
400-
401-func clipboardShortcutAction(keyval, mods uint) (string, bool) {
402- if mods&uint(gdk.ControlMaskValue) == 0 {
403- return "", false
404- }
405- if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
406- return "", false
407- }
408- switch keyval {
409- case gdkKeyLowercaseC, gdkKeyUppercaseC:
410- return "copy", true
411- case gdkKeyLowercaseX, gdkKeyUppercaseX:
412- return "cut", true
413- default:
414- return "", false
415- }
416-}
417-
418-func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
419- ib.mu.Lock()
420- host := ib.host
421- ib.mu.Unlock()
422- if host == nil {
423- return
424- }
425-
426- // Dead keys should have been absorbed by the IMContext. If one leaks
427- // through (e.g. no IMContext), drop it to avoid sending a bogus VK=0
428- // RAWKEYDOWN that confuses CEF.
429- if keyval >= gdkDeadKeyStart && keyval <= gdkDeadKeyEnd {
430- return
431- }
432-
433- // Send RAWKEYDOWN first, then CHAR if printable.
434- evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown)
435- host.SendKeyEvent(&evt)
436-
437- // Follow up with a CHAR event for printable characters.
438- if ch := keyvalToChar(keyval); ch != 0 {
439- charEvt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventChar)
440- // CHAR events use the character code as WindowsKeyCode (not the VK code).
441- charEvt.WindowsKeyCode = int32(ch)
442- charEvt.Character = ch
443- charEvt.UnmodifiedCharacter = ch
444- host.SendKeyEvent(&charEvt)
445- }
446-}
447-
448-// onIMCommit is called when the IMContext finishes a compose sequence and
449-// commits text (e.g. dead_acute + e → "é"). We send a CHAR event for each
450-// rune to CEF. No RAWKEYDOWN is needed for composed text — this matches
451-// Chromium's internal IME integration behavior on Linux.
452-func (ib *inputBridge) onIMCommit(text string) {
453- ib.mu.Lock()
454- host := ib.host
455- ib.mu.Unlock()
456- if host == nil {
457- return
458- }
459-
460- for _, r := range text {
461- if r > maxBMPCodepoint {
462- // Non-BMP character (emoji, etc.) — skip for now.
463- // Full UTF-16 surrogate pair support can be added later.
464- continue
465- }
466- ch := uint16(r)
467- evt := purecef.NewKeyEvent()
468- evt.Type = purecef.KeyEventTypeKeyeventChar
469- evt.WindowsKeyCode = int32(ch)
470- evt.Character = ch
471- evt.UnmodifiedCharacter = ch
472- host.SendKeyEvent(&evt)
473- }
474-}
475-
476-func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) {
477- ib.mu.Lock()
478- host := ib.host
479- ib.mu.Unlock()
480- if host == nil {
481- return
482- }
483-
484- evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventKeyup)
485- host.SendKeyEvent(&evt)
486-}
487-
488-// pasteFromClipboard reads text from the GDK clipboard asynchronously and
489-// injects it into the focused CEF input via document.execCommand('insertText').
490-// ImeCommitText only works during active IME compositions, so we use JS
491-// injection which works for any focused editable element.
492-func (ib *inputBridge) pasteFromClipboard() {
493- log := logging.FromContext(ib.ctx)
494-
495- cb := ib.clipboard
496- if cb == nil {
497- log.Warn().Msg("cef: paste aborted — no GDK clipboard reference")
498- return
499- }
500- ib.mu.Lock()
501- host := ib.host
502- ib.mu.Unlock()
503- if host == nil {
504- log.Warn().Msg("cef: paste aborted — no browser host")
505- return
506- }
507-
508- log.Debug().Msg("cef: calling ReadTextAsync on GDK clipboard")
509-
510- asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) {
511- log.Debug().Uint64("result_ptr", uint64(resultPtr)).Msg("cef: ReadTextAsync callback fired")
512-
513- result := &gio.AsyncResultBase{Ptr: resultPtr}
514- text, err := cb.ReadTextFinish(result)
515- if err != nil {
516- log.Warn().Err(err).Msg("cef: ReadTextFinish failed")
517- return
518- }
519- if text == "" {
520- log.Debug().Msg("cef: clipboard text is empty, nothing to paste")
521- return
522- }
523-
524- log.Debug().Int("text_len", len(text)).Msg("cef: injecting clipboard text via JS execCommand")
525-
526- ib.mu.Lock()
527- h := ib.host
528- ib.mu.Unlock()
529- if h == nil {
530- log.Warn().Msg("cef: paste — host became nil before JS injection")
531- return
532- }
533-
534- browser := h.GetBrowser()
535- if browser == nil {
536- log.Warn().Msg("cef: paste — GetBrowser returned nil")
537- return
538- }
539- frame := browser.GetMainFrame()
540- if frame == nil {
541- log.Warn().Msg("cef: paste — GetMainFrame returned nil")
542- return
543- }
544-
545- // Escape the text for embedding in a JS string literal.
546- escaped := webutil.EscapeForJSString(text)
547- js := "document.execCommand('insertText',false,'" + escaped + "')"
548- frame.ExecuteJavaScript(js, "", 0)
549- log.Debug().Msg("cef: paste JS executed")
550- })
551-
552- cb.ReadTextAsync(nil, &asyncCb, 0)
553- log.Debug().Msg("cef: ReadTextAsync dispatched")
554-}
555-
556-// ---------------------------------------------------------------------------
557-// Translation helpers
558-// ---------------------------------------------------------------------------
559-
560-func buildMouseEvent(x, y float64, gdkMods uint, scale int32) purecef.MouseEvent {
561- return purecef.MouseEvent{
562- X: int32(x * float64(scale)),
563- Y: int32(y * float64(scale)),
564- Modifiers: translateModifiers(gdkMods),
565- }
566-}
567-
568-func buildKeyEvent(keyval, keycode, gdkMods uint, eventType purecef.KeyEventType) purecef.KeyEvent {
569- evt := purecef.NewKeyEvent()
570- evt.Type = eventType
571- evt.WindowsKeyCode = gdkKeyvalToWindowsVK(keyval)
572- evt.NativeKeyCode = int32(keycode)
573- evt.Modifiers = translateModifiers(gdkMods)
574- return evt
575-}
576-
577-// translateModifiers maps GDK modifier masks to CEF EventFlags values.
578-func translateModifiers(gdkState uint) uint32 {
579- var flags uint32
580-
581- if gdkState&uint(gdk.ShiftMaskValue) != 0 {
582- flags |= uint32(purecef.EventFlagsEventflagShiftDown)
583- }
584- if gdkState&uint(gdk.ControlMaskValue) != 0 {
585- flags |= uint32(purecef.EventFlagsEventflagControlDown)
586- }
587- if gdkState&uint(gdk.AltMaskValue) != 0 {
588- flags |= uint32(purecef.EventFlagsEventflagAltDown)
589- }
590- if gdkState&uint(gdk.Button1MaskValue) != 0 {
591- flags |= uint32(purecef.EventFlagsEventflagLeftMouseButton)
592- }
593- if gdkState&uint(gdk.Button2MaskValue) != 0 {
594- flags |= uint32(purecef.EventFlagsEventflagMiddleMouseButton)
595- }
596- if gdkState&uint(gdk.Button3MaskValue) != 0 {
597- flags |= uint32(purecef.EventFlagsEventflagRightMouseButton)
598- }
599-
600- return flags
601-}
602-
603-// translateMouseButton maps a GDK button number to a CEF MouseButtonType.
604-func translateMouseButton(gdkButton uint) purecef.MouseButtonType {
605- switch gdkButton {
606- case 1:
607- return purecef.MouseButtonTypeMbtLeft
608- case 2:
609- return purecef.MouseButtonTypeMbtMiddle
610- case 3:
611- return purecef.MouseButtonTypeMbtRight
612- default:
613- return purecef.MouseButtonTypeMbtLeft
614- }
615-}
616-
617-// cefScrollUnitsPerNotch is the number of scroll units CEF expects per mouse wheel notch.
618-const cefScrollUnitsPerNotch = 120
619-
620-// translateScrollDeltas converts GDK scroll deltas to CEF units.
621-func translateScrollDeltas(dx, dy float64) (int32, int32) {
622- return int32(dx * cefScrollUnitsPerNotch), int32(-dy * cefScrollUnitsPerNotch) // CEF: positive Y = scroll up
623-}
624-
625-// GDK keyval constants for special keys.
626-const (
627- gdkKeyReturn = 0xff0d
628- gdkKeyTab = 0xff09
629- gdkKeyBackSpace = 0xff08
630- gdkKeyEscape = 0xff1b
631- gdkKeyDelete = 0xffff
632- gdkKeySpace = 0x020
633- gdkKeyHome = 0xff50
634- gdkKeyEnd = 0xff57
635- gdkKeyPageUp = 0xff55
636- gdkKeyPageDown = 0xff56
637-
638- // Clipboard shortcut keyvals.
639- gdkKeyLowercaseC = 0x063
640- gdkKeyUppercaseC = 0x043
641- gdkKeyLowercaseV = 0x076
642- gdkKeyUppercaseV = 0x056
643- gdkKeyLowercaseX = 0x078
644- gdkKeyUppercaseX = 0x058
645-)
646-
647-// keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
648-// Returns 0 for non-printable keys (arrows, F-keys, modifiers, dead keys, …).
649-// Uses gdk.KeyvalToUnicode for full Unicode coverage (accented Latin, Cyrillic, …).
650-func keyvalToChar(keyval uint) uint16 {
651- switch keyval {
652- case gdkKeyReturn:
653- return '\r'
654- case gdkKeyTab:
655- return '\t'
656- case gdkKeyBackSpace:
657- return '\b'
658- }
659-
660- cp := gdk.KeyvalToUnicode(keyval)
661- if cp == 0 || cp > maxBMPCodepoint {
662- return 0
663- }
664- // Filter control characters (except the three handled above).
665- if cp < minPrintable {
666- return 0
667- }
668- return uint16(cp)
669-}
670-
671-// gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code.
672-func gdkKeyvalToWindowsVK(keyval uint) int32 {
673- if vk, ok := gdkKeyvalToVKRange(keyval); ok {
674- return vk
675- }
676- return gdkKeyvalToVKLookup(keyval)
677-}
678-
679-// GDK keyval range boundaries for contiguous mappings.
680-const (
681- gdkKeyLowercaseAStart = 0x061
682- gdkKeyLowercaseAEnd = 0x07a
683- gdkKeyUppercaseAStart = 0x041
684- gdkKeyUppercaseAEnd = 0x05a
685- gdkKeyDigit0Start = 0x030
686- gdkKeyDigit9End = 0x039
687- gdkKeyF1Start = 0xffbe
688- gdkKeyF12End = 0xffc9
689- gdkKeyArrowStart = 0xff51
690- gdkKeyArrowEnd = 0xff54
691-
692- vkA = 0x41
693- vkF1 = 0x70
694- vkArrowLeft = 0x25
695-)
696-
697-// gdkKeyvalToVKRange handles contiguous GDK keyval ranges that map linearly
698-// to Windows virtual-key codes (letters, digits, F-keys, arrows).
699-func gdkKeyvalToVKRange(keyval uint) (int32, bool) {
700- switch {
701- // Lowercase letters -> uppercase VK codes (A-Z = 0x41-0x5A)
702- case keyval >= gdkKeyLowercaseAStart && keyval <= gdkKeyLowercaseAEnd:
703- return int32(keyval-gdkKeyLowercaseAStart) + vkA, true
704- // Uppercase letters
705- case keyval >= gdkKeyUppercaseAStart && keyval <= gdkKeyUppercaseAEnd:
706- return int32(keyval-gdkKeyUppercaseAStart) + vkA, true
707- // Digits 0-9 = 0x30-0x39
708- case keyval >= gdkKeyDigit0Start && keyval <= gdkKeyDigit9End:
709- return int32(keyval), true
710- // F1-F12: GDK 0xffbe-0xffc9 -> VK 0x70-0x7B
711- case keyval >= gdkKeyF1Start && keyval <= gdkKeyF12End:
712- return int32(keyval-gdkKeyF1Start) + vkF1, true
713- // Arrow keys: GDK 0xff51-0xff54 -> VK Left=0x25..Down=0x28
714- case keyval >= gdkKeyArrowStart && keyval <= gdkKeyArrowEnd:
715- return int32(keyval-gdkKeyArrowStart) + vkArrowLeft, true
716- default:
717- return 0, false
718- }
719-}
720-
721-// gdkKeyvalToVKMap maps individual GDK keyvals to Windows virtual-key codes.
722-//
723-// Punctuation ASCII values collide with Windows VK codes for navigation keys
724-// (e.g. '.' = 0x2E = VK_DELETE, '#' = 0x23 = VK_END) so they MUST be mapped
725-// explicitly. Shifted digit symbols (!, @, #, …) are mapped to their base
726-// digit VK code to avoid triggering navigation actions.
727-var gdkKeyvalToVKMap = map[uint]int32{
728- // Navigation / editing
729- gdkKeyReturn: 0x0D, // VK_RETURN
730- gdkKeyEscape: 0x1B, // VK_ESCAPE
731- gdkKeyTab: 0x09, // VK_TAB
732- gdkKeyBackSpace: 0x08, // VK_BACK
733- gdkKeyDelete: 0x2E, // VK_DELETE
734- gdkKeySpace: 0x20, // VK_SPACE
735- gdkKeyHome: 0x24, // VK_HOME
736- gdkKeyEnd: 0x23, // VK_END
737- gdkKeyPageUp: 0x21, // VK_PRIOR
738- gdkKeyPageDown: 0x22, // VK_NEXT
739- 0xff63: 0x2D, // Insert → VK_INSERT
740-
741- // Modifiers
742- 0xffe1: 0xA0, // Shift_L → VK_LSHIFT
743- 0xffe2: 0xA1, // Shift_R → VK_RSHIFT
744- 0xffe3: 0xA2, // Control_L → VK_LCONTROL
745- 0xffe4: 0xA3, // Control_R → VK_RCONTROL
746- 0xffe9: 0xA4, // Alt_L → VK_LMENU
747- 0xffea: 0xA5, // Alt_R → VK_RMENU
748-
749- // OEM punctuation — unshifted + shifted share the same VK
750- '.': 0xBE, '>': 0xBE, // VK_OEM_PERIOD
751- ',': 0xBC, '<': 0xBC, // VK_OEM_COMMA
752- '-': 0xBD, '_': 0xBD, // VK_OEM_MINUS
753- '=': 0xBB, '+': 0xBB, // VK_OEM_PLUS
754- ';': 0xBA, ':': 0xBA, // VK_OEM_1
755- '/': 0xBF, '?': 0xBF, // VK_OEM_2
756- '`': 0xC0, '~': 0xC0, // VK_OEM_3
757- '[': 0xDB, '{': 0xDB, // VK_OEM_4
758- '\\': 0xDC, '|': 0xDC, // VK_OEM_5
759- ']': 0xDD, '}': 0xDD, // VK_OEM_6
760- '\'': 0xDE, '"': 0xDE, // VK_OEM_7
761-
762- // Shifted digit symbols (Shift+1→'!' etc.)
763- '!': 0x31, '@': 0x32, '#': 0x33, '$': 0x34, '%': 0x35,
764- '^': 0x36, '&': 0x37, '*': 0x38, '(': 0x39, ')': 0x30,
765-}
766-
767-// gdkKeyvalToVKLookup handles individual GDK keyvals that don't fall into
768-// contiguous ranges (letters, digits, F-keys, arrows).
769-func gdkKeyvalToVKLookup(keyval uint) int32 {
770- if vk, ok := gdkKeyvalToVKMap[keyval]; ok {
771- return vk
772- }
773- // For unmapped keys, return the keyval if it fits in a reasonable range.
774- // This covers misc keys whose ASCII value doesn't collide with
775- // dangerous VK codes (e.g. space=0x20=VK_SPACE — correct match).
776- if keyval < maxSingleByteKeyval {
777- return int32(keyval)
778- }
779- return 0
780-}
D internal/infrastructure/cef/input_bridge_test.go +0 -85
@@ -1,85 +0,0 @@
1-package cef
2-
3-import (
4- "context"
5- "testing"
6-
7- purecef "github.com/bnema/purego-cef/cef"
8- "github.com/bnema/puregotk/v4/gdk"
9- "github.com/stretchr/testify/require"
10-)
11-
12-type focusSyncHostRecorder struct {
13- calls []string
14- hiddenValues []int32
15- focusValues []int32
16- invalidations []purecef.PaintElementType
17-}
18-
19-func (h *focusSyncHostRecorder) WasHidden(hidden int32) {
20- h.calls = append(h.calls, "WasHidden")
21- h.hiddenValues = append(h.hiddenValues, hidden)
22-}
23-
24-func (h *focusSyncHostRecorder) SetFocus(focus int32) {
25- h.calls = append(h.calls, "SetFocus")
26- h.focusValues = append(h.focusValues, focus)
27-}
28-
29-func (h *focusSyncHostRecorder) Invalidate(elementType purecef.PaintElementType) {
30- h.calls = append(h.calls, "Invalidate")
31- h.invalidations = append(h.invalidations, elementType)
32-}
33-
34-func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *testing.T) {
35- host := &focusSyncHostRecorder{}
36-
37- syncWindowlessBrowserFocus(host)
38-
39- require.Equal(t, []string{"WasHidden", "SetFocus", "Invalidate"}, host.calls)
40- require.Equal(t, []int32{0}, host.hiddenValues)
41- require.Equal(t, []int32{1}, host.focusValues)
42- require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations)
43-}
44-
45-func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) {
46- action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
47- require.True(t, ok)
48- require.Equal(t, "copy", action)
49-
50- action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue))
51- require.True(t, ok)
52- require.Equal(t, "cut", action)
53-
54- _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue))
55- require.False(t, ok)
56-}
57-
58-func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) {
59- ib := newInputBridge(context.Background(), 1)
60- ib.selectionText = func() string { return "selected text" }
61- routed := struct {
62- action string
63- text string
64- }{}
65- ib.explicitCopyText = func(action, text string) {
66- routed.action = action
67- routed.text = text
68- }
69-
70- ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
71-
72- require.Equal(t, "copy", routed.action)
73- require.Equal(t, "selected text", routed.text)
74-}
75-
76-func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) {
77- ib := newInputBridge(context.Background(), 1)
78- ib.selectionText = func() string { return "" }
79- called := false
80- ib.explicitCopyText = func(string, string) { called = true }
81-
82- ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue))
83-
84- require.False(t, called)
85-}
A internal/infrastructure/cef/render_handler_adapter.go +151 -0
@@ -0,0 +1,151 @@
1+package cef
2+
3+import (
4+ purecef "github.com/bnema/purego-cef/cef"
5+ cef2gtk "github.com/bnema/purego-cef2gtk"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+type dumberRenderHandler struct {
11+ wv *WebView
12+ delegate purecef.RenderHandler
13+}
14+
15+var _ purecef.RenderHandler = (*dumberRenderHandler)(nil)
16+
17+func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
18+ h := &dumberRenderHandler{wv: wv}
19+ if wv == nil || wv.viewBridge == nil {
20+ return h
21+ }
22+ h.delegate = wv.viewBridge.RenderHandler(cef2gtk.Hooks{
23+ OnUnsupportedPaint: func() {
24+ if wv.ctx != nil {
25+ logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")
26+ }
27+ },
28+ OnError: func(err error) {
29+ if wv.ctx != nil {
30+ logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error")
31+ }
32+ },
33+ OnTextSelectionChanged: func(selectedText string, selectedRange *purecef.Range) {
34+ h.OnTextSelectionChanged(nil, selectedText, selectedRange)
35+ },
36+ })
37+ return h
38+}
39+
40+func (h *dumberRenderHandler) GetAccessibilityHandler() purecef.AccessibilityHandler {
41+ if h.delegate != nil {
42+ return h.delegate.GetAccessibilityHandler()
43+ }
44+ return nil
45+}
46+func (h *dumberRenderHandler) GetRootScreenRect(browser purecef.Browser, rect *purecef.Rect) int32 {
47+ if h.delegate != nil {
48+ return h.delegate.GetRootScreenRect(browser, rect)
49+ }
50+ return 0
51+}
52+func (h *dumberRenderHandler) GetViewRect(browser purecef.Browser, rect *purecef.Rect) {
53+ if h.delegate != nil {
54+ h.delegate.GetViewRect(browser, rect)
55+ return
56+ }
57+ if rect != nil {
58+ rect.X, rect.Y, rect.Width, rect.Height = 0, 0, 1, 1
59+ }
60+}
61+func (h *dumberRenderHandler) GetScreenPoint(browser purecef.Browser, viewX, viewY int32, screenX, screenY *int32) int32 {
62+ if h.delegate != nil {
63+ return h.delegate.GetScreenPoint(browser, viewX, viewY, screenX, screenY)
64+ }
65+ return 0
66+}
67+func (h *dumberRenderHandler) GetScreenInfo(browser purecef.Browser, info *purecef.ScreenInfo) int32 {
68+ if h.delegate != nil {
69+ return h.delegate.GetScreenInfo(browser, info)
70+ }
71+ return 0
72+}
73+func (h *dumberRenderHandler) OnPopupShow(browser purecef.Browser, show int32) {
74+ if h.delegate != nil {
75+ h.delegate.OnPopupShow(browser, show)
76+ }
77+}
78+func (h *dumberRenderHandler) OnPopupSize(browser purecef.Browser, rect *purecef.Rect) {
79+ if h.delegate != nil {
80+ h.delegate.OnPopupSize(browser, rect)
81+ }
82+}
83+func (h *dumberRenderHandler) OnPaint(browser purecef.Browser, elementType purecef.PaintElementType, dirtyRects []purecef.Rect, buffer []byte, width, height int32) {
84+ if h.delegate != nil {
85+ h.delegate.OnPaint(browser, elementType, dirtyRects, buffer, width, height)
86+ }
87+}
88+func (h *dumberRenderHandler) OnAcceleratedPaint(browser purecef.Browser, elementType purecef.PaintElementType, dirtyRects []purecef.Rect, info *purecef.AcceleratedPaintInfo) {
89+ if h.delegate != nil {
90+ h.delegate.OnAcceleratedPaint(browser, elementType, dirtyRects, info)
91+ }
92+}
93+func (h *dumberRenderHandler) GetTouchHandleSize(browser purecef.Browser, orientation purecef.HorizontalAlignment, size *purecef.Size) {
94+ if h.delegate != nil {
95+ h.delegate.GetTouchHandleSize(browser, orientation, size)
96+ }
97+}
98+func (h *dumberRenderHandler) OnTouchHandleStateChanged(browser purecef.Browser, state *purecef.TouchHandleState) {
99+ if h.delegate != nil {
100+ h.delegate.OnTouchHandleStateChanged(browser, state)
101+ }
102+}
103+func (h *dumberRenderHandler) StartDragging(browser purecef.Browser, dragData purecef.DragData, allowedOps purecef.DragOperationsMask, x, y int32) int32 {
104+ if h.delegate != nil {
105+ return h.delegate.StartDragging(browser, dragData, allowedOps, x, y)
106+ }
107+ return 0
108+}
109+func (h *dumberRenderHandler) UpdateDragCursor(browser purecef.Browser, operation purecef.DragOperationsMask) {
110+ if h.delegate != nil {
111+ h.delegate.UpdateDragCursor(browser, operation)
112+ }
113+}
114+func (h *dumberRenderHandler) OnScrollOffsetChanged(browser purecef.Browser, x, y float64) {
115+ if h.delegate != nil {
116+ h.delegate.OnScrollOffsetChanged(browser, x, y)
117+ }
118+}
119+func (h *dumberRenderHandler) OnImeCompositionRangeChanged(browser purecef.Browser, selectedRange *purecef.Range, characterBounds []purecef.Rect) {
120+ if h.delegate != nil {
121+ h.delegate.OnImeCompositionRangeChanged(browser, selectedRange, characterBounds)
122+ }
123+}
124+func (h *dumberRenderHandler) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) {
125+ if h == nil || h.wv == nil {
126+ return
127+ }
128+ previous, changed := h.wv.setSelectedText(selectedText)
129+ if !changed {
130+ return
131+ }
132+ if h.wv.ctx != nil && selectedText == "" {
133+ if previous != "" {
134+ logging.FromContext(h.wv.ctx).Debug().
135+ Int("prev_text_len", len(previous)).
136+ Msg("cef: text selection cleared")
137+ }
138+ } else if h.wv.ctx != nil {
139+ logging.FromContext(h.wv.ctx).Debug().
140+ Int("text_len", len(selectedText)).
141+ Msg("cef: text selection changed")
142+ }
143+ if h.wv.engine != nil {
144+ h.wv.scheduleSelectionUpdate(selectedText)
145+ }
146+}
147+func (h *dumberRenderHandler) OnVirtualKeyboardRequested(browser purecef.Browser, mode purecef.TextInputMode) {
148+ if h.delegate != nil {
149+ h.delegate.OnVirtualKeyboardRequested(browser, mode)
150+ }
151+}
D internal/infrastructure/cef/render_pipeline.go +0 -1384
@@ -1,1384 +0,0 @@
1-package cef
2-
3-import (
4- "context"
5- "sync"
6- "sync/atomic"
7- "time"
8- "unsafe"
9-
10- "github.com/bnema/dumber/internal/application/port"
11- "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/puregotk/v4/gdk"
13- "github.com/bnema/puregotk/v4/gtk"
14-)
15-
16-// rect is a simple rectangle for dirty rect tracking.
17-type rect struct {
18- X, Y, Width, Height int32
19-}
20-
21-// renderPipeline implements PBO double-buffered OpenGL rendering of CEF's
22-// off-screen BGRA pixel buffers into a GtkGLArea widget.
23-//
24-// CEF delivers frames via OnPaint (BGRA buffer + dirty rects). handlePaint
25-// copies dirty regions into a CPU-side staging buffer on the CEF UI thread,
26-// and GTK later uploads from staging → PBO → texture before drawing a
27-// fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap.
28-type renderPipeline struct {
29- webviewID port.WebViewID
30- ctx context.Context
31- gl *glLoader
32- glArea *gtk.GLArea
33-
34- // GL resources (created on first render).
35- texture uint32
36- popupTexture uint32
37- pbo [2]uint32
38- pboIndex int
39- program uint32
40- vao uint32
41- vbo uint32
42-
43- // Surface dimensions (in device pixels, i.e. scaled).
44- // Atomics allow lock-free reads from CEF callbacks (viewRectSize).
45- widthAtomic atomic.Int32
46- heightAtomic atomic.Int32
47- scale int32
48-
49- // Staging: handlePaint copies dirty rects here, render signal uploads.
50- mu sync.Mutex
51- staging []byte
52- dirtyRects []rect
53- needsUpload bool
54- sizeChanged bool
55- forceFullUpload bool
56-
57- // Popup staging for native CEF widgets like <select> menus.
58- popupVisible bool
59- popupRect rect
60- popupStaging []byte
61- popupWidth int32
62- popupHeight int32
63- popupNeedsUpload bool
64- popupSizeChanged bool
65-
66- // GL initialized flag.
67- glReady bool
68- glWidth int32
69- glHeight int32
70-
71- // Diagnostics can be updated from both the CEF UI thread and GTK thread.
72- diagMu sync.Mutex
73- viewRectSeq atomic.Uint64
74- screenInfoSeq atomic.Uint64
75- paintSeq atomic.Uint64
76- resizeSeq atomic.Uint64
77- lastQueuedPaintSeq atomic.Uint64
78- lastResizeUnixNano atomic.Int64
79- lastResizePaintLogSeq atomic.Uint64
80- lastResizeStaleLogSeq atomic.Uint64
81- glRenderSeq atomic.Uint64
82- paintCount uint64
83- acceleratedPaintCount uint64
84- queueRenderCount uint64
85- glRenderCount uint64
86- uploadCount uint64
87- fullUploadCount uint64
88- paintBytes uint64
89- uploadBytes uint64
90- dirtyRectCount uint64
91- paintCopyTotalNs uint64
92- uploadTotalNs uint64
93- renderTotalNs uint64
94- maxPaintCopyNs int64
95- maxUploadNs int64
96- maxRenderNs int64
97- diagLastLogAt time.Time
98- diagLastPaintCount uint64
99- diagLastAccelCount uint64
100- diagLastQueueCount uint64
101- diagLastRenderCount uint64
102- diagLastUploadCount uint64
103- diagLastFullUploads uint64
104- diagLastPaintBytes uint64
105- diagLastUploadBytes uint64
106- diagLastPaintCopyNs uint64
107- diagLastUploadNs uint64
108- diagLastRenderNs uint64
109-
110- // onFirstResize is called once when the first non-zero resize occurs.
111- onFirstResize func(width, height int32)
112-
113- // onResizeCB is called on every non-zero resize after the first.
114- onResizeCB func(width, height int32)
115-}
116-
117-type renderPipelineSnapshot struct {
118- Width int32
119- Height int32
120- Scale int32
121- GLReady bool
122- NeedsUpload bool
123- SizeChanged bool
124- ForceFullUpload bool
125- ViewRectSeq uint64
126- ScreenInfoSeq uint64
127- PaintSeq uint64
128- ResizeSeq uint64
129- LastQueuedPaintSeq uint64
130- GLRenderSeq uint64
131- PaintCount uint64
132- QueueRenderCount uint64
133- RenderCount uint64
134- UploadCount uint64
135- FullUploadCount uint64
136-}
137-
138-// Shader sources for the fullscreen textured quad.
139-const vertexShaderSource = "" +
140- "#version 330 core\n" +
141- "layout(location=0) in vec2 aPos;\n" +
142- "layout(location=1) in vec2 aUV;\n" +
143- "out vec2 vUV;\n" +
144- "void main() {\n" +
145- " gl_Position = vec4(aPos, 0.0, 1.0);\n" +
146- " vUV = aUV;\n" +
147- "}\x00"
148-
149-const fragmentShaderSource = "" +
150- "#version 330 core\n" +
151- "in vec2 vUV;\n" +
152- "out vec4 fragColor;\n" +
153- "uniform sampler2D tex;\n" +
154- "void main() {\n" +
155- " fragColor = texture(tex, vUV);\n" +
156- "}\x00"
157-
158-// Fullscreen quad: position (x,y) + UV (u,v), triangle strip.
159-// UV Y is flipped because CEF's buffer origin is top-left, GL is bottom-left.
160-var quadVertices = [16]float32{
161- -1, -1, 0, 1, // bottom-left
162- 1, -1, 1, 1, // bottom-right
163- -1, 1, 0, 0, // top-left
164- 1, 1, 1, 0, // top-right
165-}
166-
167-// newRenderPipeline creates a GtkGLArea and wires up the render and resize
168-// signals. The returned pipeline is ready to receive handlePaint calls.
169-func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32, webviewID port.WebViewID) *renderPipeline {
170- if scale < 1 {
171- scale = 1
172- }
173-
174- rp := &renderPipeline{
175- webviewID: webviewID,
176- ctx: ctx,
177- gl: gl,
178- scale: scale,
179- }
180-
181- rp.glArea = gtk.NewGLArea()
182- rp.glArea.SetRequiredVersion(3, 3)
183- rp.glArea.SetAutoRender(false)
184- rp.glArea.SetHasDepthBuffer(false)
185- rp.glArea.SetHasStencilBuffer(false)
186-
187- // Wire signals. puregotk currently exposes the render context as a raw pointer.
188- renderCb := func(_ gtk.GLArea, contextPtr uintptr) bool {
189- _ = gdk.GLContextNewFromInternalPtr(contextPtr)
190- return rp.onGLRender()
191- }
192- rp.glArea.ConnectRender(&renderCb)
193-
194- resizeCb := func(_ gtk.GLArea, w int, h int) {
195- rp.onResize(int32(w), int32(h))
196- }
197- rp.glArea.ConnectResize(&resizeCb)
198-
199- return rp
200-}
201-
202-// handlePaint is called from CEF's OnPaint callback. It copies dirty rect
203-// regions from the transient CEF buffer into the persistent staging buffer and
204-// marks the next GTK render for upload.
205-func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {
206- if len(buffer) == 0 || width <= 0 || height <= 0 {
207- return
208- }
209-
210- startedAt := time.Now()
211- rp.lastQueuedPaintSeq.Store(paintSeq)
212- if rp.ctx != nil {
213- logging.FromContext(rp.ctx).Trace().
214- Uint64("paint_seq", paintSeq).
215- Int32("width", width).
216- Int32("height", height).
217- Int("rect_count", len(rects)).
218- Int("buffer_len", len(buffer)).
219- Msg("cef: handlePaint begin")
220- }
221- rp.mu.Lock()
222-
223- bufSize, ok := bgraBufferSize(width, height)
224- if !ok {
225- rp.mu.Unlock()
226- return
227- }
228- copiedBytes := uint64(0)
229- sizeChanged := false
230-
231- // Detect size change, or first paint (staging not yet allocated).
232- if stagingNeedsReset(rp.staging, rp.widthAtomic.Load(), rp.heightAtomic.Load(), width, height, bufSize) {
233- rp.widthAtomic.Store(width)
234- rp.heightAtomic.Store(height)
235- rp.staging = make([]byte, bufSize)
236- rp.sizeChanged = true
237- rp.forceFullUpload = true
238- sizeChanged = true
239- // On size change, copy the entire buffer.
240- copiedBytes = uint64(copy(rp.staging, buffer))
241- } else {
242- // Copy only dirty rect rows.
243- var truncated bool
244- copiedBytes, rects, truncated = copyDirtyRectsIntoStaging(rp.staging, buffer, width, height, rects)
245- if truncated && rp.ctx != nil {
246- logging.FromContext(rp.ctx).Warn().
247- Uint64("paint_seq", paintSeq).
248- Int32("width", width).
249- Int32("height", height).
250- Int("buffer_len", len(buffer)).
251- Int("expected_len", bufSize).
252- Msg("cef: truncated paint buffer while copying dirty rects")
253- }
254- }
255-
256- // Accumulate dirty rects across multiple OnPaint calls between renders.
257- // If we replaced instead of appending, paints that arrive before the
258- // next GL render would lose their dirty rect metadata — the pixels are
259- // in staging but never uploaded to the texture, causing trails/ghosts.
260- rp.dirtyRects = append(rp.dirtyRects, rects...)
261- rp.dirtyRects = coalesceDirtyRects(rp.dirtyRects, rp.widthAtomic.Load(), rp.heightAtomic.Load())
262- rp.needsUpload = true
263-
264- rp.mu.Unlock()
265-
266- copyDuration := time.Since(startedAt)
267- rp.diagMu.Lock()
268- rp.paintCount++
269- rp.paintBytes += copiedBytes
270- rp.dirtyRectCount += uint64(len(rects))
271- rp.paintCopyTotalNs += uint64(copyDuration)
272- if copyDuration.Nanoseconds() > rp.maxPaintCopyNs {
273- rp.maxPaintCopyNs = copyDuration.Nanoseconds()
274- }
275- rp.diagMu.Unlock()
276-
277- if rp.ctx != nil {
278- if sizeChanged && copiedBytes < uint64(bufSize) {
279- logging.FromContext(rp.ctx).Warn().
280- Uint64("paint_seq", paintSeq).
281- Int32("width", width).
282- Int32("height", height).
283- Int("buffer_len", len(buffer)).
284- Int("expected_len", bufSize).
285- Msg("cef: truncated full paint buffer on resize")
286- }
287- logging.FromContext(rp.ctx).Trace().
288- Uint64("paint_seq", paintSeq).
289- Uint64("copied_bytes", copiedBytes).
290- Bool("size_changed", sizeChanged).
291- Dur("copy_duration", copyDuration).
292- Msg("cef: handlePaint end")
293- }
294- rp.maybeLogDiagnostics()
295-}
296-
297-func (rp *renderPipeline) queuePaintRender() {
298- rp.diagMu.Lock()
299- rp.queueRenderCount++
300- rp.diagMu.Unlock()
301- rp.glArea.QueueRender()
302-}
303-
304-// onGLRender is the GTK "render" signal handler. GL context is current.
305-func (rp *renderPipeline) onGLRender() bool {
306- renderSeq := rp.glRenderSeq.Add(1)
307- paintSeq := rp.lastQueuedPaintSeq.Load()
308- renderStartedAt := time.Now()
309- if rp.ctx != nil {
310- logging.FromContext(rp.ctx).Trace().
311- Uint64("render_seq", renderSeq).
312- Uint64("paint_seq", paintSeq).
313- Msg("cef: onGLRender begin")
314- }
315- rp.mu.Lock()
316-
317- needsUpload := rp.needsUpload
318- sizeChanged := rp.sizeChanged
319- uploadWidth := rp.widthAtomic.Load()
320- uploadHeight := rp.heightAtomic.Load()
321-
322- rp.mu.Unlock()
323-
324- if rp.ctx != nil && sizeChanged && !needsUpload {
325- resizeSeq, resizeAgeMs := rp.latestResizeDiagnostics()
326- logging.FromContext(rp.ctx).Warn().
327- Uint64("webview_id", uint64(rp.webviewID)).
328- Uint64("resize_seq", resizeSeq).
329- Int64("time_since_resize_ms", resizeAgeMs).
330- Int32("upload_width", uploadWidth).
331- Int32("upload_height", uploadHeight).
332- Bool("gl_ready", rp.glReady).
333- Bool("needs_upload", needsUpload).
334- Bool("size_changed", sizeChanged).
335- Uint64("last_queued_paint_seq", paintSeq).
336- Msg("cef: render after resize without fresh paint")
337- }
338-
339- if !rp.glReady || sizeChanged {
340- rp.initGL(uploadWidth, uploadHeight)
341- }
342-
343- if !rp.glReady {
344- rp.recordGLRenderDuration(renderStartedAt)
345- return true
346- }
347-
348- if needsUpload {
349- uploadStartedAt := time.Now()
350- uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq)
351- if didUpload {
352- rp.recordGLUploadDuration(uploadStartedAt, uploadedBytes, fullUpload)
353- }
354- }
355-
356- // Draw the main view texture.
357- gl := rp.gl
358- w, h, _ := rp.viewRectSize()
359- gl.viewport(0, 0, w, h)
360- gl.clearColor(0, 0, 0, 1)
361- gl.clear(glColorBufferBit)
362- rp.drawTexture(rp.texture)
363-
364- if rp.uploadPopupTexture(renderSeq, paintSeq) {
365- rp.drawPopupOverlay(w, h)
366- }
367-
368- renderDuration := time.Since(renderStartedAt)
369- rp.recordGLRenderDuration(renderStartedAt)
370- rp.maybeLogDiagnostics()
371- if rp.ctx != nil {
372- logging.FromContext(rp.ctx).Trace().
373- Uint64("render_seq", renderSeq).
374- Uint64("paint_seq", paintSeq).
375- Dur("render_duration", renderDuration).
376- Bool("needs_upload", needsUpload).
377- Bool("size_changed", sizeChanged).
378- Msg("cef: onGLRender end")
379- }
380-
381- return true
382-}
383-
384-func (rp *renderPipeline) recordGLRenderDuration(startedAt time.Time) {
385- renderDuration := time.Since(startedAt)
386- rp.diagMu.Lock()
387- rp.glRenderCount++
388- rp.renderTotalNs += uint64(renderDuration)
389- if renderDuration.Nanoseconds() > rp.maxRenderNs {
390- rp.maxRenderNs = renderDuration.Nanoseconds()
391- }
392- rp.diagMu.Unlock()
393- rp.maybeLogDiagnostics()
394-}
395-
396-func (rp *renderPipeline) recordGLUploadDuration(startedAt time.Time, uploadedBytes uint64, fullUpload bool) {
397- uploadDuration := time.Since(startedAt)
398- rp.diagMu.Lock()
399- rp.uploadCount++
400- if fullUpload {
401- rp.fullUploadCount++
402- }
403- rp.uploadBytes += uploadedBytes
404- rp.uploadTotalNs += uint64(uploadDuration)
405- if uploadDuration.Nanoseconds() > rp.maxUploadNs {
406- rp.maxUploadNs = uploadDuration.Nanoseconds()
407- }
408- rp.diagMu.Unlock()
409-}
410-
411-func (rp *renderPipeline) drawTexture(texture uint32) {
412- if texture == 0 {
413- return
414- }
415- gl := rp.gl
416- gl.useProgram(rp.program)
417- gl.bindTexture(glTexture2D, texture)
418- gl.bindVertexArray(rp.vao)
419- gl.drawArrays(glTriangleStrip, 0, 4)
420- gl.bindVertexArray(0)
421- gl.bindTexture(glTexture2D, 0)
422- gl.useProgram(0)
423-}
424-
425-// uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
426-// transfers to the GL texture via async DMA. The mutex serializes CEF-thread
427-// staging writes against GTK-thread PBO copies so the upload sees a consistent
428-// staging buffer and dirty-rect set.
429-func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, bool) {
430- gl := rp.gl
431-
432- rp.mu.Lock()
433- w := rp.glWidth
434- h := rp.glHeight
435- needsUpload := rp.needsUpload
436- rp.mu.Unlock()
437- if !needsUpload || w <= 0 || h <= 0 {
438- return 0, false, false
439- }
440-
441- if rp.ctx != nil {
442- logging.FromContext(rp.ctx).Trace().
443- Uint64("render_seq", renderSeq).
444- Uint64("paint_seq", paintSeq).
445- Int32("width", w).
446- Int32("height", h).
447- Msg("cef: uploadToPBO begin")
448- }
449-
450- bufSize := int64(w) * int64(h) * 4
451- stride := int(w) * 4
452- uploadedBytes, fullUpload, dirtyRectCount, ok := rp.uploadMappedPBO(gl, bufSize, stride, w, h, renderSeq, paintSeq)
453- if !ok {
454- return 0, fullUpload, false
455- }
456-
457- // Swap PBO index for next frame.
458- rp.pboIndex = 1 - rp.pboIndex
459-
460- gl.bindBuffer(glPixelUnpackBuffer, 0)
461- if rp.ctx != nil {
462- logging.FromContext(rp.ctx).Trace().
463- Uint64("render_seq", renderSeq).
464- Uint64("paint_seq", paintSeq).
465- Uint64("uploaded_bytes", uploadedBytes).
466- Int("dirty_rect_count", dirtyRectCount).
467- Bool("full_upload", fullUpload).
468- Int("next_pbo_index", rp.pboIndex).
469- Msg("cef: uploadToPBO end")
470- }
471- return uploadedBytes, fullUpload, true
472-}
473-
474-func (rp *renderPipeline) uploadMappedPBO(
475- gl *glLoader, bufSize int64, stride int, w, h int32, renderSeq, paintSeq uint64,
476-) (uint64, bool, int, bool) {
477- gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[rp.pboIndex])
478- gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw)
479- mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly)
480- if mapped == nil {
481- gl.bindBuffer(glPixelUnpackBuffer, 0)
482- return 0, false, 0, false
483- }
484-
485- mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize))
486- defer gl.bindBuffer(glPixelUnpackBuffer, 0)
487-
488- rp.mu.Lock()
489- if !rp.needsUpload {
490- rp.mu.Unlock()
491- if !gl.unmapBuffer(glPixelUnpackBuffer) {
492- return 0, false, 0, false
493- }
494- return 0, false, 0, false
495- }
496-
497- if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight {
498- rp.mu.Unlock()
499- if !gl.unmapBuffer(glPixelUnpackBuffer) {
500- return 0, false, 0, false
501- }
502- if rp.ctx != nil {
503- logging.FromContext(rp.ctx).Trace().
504- Uint64("render_seq", renderSeq).
505- Uint64("paint_seq", paintSeq).
506- Msg("cef: skipping upload while newer surface resize is pending")
507- }
508- return 0, false, 0, false
509- }
510-
511- fullUpload := rp.forceFullUpload
512- dirtyRectCount, dirtyRects := len(rp.dirtyRects), make([]rect, 0, len(rp.dirtyRects))
513- if fullUpload {
514- copied := copy(mappedSlice, rp.staging)
515- if copied < len(mappedSlice) {
516- clear(mappedSlice[copied:])
517- }
518- } else {
519- dirtyRects = append(dirtyRects, rp.dirtyRects...)
520- }
521- rp.needsUpload, rp.forceFullUpload = false, false
522- rp.dirtyRects = rp.dirtyRects[:0]
523- rp.mu.Unlock()
524-
525- if !gl.unmapBuffer(glPixelUnpackBuffer) {
526- if rp.ctx != nil {
527- logging.FromContext(rp.ctx).Error().
528- Int32("width", w).
529- Int32("height", h).
530- Bool("full_upload", fullUpload).
531- Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
532- }
533- return 0, fullUpload, dirtyRectCount, false
534- }
535-
536- gl.bindTexture(glTexture2D, rp.texture)
537- if fullUpload {
538- gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil)
539- gl.bindTexture(glTexture2D, 0)
540- return uint64(len(mappedSlice)), true, dirtyRectCount, true
541- }
542-
543- gl.pixelStorei(glUnpackRowLength, w)
544- uploadedBytes := copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects)
545- for _, rawRect := range dirtyRects {
546- r, ok := clampDirtyRect(rawRect, w, h)
547- if !ok {
548- continue
549- }
550- offset := uintptr(int(r.Y)*stride + int(r.X)*4)
551- gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,
552- glBGRA, glUnsignedByte, pboOffset(offset))
553- }
554- gl.pixelStorei(glUnpackRowLength, 0)
555- gl.bindTexture(glTexture2D, 0)
556- return uploadedBytes, false, dirtyRectCount, true
557-}
558-
559-func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool {
560- // Copy staging data under lock, then release to avoid holding the mutex
561- // during GL upload calls which may block on GPU synchronization.
562- rp.mu.Lock()
563-
564- if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 {
565- rp.mu.Unlock()
566- return false
567- }
568-
569- if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 {
570- rp.mu.Unlock()
571- return true
572- }
573-
574- // Snapshot the staging data and dimensions while holding the lock.
575- pixelsCopy := make([]byte, len(rp.popupStaging))
576- copy(pixelsCopy, rp.popupStaging)
577- popupW := rp.popupWidth
578- popupH := rp.popupHeight
579- sizeChanged := rp.popupSizeChanged
580- needsCreate := rp.popupTexture == 0
581-
582- rp.mu.Unlock()
583-
584- // Perform GL operations without the lock.
585- gl := rp.gl
586-
587- if needsCreate {
588- var tex uint32
589- gl.genTextures(1, &tex)
590- gl.bindTexture(glTexture2D, tex)
591- gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
592- gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
593- gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
594- gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
595-
596- // Store the texture ID under lock.
597- rp.mu.Lock()
598- rp.popupTexture = tex
599- rp.mu.Unlock()
600- } else {
601- rp.mu.Lock()
602- tex := rp.popupTexture
603- rp.mu.Unlock()
604- gl.bindTexture(glTexture2D, tex)
605- }
606-
607- pixels := unsafe.Pointer(&pixelsCopy[0])
608- if sizeChanged {
609- gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels)
610- } else {
611- gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels)
612- }
613- gl.bindTexture(glTexture2D, 0)
614-
615- // Update state under lock.
616- rp.mu.Lock()
617- rp.popupNeedsUpload = false
618- rp.popupSizeChanged = false
619- rp.mu.Unlock()
620-
621- if rp.ctx != nil {
622- logging.FromContext(rp.ctx).Debug().
623- Uint64("render_seq", renderSeq).
624- Uint64("paint_seq", paintSeq).
625- Int32("popup_width", popupW).
626- Int32("popup_height", popupH).
627- Msg("cef: popup texture uploaded")
628- }
629-
630- return true
631-}
632-
633-func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) {
634- rp.mu.Lock()
635- popupVisible := rp.popupVisible
636- popupRect := rp.popupRect
637- popupWidth := rp.popupWidth
638- popupHeight := rp.popupHeight
639- texture := rp.popupTexture
640- rp.mu.Unlock()
641-
642- if !popupVisible || texture == 0 || popupWidth <= 0 || popupHeight <= 0 || viewWidth <= 0 || viewHeight <= 0 {
643- return
644- }
645-
646- popupRect.Width = popupWidth
647- popupRect.Height = popupHeight
648- popupRect, ok := clampDirtyRect(popupRect, viewWidth, viewHeight)
649- if !ok {
650- return
651- }
652-
653- viewportY := viewHeight - popupRect.Y - popupRect.Height
654- if viewportY < 0 {
655- viewportY = 0
656- }
657-
658- rp.gl.viewport(popupRect.X, viewportY, popupRect.Width, popupRect.Height)
659- rp.drawTexture(texture)
660- rp.gl.viewport(0, 0, viewWidth, viewHeight)
661-}
662-
663-// initGL creates or recreates all GL resources for the provided surface size.
664-func (rp *renderPipeline) initGL(width, height int32) {
665- if width <= 0 || height <= 0 {
666- return
667- }
668-
669- gl := rp.gl
670-
671- // Clean up previous resources if reinitializing.
672- if rp.glReady {
673- rp.deleteGLResources()
674- }
675-
676- rp.mu.Lock()
677- if rp.widthAtomic.Load() == width && rp.heightAtomic.Load() == height {
678- rp.sizeChanged = false
679- }
680- rp.forceFullUpload = true
681- rp.glWidth = width
682- rp.glHeight = height
683- rp.mu.Unlock()
684-
685- // Texture.
686- gl.genTextures(1, &rp.texture)
687- gl.bindTexture(glTexture2D, rp.texture)
688- gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear))
689- gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear))
690- gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge))
691- gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge))
692- gl.texImage2D(glTexture2D, 0, int32(glRGBA), width, height, 0, glBGRA, glUnsignedByte, nil)
693- gl.bindTexture(glTexture2D, 0)
694-
695- // PBOs.
696- bufSize := int64(width) * int64(height) * 4
697- gl.genBuffers(1, &rp.pbo[0])
698- gl.genBuffers(1, &rp.pbo[1])
699- for i := 0; i < 2; i++ {
700- gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[i])
701- gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw)
702- }
703- gl.bindBuffer(glPixelUnpackBuffer, 0)
704- rp.pboIndex = 0
705-
706- // Shaders.
707- rp.program = rp.buildShaderProgram()
708-
709- // VAO + VBO.
710- gl.genVertexArrays(1, &rp.vao)
711- gl.bindVertexArray(rp.vao)
712-
713- gl.genBuffers(1, &rp.vbo)
714- gl.bindBuffer(glArrayBuffer, rp.vbo)
715- gl.bufferData(glArrayBuffer, int64(len(quadVertices)*4), unsafe.Pointer(&quadVertices[0]), glStaticDraw)
716-
717- // Position attribute: location 0, 2 floats, stride 16, offset 0.
718- gl.vertexAttribPointer(0, 2, glFloat, false, 4*4, 0)
719- gl.enableVertexAttribArray(0)
720-
721- // UV attribute: location 1, 2 floats, stride 16, offset 8.
722- gl.vertexAttribPointer(1, 2, glFloat, false, 4*4, 2*4)
723- gl.enableVertexAttribArray(1)
724-
725- gl.bindBuffer(glArrayBuffer, 0)
726- gl.bindVertexArray(0)
727-
728- // Set the "tex" uniform to texture unit 0.
729- gl.useProgram(rp.program)
730- texName := []byte("tex\x00")
731- loc := gl.getUniformLocation(rp.program, &texName[0])
732- gl.uniform1i(loc, 0)
733- gl.useProgram(0)
734-
735- rp.glReady = true
736-}
737-
738-func (rp *renderPipeline) recordAcceleratedPaint() uint64 {
739- rp.diagMu.Lock()
740- rp.acceleratedPaintCount++
741- count := rp.acceleratedPaintCount
742- rp.diagMu.Unlock()
743- rp.maybeLogDiagnostics()
744- return count
745-}
746-
747-func (rp *renderPipeline) maybeLogDiagnostics() {
748- rp.diagMu.Lock()
749- defer rp.diagMu.Unlock()
750-
751- now := time.Now()
752- if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second {
753- return
754- }
755-
756- paintDelta := rp.paintCount - rp.diagLastPaintCount
757- accelDelta := rp.acceleratedPaintCount - rp.diagLastAccelCount
758- queueDelta := rp.queueRenderCount - rp.diagLastQueueCount
759- renderDelta := rp.glRenderCount - rp.diagLastRenderCount
760- uploadDelta := rp.uploadCount - rp.diagLastUploadCount
761- fullUploadDelta := rp.fullUploadCount - rp.diagLastFullUploads
762- paintBytesDelta := rp.paintBytes - rp.diagLastPaintBytes
763- uploadBytesDelta := rp.uploadBytes - rp.diagLastUploadBytes
764- paintCopyNsDelta := rp.paintCopyTotalNs - rp.diagLastPaintCopyNs
765- uploadNsDelta := rp.uploadTotalNs - rp.diagLastUploadNs
766- renderNsDelta := rp.renderTotalNs - rp.diagLastRenderNs
767- if paintDelta == 0 && accelDelta == 0 && queueDelta == 0 && renderDelta == 0 && uploadDelta == 0 {
768- return
769- }
770-
771- elapsed := now.Sub(rp.diagLastLogAt)
772- if rp.diagLastLogAt.IsZero() || elapsed <= 0 {
773- elapsed = time.Second
774- }
775- elapsedSec := elapsed.Seconds()
776-
777- paintAvgUs := float64(0)
778- if paintDelta > 0 {
779- paintAvgUs = float64(paintCopyNsDelta) / float64(paintDelta) / 1_000
780- }
781- uploadAvgUs := float64(0)
782- if uploadDelta > 0 {
783- uploadAvgUs = float64(uploadNsDelta) / float64(uploadDelta) / 1_000
784- }
785- renderAvgUs := float64(0)
786- if renderDelta > 0 {
787- renderAvgUs = float64(renderNsDelta) / float64(renderDelta) / 1_000
788- }
789-
790- logging.FromContext(rp.ctx).Debug().
791- Float64("paint_hz", float64(paintDelta)/elapsedSec).
792- Float64("queue_hz", float64(queueDelta)/elapsedSec).
793- Float64("render_hz", float64(renderDelta)/elapsedSec).
794- Float64("upload_hz", float64(uploadDelta)/elapsedSec).
795- Uint64("paint_delta", paintDelta).
796- Uint64("accelerated_paint_delta", accelDelta).
797- Uint64("queue_delta", queueDelta).
798- Uint64("render_delta", renderDelta).
799- Uint64("upload_delta", uploadDelta).
800- Uint64("full_upload_delta", fullUploadDelta).
801- Float64("paint_mb", float64(paintBytesDelta)/(1024*1024)).
802- Float64("upload_mb", float64(uploadBytesDelta)/(1024*1024)).
803- Float64("avg_paint_copy_us", paintAvgUs).
804- Float64("avg_upload_us", uploadAvgUs).
805- Float64("avg_render_us", renderAvgUs).
806- Float64("max_paint_copy_us", float64(rp.maxPaintCopyNs)/1_000).
807- Float64("max_upload_us", float64(rp.maxUploadNs)/1_000).
808- Float64("max_render_us", float64(rp.maxRenderNs)/1_000).
809- Uint64("paint_total", rp.paintCount).
810- Uint64("accelerated_paint_total", rp.acceleratedPaintCount).
811- Uint64("render_total", rp.glRenderCount).
812- Uint64("upload_total", rp.uploadCount).
813- Msg("cef: render pipeline activity")
814-
815- rp.diagLastLogAt = now
816- rp.diagLastPaintCount = rp.paintCount
817- rp.diagLastAccelCount = rp.acceleratedPaintCount
818- rp.diagLastQueueCount = rp.queueRenderCount
819- rp.diagLastRenderCount = rp.glRenderCount
820- rp.diagLastUploadCount = rp.uploadCount
821- rp.diagLastFullUploads = rp.fullUploadCount
822- rp.diagLastPaintBytes = rp.paintBytes
823- rp.diagLastUploadBytes = rp.uploadBytes
824- rp.diagLastPaintCopyNs = rp.paintCopyTotalNs
825- rp.diagLastUploadNs = rp.uploadTotalNs
826- rp.diagLastRenderNs = rp.renderTotalNs
827-}
828-
829-// buildShaderProgram compiles and links the vertex+fragment shaders.
830-func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot {
831- if rp == nil {
832- return renderPipelineSnapshot{}
833- }
834-
835- rp.diagMu.Lock()
836- snap := renderPipelineSnapshot{
837- Width: rp.widthAtomic.Load(),
838- Height: rp.heightAtomic.Load(),
839- ViewRectSeq: rp.viewRectSeq.Load(),
840- ScreenInfoSeq: rp.screenInfoSeq.Load(),
841- PaintSeq: rp.paintSeq.Load(),
842- ResizeSeq: rp.resizeSeq.Load(),
843- LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(),
844- GLRenderSeq: rp.glRenderSeq.Load(),
845- PaintCount: rp.paintCount,
846- QueueRenderCount: rp.queueRenderCount,
847- RenderCount: rp.glRenderCount,
848- UploadCount: rp.uploadCount,
849- FullUploadCount: rp.fullUploadCount,
850- }
851- rp.diagMu.Unlock()
852-
853- rp.mu.Lock()
854- snap.Scale = rp.scale
855- snap.GLReady = rp.glReady
856- snap.NeedsUpload = rp.needsUpload
857- snap.SizeChanged = rp.sizeChanged
858- snap.ForceFullUpload = rp.forceFullUpload
859- rp.mu.Unlock()
860-
861- return snap
862-}
863-
864-func (rp *renderPipeline) buildShaderProgram() uint32 {
865- gl := rp.gl
866-
867- vs := compileShader(gl, glVertexShader, vertexShaderSource)
868- fs := compileShader(gl, glFragmentShader, fragmentShaderSource)
869-
870- prog := gl.createProgram()
871- gl.attachShader(prog, vs)
872- gl.attachShader(prog, fs)
873- gl.linkProgram(prog)
874-
875- // Shaders can be deleted after linking.
876- gl.deleteShader(vs)
877- gl.deleteShader(fs)
878-
879- return prog
880-}
881-
882-// compileShader compiles a single GLSL shader and returns its GL handle.
883-// TODO: add glGetShaderiv + glGetShaderInfoLog to gl_loader to check compilation status.
884-func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
885- shader := gl.createShader(shaderType)
886-
887- // source already has null terminator.
888- srcBytes := []byte(source)
889- srcPtr := &srcBytes[0]
890- srcLen := int32(len(source) - 1) // exclude null terminator from length
891- gl.shaderSource(shader, 1, &srcPtr, &srcLen)
892- gl.compileShader(shader)
893-
894- return shader
895-}
896-
897-// onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels;
898-// we multiply by scale to get device pixels.
899-func (rp *renderPipeline) onResize(width, height int32) {
900- if width <= 0 || height <= 0 {
901- return
902- }
903-
904- rp.mu.Lock()
905-
906- prevWidth := rp.widthAtomic.Load()
907- prevHeight := rp.heightAtomic.Load()
908- scale := rp.scale
909- if scale <= 0 {
910- scale = 1
911- }
912-
913- scaled := func(v int32) int32 {
914- return v * scale
915- }
916-
917- w, h := scaled(width), scaled(height)
918- resizeSeq := rp.resizeSeq.Add(1)
919- now := time.Now()
920- rp.lastResizeUnixNano.Store(now.UnixNano())
921- rp.widthAtomic.Store(w)
922- rp.heightAtomic.Store(h)
923- rp.sizeChanged = true
924-
925- firstCB := rp.onFirstResize
926- if firstCB != nil {
927- rp.onFirstResize = nil
928- }
929- resizeCB := rp.onResizeCB
930- rp.mu.Unlock()
931-
932- if rp.ctx != nil {
933- logging.FromContext(rp.ctx).Debug().
934- Uint64("webview_id", uint64(rp.webviewID)).
935- Uint64("resize_seq", resizeSeq).
936- Int32("css_width", width).
937- Int32("css_height", height).
938- Int32("scaled_width", w).
939- Int32("scaled_height", h).
940- Int32("prev_width", prevWidth).
941- Int32("prev_height", prevHeight).
942- Int32("scale", scale).
943- Bool("first_resize", firstCB != nil).
944- Bool("has_resize_cb", resizeCB != nil).
945- Msg("cef: GLArea resize")
946- }
947-
948- if firstCB != nil {
949- firstCB(w, h)
950- } else if resizeCB != nil {
951- resizeCB(w, h)
952- }
953-}
954-
955-func (rp *renderPipeline) setPopupVisible(show bool) {
956- rp.mu.Lock()
957- rp.popupVisible = show
958- if !show {
959- rp.popupStaging = nil
960- rp.popupWidth = 0
961- rp.popupHeight = 0
962- rp.popupNeedsUpload = false
963- rp.popupSizeChanged = false
964- }
965- rp.mu.Unlock()
966- rp.glArea.QueueRender()
967-}
968-
969-func (rp *renderPipeline) setPopupRect(popup rect) {
970- rp.mu.Lock()
971- rp.popupRect = rect{
972- X: popup.X * rp.scale,
973- Y: popup.Y * rp.scale,
974- Width: popup.Width * rp.scale,
975- Height: popup.Height * rp.scale,
976- }
977- rp.mu.Unlock()
978- rp.glArea.QueueRender()
979-}
980-
981-func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, paintSeq uint64) {
982- if len(buffer) == 0 || width <= 0 || height <= 0 {
983- return
984- }
985-
986- bufSize, ok := bgraBufferSize(width, height)
987- if !ok {
988- return
989- }
990-
991- rp.mu.Lock()
992- if len(rp.popupStaging) != bufSize {
993- rp.popupStaging = make([]byte, bufSize)
994- rp.popupSizeChanged = true
995- } else if rp.popupWidth != width || rp.popupHeight != height {
996- rp.popupSizeChanged = true
997- }
998- copied := copy(rp.popupStaging, buffer)
999- if copied < len(rp.popupStaging) {
1000- clear(rp.popupStaging[copied:])
1001- }
1002- rp.popupWidth = width
1003- rp.popupHeight = height
1004- rp.popupNeedsUpload = true
1005- rp.popupVisible = true
1006- rp.mu.Unlock()
1007-
1008- if rp.ctx != nil {
1009- logging.FromContext(rp.ctx).Debug().
1010- Uint64("paint_seq", paintSeq).
1011- Int32("popup_width", width).
1012- Int32("popup_height", height).
1013- Msg("cef: handlePopupPaint")
1014- }
1015-
1016- rp.glArea.QueueRender()
1017-}
1018-
1019-func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
1020- width = rp.widthAtomic.Load()
1021- height = rp.heightAtomic.Load()
1022- scale = rp.scale
1023- if scale <= 0 {
1024- scale = 1
1025- }
1026- return width, height, scale
1027-}
1028-
1029-func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) {
1030- resizeSeq = rp.resizeSeq.Load()
1031- lastResizeUnixNano := rp.lastResizeUnixNano.Load()
1032- if lastResizeUnixNano == 0 {
1033- return resizeSeq, 0
1034- }
1035- age := time.Since(time.Unix(0, lastResizeUnixNano))
1036- if age <= 0 {
1037- return resizeSeq, 0
1038- }
1039- return resizeSeq, age.Milliseconds()
1040-}
1041-
1042-func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) {
1043- resizeSeq, ageMs = rp.latestResizeDiagnostics()
1044- if resizeSeq == 0 {
1045- return resizeSeq, ageMs, false
1046- }
1047- for {
1048- lastLogged := rp.lastResizePaintLogSeq.Load()
1049- if lastLogged >= resizeSeq {
1050- return resizeSeq, ageMs, false
1051- }
1052- if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) {
1053- return resizeSeq, ageMs, true
1054- }
1055- }
1056-}
1057-
1058-func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool {
1059- if resizeSeq == 0 {
1060- return false
1061- }
1062- for {
1063- lastLogged := rp.lastResizeStaleLogSeq.Load()
1064- if lastLogged >= resizeSeq {
1065- return false
1066- }
1067- if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) {
1068- return true
1069- }
1070- }
1071-}
1072-
1073-func (rp *renderPipeline) nextViewRectSeq() uint64 {
1074- return rp.viewRectSeq.Add(1)
1075-}
1076-
1077-func (rp *renderPipeline) nextScreenInfoSeq() uint64 {
1078- return rp.screenInfoSeq.Add(1)
1079-}
1080-
1081-func (rp *renderPipeline) nextPaintSeq() uint64 {
1082- return rp.paintSeq.Add(1)
1083-}
1084-
1085-func clampDirtyRect(r rect, width, height int32) (rect, bool) {
1086- if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 {
1087- return rect{}, false
1088- }
1089-
1090- x0 := max(r.X, 0)
1091- y0 := max(r.Y, 0)
1092- x1 := min(r.X+r.Width, width)
1093- y1 := min(r.Y+r.Height, height)
1094-
1095- if x1 <= x0 || y1 <= y0 {
1096- return rect{}, false
1097- }
1098-
1099- return rect{
1100- X: x0,
1101- Y: y0,
1102- Width: x1 - x0,
1103- Height: y1 - y0,
1104- }, true
1105-}
1106-
1107-// destroy releases all GL resources.
1108-func (rp *renderPipeline) destroy() {
1109- if !rp.glReady {
1110- return
1111- }
1112- rp.deleteGLResources()
1113- rp.glReady = false
1114-}
1115-
1116-// deleteGLResources frees texture, PBOs, VAO, VBO, and program.
1117-func (rp *renderPipeline) deleteGLResources() {
1118- gl := rp.gl
1119- if rp.texture != 0 {
1120- gl.deleteTextures(1, &rp.texture)
1121- rp.texture = 0
1122- }
1123- if rp.popupTexture != 0 {
1124- gl.deleteTextures(1, &rp.popupTexture)
1125- rp.popupTexture = 0
1126- }
1127- for i := 0; i < 2; i++ {
1128- if rp.pbo[i] != 0 {
1129- gl.deleteBuffers(1, &rp.pbo[i])
1130- rp.pbo[i] = 0
1131- }
1132- }
1133- if rp.vao != 0 {
1134- gl.deleteVertexArrays(1, &rp.vao)
1135- rp.vao = 0
1136- }
1137- if rp.vbo != 0 {
1138- gl.deleteBuffers(1, &rp.vbo)
1139- rp.vbo = 0
1140- }
1141- if rp.program != 0 {
1142- // deleteProgram not in glLoader — just zero it. The program leaks on
1143- // reinit but that only happens on resize which is rare. We can add
1144- // glDeleteProgram to glLoader if needed.
1145- rp.program = 0
1146- }
1147- rp.glWidth = 0
1148- rp.glHeight = 0
1149-}
1150-
1151-func bgraBufferSize(width, height int32) (int, bool) {
1152- if width <= 0 || height <= 0 {
1153- return 0, false
1154- }
1155- size64 := int64(width) * int64(height) * 4
1156- if size64 <= 0 {
1157- return 0, false
1158- }
1159- return int(size64), true
1160-}
1161-
1162-// coalesceDirtyRects merges overlapping or edge-adjacent dirty rects to reduce
1163-// redundant row-by-row PBO copies. If the total dirty area exceeds 60% of the
1164-// surface, it collapses to a single full-surface rect.
1165-//
1166-// This is called inside handlePaint's mu.Lock() section so it must be fast for
1167-// small n. O(n^2) is fine since n is typically < 20.
1168-func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect {
1169- if len(rects) <= 1 {
1170- return rects
1171- }
1172-
1173- // Check if total dirty area exceeds 60% of the surface — if so,
1174- // a single full-surface upload is cheaper than per-rect copies.
1175- totalArea := int64(0)
1176- surfaceArea := int64(surfaceW) * int64(surfaceH)
1177- for _, r := range rects {
1178- totalArea += int64(r.Width) * int64(r.Height)
1179- }
1180- if surfaceArea > 0 && totalArea*100 > surfaceArea*60 {
1181- return []rect{{0, 0, surfaceW, surfaceH}}
1182- }
1183-
1184- // Merge overlapping or edge-adjacent rects. Two rects touch if their
1185- // X ranges overlap-or-touch AND Y ranges overlap-or-touch. When merged,
1186- // both are replaced with their bounding box. Repeat until stable.
1187- merged := make([]rect, len(rects))
1188- copy(merged, rects)
1189-
1190- changed := true
1191- for changed {
1192- changed = false
1193- for i := 0; i < len(merged); i++ {
1194- for j := i + 1; j < len(merged); {
1195- if !rectsTouch(merged[i], merged[j]) {
1196- j++
1197- continue
1198- }
1199- merged[i] = boundingBox(merged[i], merged[j])
1200- // Remove j by swapping with last element.
1201- merged[j] = merged[len(merged)-1]
1202- merged = merged[:len(merged)-1]
1203- changed = true
1204- }
1205- }
1206- }
1207-
1208- return merged
1209-}
1210-
1211-// rectsTouch returns true if two rects overlap or share an edge (adjacent).
1212-func rectsTouch(a, b rect) bool {
1213- // a's right edge <= b's left edge → no horizontal contact
1214- if a.X+a.Width < b.X || b.X+b.Width < a.X {
1215- return false
1216- }
1217- // a's bottom edge <= b's top edge → no vertical contact
1218- if a.Y+a.Height < b.Y || b.Y+b.Height < a.Y {
1219- return false
1220- }
1221- return true
1222-}
1223-
1224-// boundingBox returns the smallest rect containing both a and b.
1225-func boundingBox(a, b rect) rect {
1226- x := a.X
1227- if b.X < x {
1228- x = b.X
1229- }
1230- y := a.Y
1231- if b.Y < y {
1232- y = b.Y
1233- }
1234- right := a.X + a.Width
1235- if br := b.X + b.Width; br > right {
1236- right = br
1237- }
1238- bottom := a.Y + a.Height
1239- if bb := b.Y + b.Height; bb > bottom {
1240- bottom = bb
1241- }
1242- return rect{X: x, Y: y, Width: right - x, Height: bottom - y}
1243-}
1244-
1245-func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool {
1246- return staging == nil ||
1247- len(staging) != expectedBytes ||
1248- currentWidth != nextWidth ||
1249- currentHeight != nextHeight
1250-}
1251-
1252-func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rect) (uint64, []rect, bool) {
1253- if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
1254- return 0, nil, false
1255- }
1256-
1257- stride := int(width) * 4
1258- copiedBytes := uint64(0)
1259- truncated := false
1260- sanitized := make([]rect, 0, len(rects))
1261-
1262- for _, rawRect := range rects {
1263- r, ok := clampDirtyRect(rawRect, width, height)
1264- if !ok {
1265- continue
1266- }
1267- sanitized = append(sanitized, r)
1268-
1269- for row := r.Y; row < r.Y+r.Height; row++ {
1270- dstOff := int(row)*stride + int(r.X)*4
1271- if dstOff < 0 || dstOff >= len(dst) {
1272- truncated = true
1273- break
1274- }
1275-
1276- rowBytes := int(r.Width) * 4
1277- dstEnd := dstOff + rowBytes
1278- if dstEnd > len(dst) {
1279- dstEnd = len(dst)
1280- truncated = true
1281- }
1282-
1283- srcOff := int(row)*stride + int(r.X)*4
1284- if srcOff < 0 || srcOff >= len(src) {
1285- clear(dst[dstOff:dstEnd])
1286- truncated = true
1287- continue
1288- }
1289-
1290- srcEnd := srcOff + rowBytes
1291- if srcEnd > len(src) {
1292- srcEnd = len(src)
1293- truncated = true
1294- }
1295-
1296- copyLen := srcEnd - srcOff
1297- maxDstLen := dstEnd - dstOff
1298- if copyLen > maxDstLen {
1299- copyLen = maxDstLen
1300- truncated = true
1301- }
1302-
1303- if copyLen > 0 {
1304- copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1305- copiedBytes += uint64(copyLen)
1306- }
1307-
1308- if dstOff+copyLen < dstEnd {
1309- clear(dst[dstOff+copyLen : dstEnd])
1310- }
1311- }
1312- }
1313-
1314- return copiedBytes, sanitized, truncated
1315-}
1316-
1317-// copyDirtyRectsRaw copies only the rows covered by dirty rects from src into
1318-// dst at matching offsets. Both src and dst must be BGRA buffers of dimensions
1319-// width x height. Unlike copyDirtyRectsIntoStaging, the destination buffer's
1320-// existing data outside dirty rect rows is left untouched — the caller must
1321-// ensure that only the copied rows will be read downstream.
1322-//
1323-// On a full-surface dirty rect (e.g. resize), this degrades to copying all
1324-// rows — equivalent to a full buffer copy.
1325-//
1326-// Returns the total bytes copied.
1327-func copyDirtyRectsRaw(dst, src []byte, width, height int32, rects []rect) uint64 {
1328- if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 {
1329- return 0
1330- }
1331-
1332- stride := int(width) * 4
1333- copiedBytes := uint64(0)
1334-
1335- for _, rawRect := range rects {
1336- r, ok := clampDirtyRect(rawRect, width, height)
1337- if !ok {
1338- continue
1339- }
1340-
1341- for row := r.Y; row < r.Y+r.Height; row++ {
1342- dstOff := int(row)*stride + int(r.X)*4
1343- if dstOff < 0 || dstOff >= len(dst) {
1344- break
1345- }
1346-
1347- rowBytes := int(r.Width) * 4
1348- dstEnd := dstOff + rowBytes
1349- if dstEnd > len(dst) {
1350- dstEnd = len(dst)
1351- }
1352-
1353- srcOff := int(row)*stride + int(r.X)*4
1354- if srcOff < 0 || srcOff >= len(src) {
1355- continue
1356- }
1357-
1358- srcEnd := srcOff + rowBytes
1359- if srcEnd > len(src) {
1360- srcEnd = len(src)
1361- }
1362-
1363- copyLen := srcEnd - srcOff
1364- maxDstLen := dstEnd - dstOff
1365- if copyLen > maxDstLen {
1366- copyLen = maxDstLen
1367- }
1368-
1369- if copyLen > 0 {
1370- copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen])
1371- copiedBytes += uint64(copyLen)
1372- }
1373- }
1374- }
1375-
1376- return copiedBytes
1377-}
1378-
1379-// pboOffset converts a byte offset into an unsafe.Pointer for use with
1380-// glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a
1381-// byte offset into the bound buffer rather than a real memory address.
1382-func pboOffset(offset uintptr) unsafe.Pointer {
1383- return unsafe.Add(nil, offset)
1384-}
D internal/infrastructure/cef/render_pipeline_test.go +0 -271
@@ -1,271 +0,0 @@
1-package cef
2-
3-import (
4- "bytes"
5- "context"
6- "sort"
7- "testing"
8-
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestStagingNeedsResetWhenBufferSizeMismatches(t *testing.T) {
14- staging := make([]byte, 8)
15-
16- require.True(t, stagingNeedsReset(staging, 2, 2, 2, 2, 16))
17- require.False(t, stagingNeedsReset(make([]byte, 16), 2, 2, 2, 2, 16))
18-}
19-
20-func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) {
21- const (
22- width = int32(4)
23- height = int32(2)
24- )
25-
26- dst := bytes.Repeat([]byte{0xaa}, 32)
27- src := make([]byte, 28)
28- for i := range src {
29- src[i] = byte(i + 1)
30- }
31-
32- copied, sanitized, truncated := copyDirtyRectsIntoStaging(dst, src, width, height, []rect{{
33- X: 0,
34- Y: 0,
35- Width: width,
36- Height: height,
37- }})
38-
39- require.True(t, truncated)
40- require.Equal(t, uint64(len(src)), copied)
41- require.Len(t, sanitized, 1)
42- require.Equal(t, src, dst[:len(src)])
43- require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):])
44-}
45-
46-func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) {
47- rp := &renderPipeline{
48- ctx: context.Background(),
49- scale: 1,
50- }
51-
52- initial := []byte{
53- 1, 2, 3, 4,
54- 5, 6, 7, 8,
55- 9, 10, 11, 12,
56- 13, 14, 15, 16,
57- }
58- rp.handlePaint(initial, 2, 2, []rect{{X: 0, Y: 0, Width: 2, Height: 2}}, 1)
59-
60- require.Equal(t, initial, rp.staging)
61- require.True(t, rp.needsUpload)
62- require.True(t, rp.sizeChanged)
63- require.True(t, rp.forceFullUpload)
64-
65- rp.mu.Lock()
66- rp.dirtyRects = nil
67- rp.needsUpload = false
68- rp.sizeChanged = false
69- rp.forceFullUpload = false
70- rp.mu.Unlock()
71-
72- updated := append([]byte(nil), initial...)
73- copy(updated[8:12], []byte{42, 43, 44, 45})
74- rp.handlePaint(updated, 2, 2, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, 2)
75-
76- require.Equal(t, updated, rp.staging)
77- require.Equal(t, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, rp.dirtyRects)
78- require.True(t, rp.needsUpload)
79- require.False(t, rp.sizeChanged)
80- require.False(t, rp.forceFullUpload)
81-}
82-
83-func TestOnResizeIgnoresZeroFirstResize(t *testing.T) {
84- called := false
85- rp := &renderPipeline{
86- scale: 1,
87- onFirstResize: func(_, _ int32) {
88- called = true
89- },
90- }
91- rp.widthAtomic.Store(123)
92- rp.heightAtomic.Store(456)
93-
94- rp.onResize(0, 0)
95-
96- require.False(t, called)
97- require.Equal(t, int32(123), rp.widthAtomic.Load())
98- require.Equal(t, int32(456), rp.heightAtomic.Load())
99- require.False(t, rp.sizeChanged)
100- require.NotNil(t, rp.onFirstResize)
101-
102- rp.onResize(10, 20)
103-
104- require.True(t, called)
105- require.Equal(t, int32(10), rp.widthAtomic.Load())
106- require.Equal(t, int32(20), rp.heightAtomic.Load())
107- require.True(t, rp.sizeChanged)
108- require.Nil(t, rp.onFirstResize)
109-}
110-
111-// sortRects sorts rects by (X, Y) so tests can compare without caring about order.
112-func sortRects(rects []rect) {
113- sort.Slice(rects, func(i, j int) bool {
114- if rects[i].X != rects[j].X {
115- return rects[i].X < rects[j].X
116- }
117- return rects[i].Y < rects[j].Y
118- })
119-}
120-
121-func TestCoalesceDirtyRectsEmpty(t *testing.T) {
122- result := coalesceDirtyRects(nil, 1920, 1080)
123- assert.Nil(t, result)
124-
125- result = coalesceDirtyRects([]rect{}, 1920, 1080)
126- assert.Empty(t, result)
127-}
128-
129-func TestCoalesceDirtyRectsSingle(t *testing.T) {
130- input := []rect{{10, 20, 30, 40}}
131- result := coalesceDirtyRects(input, 1920, 1080)
132- require.Len(t, result, 1)
133- assert.Equal(t, rect{10, 20, 30, 40}, result[0])
134-}
135-
136-func TestCoalesceDirtyRectsDisjoint(t *testing.T) {
137- // Top-left cursor blink and bottom-right scrollbar: should NOT merge.
138- input := []rect{
139- {0, 0, 2, 16}, // cursor at top-left
140- {1900, 1060, 20, 20}, // scrollbar at bottom-right
141- }
142- result := coalesceDirtyRects(input, 1920, 1080)
143- require.Len(t, result, 2)
144- sortRects(result)
145- assert.Equal(t, rect{0, 0, 2, 16}, result[0])
146- assert.Equal(t, rect{1900, 1060, 20, 20}, result[1])
147-}
148-
149-func TestCoalesceDirtyRectsOverlapping(t *testing.T) {
150- // Two overlapping rects merge to their bounding box.
151- input := []rect{
152- {10, 10, 30, 30},
153- {20, 20, 30, 30},
154- }
155- result := coalesceDirtyRects(input, 1920, 1080)
156- require.Len(t, result, 1)
157- assert.Equal(t, rect{10, 10, 40, 40}, result[0])
158-}
159-
160-func TestCoalesceDirtyRectsEdgeAdjacent(t *testing.T) {
161- // Two rects sharing an edge (right edge of A touches left edge of B).
162- input := []rect{
163- {0, 0, 50, 100},
164- {50, 0, 50, 100},
165- }
166- result := coalesceDirtyRects(input, 1920, 1080)
167- require.Len(t, result, 1)
168- assert.Equal(t, rect{0, 0, 100, 100}, result[0])
169-}
170-
171-func TestCoalesceDirtyRectsEdgeAdjacentVertical(t *testing.T) {
172- // Two rects sharing a vertical edge (bottom of A touches top of B).
173- input := []rect{
174- {0, 0, 100, 50},
175- {0, 50, 100, 50},
176- }
177- result := coalesceDirtyRects(input, 1920, 1080)
178- require.Len(t, result, 1)
179- assert.Equal(t, rect{0, 0, 100, 100}, result[0])
180-}
181-
182-func TestCoalesceDirtyRectsTwoOverlapOneDisjoint(t *testing.T) {
183- // Three rects: first two overlap, third is disjoint.
184- input := []rect{
185- {10, 10, 20, 20},
186- {20, 10, 20, 20},
187- {500, 500, 10, 10},
188- }
189- result := coalesceDirtyRects(input, 1920, 1080)
190- require.Len(t, result, 2)
191- sortRects(result)
192- assert.Equal(t, rect{10, 10, 30, 20}, result[0])
193- assert.Equal(t, rect{500, 500, 10, 10}, result[1])
194-}
195-
196-func TestCoalesceDirtyRectsAreaThreshold(t *testing.T) {
197- // Total area > 60% of surface → collapses to single full-surface rect.
198- // Surface is 100x100 = 10000 pixels. 60% = 6000.
199- // Two rects: 4000 + 3000 = 7000 > 6000.
200- input := []rect{
201- {0, 0, 100, 40}, // 4000 pixels
202- {0, 40, 100, 30}, // 3000 pixels
203- }
204- result := coalesceDirtyRects(input, 100, 100)
205- require.Len(t, result, 1)
206- assert.Equal(t, rect{0, 0, 100, 100}, result[0])
207-}
208-
209-func TestCoalesceDirtyRectsAreaThresholdBelowDoesNotCollapse(t *testing.T) {
210- // Total area <= 60% of surface → no full-surface collapse.
211- // Surface is 100x100 = 10000. 59% = 5900.
212- input := []rect{
213- {0, 0, 100, 29}, // 2900 pixels
214- {0, 70, 100, 29}, // 2900 pixels → total 5800 < 6000
215- }
216- result := coalesceDirtyRects(input, 100, 100)
217- // These two rects are disjoint (gap from Y=29 to Y=70), so no merge.
218- require.Len(t, result, 2)
219-}
220-
221-func TestCoalesceDirtyRectsFullyContained(t *testing.T) {
222- // Rect A fully inside rect B → merges to rect B.
223- input := []rect{
224- {10, 10, 5, 5}, // small rect inside
225- {0, 0, 100, 100}, // large outer rect
226- }
227- result := coalesceDirtyRects(input, 1920, 1080)
228- require.Len(t, result, 1)
229- assert.Equal(t, rect{0, 0, 100, 100}, result[0])
230-}
231-
232-func TestCoalesceDirtyRectsChainMerge(t *testing.T) {
233- // Three rects in a chain: A overlaps B, B overlaps C, but A and C don't.
234- // All three should merge into one bounding box after iterative merging.
235- input := []rect{
236- {0, 0, 20, 20},
237- {15, 0, 20, 20},
238- {30, 0, 20, 20},
239- }
240- result := coalesceDirtyRects(input, 1920, 1080)
241- require.Len(t, result, 1)
242- assert.Equal(t, rect{0, 0, 50, 20}, result[0])
243-}
244-
245-func TestCoalesceDirtyRectsDoesNotMutateInput(t *testing.T) {
246- input := []rect{
247- {10, 10, 30, 30},
248- {20, 20, 30, 30},
249- }
250- inputCopy := make([]rect, len(input))
251- copy(inputCopy, input)
252-
253- coalesceDirtyRects(input, 1920, 1080)
254-
255- // Original input should be unchanged.
256- assert.Equal(t, inputCopy, input)
257-}
258-
259-func TestRectsTouchSeparatedByGap(t *testing.T) {
260- // One pixel gap between rects → should NOT touch.
261- a := rect{0, 0, 10, 10}
262- b := rect{11, 0, 10, 10}
263- assert.False(t, rectsTouch(a, b))
264-}
265-
266-func TestRectsTouchExactlyAdjacent(t *testing.T) {
267- // Right edge of A at X=10, left edge of B at X=10 → touching.
268- a := rect{0, 0, 10, 10}
269- b := rect{10, 0, 10, 10}
270- assert.True(t, rectsTouch(a, b))
271-}
D internal/infrastructure/cef/resize_reconciler.go +0 -271
@@ -1,271 +0,0 @@
1-package cef
2-
3-import (
4- "context"
5- "sync"
6- "time"
7-
8- "github.com/bnema/dumber/internal/application/port"
9- purecef "github.com/bnema/purego-cef/cef"
10- "github.com/bnema/puregotk/v4/glib"
11-
12- "github.com/bnema/dumber/internal/logging"
13-)
14-
15-const (
16- resizeReconcileRetryDelay = 8 * time.Millisecond
17- resizeReconcileWindow = 64 * time.Millisecond
18-)
19-
20-type resizeReconcileTimer interface {
21- stop()
22-}
23-
24-type resizeReconcileScheduler interface {
25- afterFunc(delay time.Duration, fn func()) resizeReconcileTimer
26-}
27-
28-type glibResizeTimer struct{ id uint }
29-
30-func (t *glibResizeTimer) stop() {
31- if t == nil || t.id == 0 {
32- return
33- }
34- glib.SourceRemove(t.id)
35- t.id = 0
36-}
37-
38-type glibResizeScheduler struct{}
39-
40-func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer {
41- cb := glib.SourceFunc(func(_ uintptr) bool {
42- if fn != nil {
43- fn()
44- }
45- return false
46- })
47- return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)}
48-}
49-
50-type resizeReconciler struct {
51- mu sync.Mutex
52- webviewID port.WebViewID
53- ctx context.Context
54- scheduler resizeReconcileScheduler
55- now func() time.Time
56- session *resizeReconcileSession
57-}
58-
59-type resizeReconcileSession struct {
60- seq uint64
61- startedAt time.Time
62- retryCount int
63- timer resizeReconcileTimer
64- hostGetter func() resizeNotifiableBrowserHost
65- destroyed func() bool
66-}
67-
68-func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler {
69- return &resizeReconciler{
70- webviewID: webviewID,
71- ctx: ctx,
72- scheduler: glibResizeScheduler{},
73- now: time.Now,
74- }
75-}
76-
77-func (r *resizeReconciler) clock() time.Time {
78- if r != nil && r.now != nil {
79- return r.now()
80- }
81- return time.Now()
82-}
83-
84-func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler {
85- if r != nil && r.scheduler != nil {
86- return r.scheduler
87- }
88- return glibResizeScheduler{}
89-}
90-
91-func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) {
92- if r == nil || seq == 0 || hostGetter == nil {
93- return
94- }
95- if destroyed != nil && destroyed() {
96- return
97- }
98-
99- now := r.clock()
100- var superseded *resizeReconcileSession
101-
102- r.mu.Lock()
103- if r.session != nil {
104- superseded = r.session
105- if superseded.timer != nil {
106- superseded.timer.stop()
107- }
108- r.session = nil
109- }
110- session := &resizeReconcileSession{
111- seq: seq,
112- startedAt: now,
113- hostGetter: hostGetter,
114- destroyed: destroyed,
115- }
116- r.session = session
117- r.mu.Unlock()
118-
119- if superseded != nil {
120- r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds())
121- }
122- r.logStarted(seq)
123- r.schedule(session)
124-}
125-
126-func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) {
127- if r == nil || seq == 0 || !sizeMatches {
128- return
129- }
130- now := r.clock()
131-
132- r.mu.Lock()
133- session := r.session
134- if session == nil || session.seq != seq {
135- r.mu.Unlock()
136- return
137- }
138- if session.timer != nil {
139- session.timer.stop()
140- session.timer = nil
141- }
142- r.session = nil
143- retryCount := session.retryCount
144- elapsed := now.Sub(session.startedAt).Milliseconds()
145- r.mu.Unlock()
146-
147- r.logStopped("paint-arrived", seq, retryCount, elapsed)
148-}
149-
150-func (r *resizeReconciler) stop() {
151- if r == nil {
152- return
153- }
154- r.mu.Lock()
155- session := r.session
156- r.session = nil
157- r.mu.Unlock()
158- if session != nil && session.timer != nil {
159- session.timer.stop()
160- }
161-}
162-
163-func (r *resizeReconciler) schedule(session *resizeReconcileSession) {
164- if r == nil || session == nil {
165- return
166- }
167- timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() {
168- r.runTick(session)
169- })
170- r.mu.Lock()
171- if r.session == session {
172- session.timer = timer
173- } else if timer != nil {
174- timer.stop()
175- }
176- r.mu.Unlock()
177-}
178-
179-func (r *resizeReconciler) runTick(session *resizeReconcileSession) {
180- if r == nil || session == nil {
181- return
182- }
183- now := r.clock()
184-
185- r.mu.Lock()
186- if r.session != session {
187- r.mu.Unlock()
188- return
189- }
190- if session.destroyed != nil && session.destroyed() {
191- r.session = nil
192- r.mu.Unlock()
193- return
194- }
195- host := session.hostGetter()
196- if host == nil {
197- r.session = nil
198- r.mu.Unlock()
199- return
200- }
201- elapsed := now.Sub(session.startedAt)
202- if elapsed > resizeReconcileWindow {
203- r.session = nil
204- retryCount := session.retryCount
205- r.mu.Unlock()
206- r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds())
207- return
208- }
209- session.retryCount++
210- retryCount := session.retryCount
211- seq := session.seq
212- r.mu.Unlock()
213-
214- host.Invalidate(purecef.PaintElementTypePetView)
215- r.logRetry(seq, retryCount, elapsed.Milliseconds())
216-
217- elapsedAfterInvalidate := r.clock().Sub(session.startedAt)
218- r.mu.Lock()
219- if r.session != session {
220- r.mu.Unlock()
221- return
222- }
223- if elapsedAfterInvalidate >= resizeReconcileWindow {
224- r.session = nil
225- r.mu.Unlock()
226- r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds())
227- return
228- }
229- newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() {
230- r.runTick(session)
231- })
232- session.timer = newTimer
233- r.mu.Unlock()
234-}
235-
236-func (r *resizeReconciler) logStarted(seq uint64) {
237- if r == nil || r.ctx == nil {
238- return
239- }
240- logging.FromContext(r.ctx).Debug().
241- Uint64("webview_id", uint64(r.webviewID)).
242- Uint64("resize_seq", seq).
243- Int("retry_count", 0).
244- Int64("elapsed_ms", 0).
245- Msg("cef: resize reconciliation started")
246-}
247-
248-func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) {
249- if r == nil || r.ctx == nil {
250- return
251- }
252- logging.FromContext(r.ctx).Debug().
253- Uint64("webview_id", uint64(r.webviewID)).
254- Uint64("resize_seq", seq).
255- Int("retry_count", retryCount).
256- Int64("elapsed_ms", elapsedMs).
257- Msg("cef: resize reconciliation retry")
258-}
259-
260-func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) {
261- if r == nil || r.ctx == nil {
262- return
263- }
264- logging.FromContext(r.ctx).Debug().
265- Uint64("webview_id", uint64(r.webviewID)).
266- Uint64("resize_seq", seq).
267- Int("retry_count", retryCount).
268- Int64("elapsed_ms", elapsedMs).
269- Str("reason", reason).
270- Msg("cef: resize reconciliation stopped")
271-}
D internal/infrastructure/cef/resize_reconciler_test.go +0 -192
@@ -1,192 +0,0 @@
1-package cef
2-
3-import (
4- "context"
5- "sync"
6- "testing"
7- "time"
8-
9- purecef "github.com/bnema/purego-cef/cef"
10- "github.com/stretchr/testify/require"
11-)
12-
13-type resizeHostRecorder struct {
14- mu sync.Mutex
15- invalidations []purecef.PaintElementType
16-}
17-
18-func (h *resizeHostRecorder) WasResized() {}
19-
20-func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) {
21- h.mu.Lock()
22- defer h.mu.Unlock()
23- h.invalidations = append(h.invalidations, elementType)
24-}
25-
26-func (h *resizeHostRecorder) invalidationCount() int {
27- h.mu.Lock()
28- defer h.mu.Unlock()
29- return len(h.invalidations)
30-}
31-
32-type fakeResizeTimer struct {
33- id uint
34- cancel func()
35-}
36-
37-func (t *fakeResizeTimer) stop() {
38- if t != nil && t.cancel != nil {
39- t.cancel()
40- }
41-}
42-
43-type fakeResizeScheduler struct {
44- mu sync.Mutex
45- nextID uint
46- timers map[uint]func()
47- delays []time.Duration
48- stopped map[uint]bool
49-}
50-
51-func newFakeResizeScheduler() *fakeResizeScheduler {
52- return &fakeResizeScheduler{
53- timers: make(map[uint]func()),
54- stopped: make(map[uint]bool),
55- }
56-}
57-
58-func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer {
59- s.mu.Lock()
60- defer s.mu.Unlock()
61- s.nextID++
62- id := s.nextID
63- s.timers[id] = fn
64- s.delays = append(s.delays, delay)
65- return &fakeResizeTimer{
66- id: id,
67- cancel: func() {
68- s.mu.Lock()
69- defer s.mu.Unlock()
70- s.stopped[id] = true
71- },
72- }
73-}
74-
75-func (s *fakeResizeScheduler) fireLast() {
76- s.mu.Lock()
77- id := s.nextID
78- fn := s.timers[id]
79- stopped := s.stopped[id]
80- s.mu.Unlock()
81- if fn == nil || stopped {
82- return
83- }
84- fn()
85-}
86-
87-func (s *fakeResizeScheduler) delayCount() int {
88- s.mu.Lock()
89- defer s.mu.Unlock()
90- return len(s.delays)
91-}
92-
93-func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) {
94- clock := time.Unix(0, 0)
95- scheduler := newFakeResizeScheduler()
96- host := &resizeHostRecorder{}
97- rr := &resizeReconciler{
98- ctx: context.Background(),
99- scheduler: scheduler,
100- now: func() time.Time { return clock },
101- }
102-
103- rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
104- require.Equal(t, 1, scheduler.delayCount())
105- require.Equal(t, 0, host.invalidationCount())
106-
107- clock = clock.Add(8 * time.Millisecond)
108- scheduler.fireLast()
109- require.Equal(t, 1, host.invalidationCount())
110- require.Equal(t, 2, scheduler.delayCount())
111-}
112-
113-func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) {
114- clock := time.Unix(0, 0)
115- scheduler := newFakeResizeScheduler()
116- host := &resizeHostRecorder{}
117- rr := &resizeReconciler{
118- ctx: context.Background(),
119- scheduler: scheduler,
120- now: func() time.Time { return clock },
121- }
122-
123- rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
124- rr.notePaint(9, true)
125-
126- clock = clock.Add(8 * time.Millisecond)
127- scheduler.fireLast()
128- require.Zero(t, host.invalidationCount())
129- require.Equal(t, 1, scheduler.delayCount())
130-}
131-
132-func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) {
133- clock := time.Unix(0, 0)
134- scheduler := newFakeResizeScheduler()
135- host := &resizeHostRecorder{}
136- rr := &resizeReconciler{
137- ctx: context.Background(),
138- scheduler: scheduler,
139- now: func() time.Time { return clock },
140- }
141-
142- rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
143- rr.notePaint(10, false)
144-
145- clock = clock.Add(8 * time.Millisecond)
146- scheduler.fireLast()
147- require.Equal(t, 1, host.invalidationCount())
148- require.Equal(t, 2, scheduler.delayCount())
149-}
150-
151-func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) {
152- clock := time.Unix(0, 0)
153- scheduler := newFakeResizeScheduler()
154- host := &resizeHostRecorder{}
155- rr := &resizeReconciler{
156- ctx: context.Background(),
157- scheduler: scheduler,
158- now: func() time.Time { return clock },
159- }
160-
161- rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
162- firstDelayCount := scheduler.delayCount()
163- rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
164- require.Equal(t, firstDelayCount+1, scheduler.delayCount())
165-
166- clock = clock.Add(8 * time.Millisecond)
167- scheduler.fireLast()
168- require.Equal(t, 1, host.invalidationCount())
169-}
170-
171-func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) {
172- clock := time.Unix(0, 0)
173- scheduler := newFakeResizeScheduler()
174- host := &resizeHostRecorder{}
175- rr := &resizeReconciler{
176- ctx: context.Background(),
177- scheduler: scheduler,
178- now: func() time.Time { return clock },
179- }
180-
181- rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false })
182- for i := 0; i < 8; i++ {
183- clock = clock.Add(8 * time.Millisecond)
184- scheduler.fireLast()
185- }
186-
187- require.Equal(t, 8, host.invalidationCount())
188- require.Equal(t, 8, scheduler.delayCount())
189- clock = clock.Add(8 * time.Millisecond)
190- scheduler.fireLast()
191- require.Equal(t, 8, host.invalidationCount())
192-}
M internal/infrastructure/cef/webview.go +89 -46
@@ -64,20 +64,21 @@ var (
64 cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }64 cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
65 )65 )
66 66
67-// WebView implements port.WebView using a CEF off-screen browser rendered67+// WebView implements port.WebView using a CEF off-screen browser rendered and
68-// through a renderPipeline and driven by an inputBridge.68+// driven through purego-cef2gtk. Dumber owns browser state and callbacks; the
69+// bridge owns GTK rendering and input forwarding.
69 type WebView struct {70 type WebView struct {
70- id port.WebViewID71+ id port.WebViewID
71- ctx context.Context72+ ctx context.Context
72- engine *Engine73+ engine *Engine
73- browser purecef.Browser74+ browser purecef.Browser
74- host purecef.BrowserHost75+ host purecef.BrowserHost
75- client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it76+ client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
76- pipeline *renderPipeline77+ viewBridge *Cef2gtkAdapter
77- input *inputBridge78+ handlers *handlerSet
78- handlers *handlerSet79+ findCtrl *cefFindController
79- findCtrl *cefFindController80+
80- resizeReconciler *resizeReconciler81+ removeSizeObserver func()
81 82
82 // beginFrameTick drives CEF external BeginFrame requests while the GTK83 // beginFrameTick drives CEF external BeginFrame requests while the GTK
83 // widget is visible. Access is guarded by mu.84 // widget is visible. Access is guarded by mu.
@@ -1009,9 +1010,6 @@ func (wv *WebView) Destroy() {
1009 wv.mu.Unlock()1010 wv.mu.Unlock()
1010 wv.stopNativePopupFallbackTimer()1011 wv.stopNativePopupFallbackTimer()
1011 wv.cancelSelectionDebounce()1012 wv.cancelSelectionDebounce()
1012- if wv.resizeReconciler != nil {
1013- wv.resizeReconciler.stop()
1014- }
1015 wv.closeAudioStream()1013 wv.closeAudioStream()
1016 wv.scheduleStopBeginFrameLoop()1014 wv.scheduleStopBeginFrameLoop()
1017 wv.mu.RLock()1015 wv.mu.RLock()
@@ -1022,8 +1020,19 @@ func (wv *WebView) Destroy() {
1022 } else {1020 } else {
1023 wv.runCloseCallbacks()1021 wv.runCloseCallbacks()
1024 }1022 }
1025- if wv.pipeline != nil {1023+ if wv.viewBridge != nil {
1026- wv.pipeline.destroy()1024+ wv.runOnGTKSync(func() {
1025+ if wv.removeSizeObserver != nil {
1026+ wv.removeSizeObserver()
1027+ wv.removeSizeObserver = nil
1028+ }
1029+ bridge := wv.viewBridge
1030+ wv.viewBridge = nil
1031+ if bridge != nil {
1032+ _ = bridge.DetachInput()
1033+ _ = bridge.Destroy()
1034+ }
1035+ })
1027 }1036 }
1028 }1037 }
1029 1038
@@ -1031,12 +1040,18 @@ func (wv *WebView) Destroy() {
1031 // NativeWidgetProvider1040 // NativeWidgetProvider
1032 // ---------------------------------------------------------------------------1041 // ---------------------------------------------------------------------------
1033 1042
1034-// NativeWidget returns the uintptr for embedding the GLArea into GTK.1043+// NativeWidget returns the uintptr for embedding the bridge GtkGLArea into GTK.
1035 func (wv *WebView) NativeWidget() uintptr {1044 func (wv *WebView) NativeWidget() uintptr {
1036- if wv.pipeline == nil || wv.pipeline.glArea == nil {1045+ if wv.viewBridge == nil {
1037 return 01046 return 0
1038 }1047 }
1039- return wv.pipeline.glArea.GoPointer()1048+ var ptr uintptr
1049+ wv.runOnGTKSync(func() {
1050+ if wv.viewBridge != nil {
1051+ ptr = wv.viewBridge.NativeWidget()
1052+ }
1053+ })
1054+ return ptr
1040 }1055 }
1041 1056
1042 // ---------------------------------------------------------------------------1057 // ---------------------------------------------------------------------------
@@ -1263,6 +1278,35 @@ func (wv *WebView) selectedTextSnapshot() string {
1263 return wv.selectedText1278 return wv.selectedText
1264 }1279 }
1265 1280
1281+func (wv *WebView) viewBridgeScale() int32 {
1282+ if wv == nil || wv.viewBridge == nil {
1283+ return 1
1284+ }
1285+ scale := int32(math.Round(float64(wv.viewBridge.DeviceScaleFactor())))
1286+ if scale < 1 {
1287+ return 1
1288+ }
1289+ return scale
1290+}
1291+
1292+func (wv *WebView) handleMiddleClickFromBridge() bool {
1293+ if wv == nil {
1294+ return false
1295+ }
1296+ wv.mu.RLock()
1297+ hoverURI := wv.lastHoverURI
1298+ cb := wv.callbacks
1299+ wv.mu.RUnlock()
1300+ if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil {
1301+ return false
1302+ }
1303+ uri := hoverURI
1304+ wv.runOnGTK(func() {
1305+ cb.OnLinkMiddleClick(uri)
1306+ })
1307+ return true
1308+}
1309+
1266 func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {1310 func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {
1267 for _, delay := range cefLoadWatchdogDelays {1311 for _, delay := range cefLoadWatchdogDelays {
1268 d := delay1312 d := delay
@@ -1294,9 +1338,18 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
1294 return1338 return
1295 }1339 }
1296 1340
1297- snap := renderPipelineSnapshot{}1341+ var acceleratedPaints, unsupportedPaints, acceleratedPaintErrors, importFailures, renderFailures int
1298- if wv.pipeline != nil {1342+ var surfaceWidth, surfaceHeight int32 = 1, 1
1299- snap = wv.pipeline.diagnosticSnapshot()1343+ var surfaceScale float32 = 1
1344+ if wv.viewBridge != nil {
1345+ diag := wv.viewBridge.Diagnostics()
1346+ acceleratedPaints = diag.AcceleratedPaints
1347+ unsupportedPaints = diag.UnsupportedPaints
1348+ acceleratedPaintErrors = diag.AcceleratedPaintErrors
1349+ importFailures = diag.ImportFailures
1350+ renderFailures = diag.RenderFailures
1351+ surfaceWidth, surfaceHeight = wv.viewBridge.Size()
1352+ surfaceScale = wv.viewBridge.DeviceScaleFactor()
1300 }1353 }
1301 1354
1302 now := time.Now()1355 now := time.Now()
@@ -1311,24 +1364,14 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
1311 Str("title", title).1364 Str("title", title).
1312 Float64("progress", progress).1365 Float64("progress", progress).
1313 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).1366 Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
1314- Int32("surface_width", snap.Width).1367+ Int32("surface_width", surfaceWidth).
1315- Int32("surface_height", snap.Height).1368+ Int32("surface_height", surfaceHeight).
1316- Int32("surface_scale", snap.Scale).1369+ Float32("surface_scale", surfaceScale).
1317- Bool("gl_ready", snap.GLReady).1370+ Int("accelerated_paints", acceleratedPaints).
1318- Bool("needs_upload", snap.NeedsUpload).1371+ Int("unsupported_paints", unsupportedPaints).
1319- Bool("size_changed", snap.SizeChanged).1372+ Int("accelerated_paint_errors", acceleratedPaintErrors).
1320- Bool("force_full_upload", snap.ForceFullUpload).1373+ Int("import_failures", importFailures).
1321- Uint64("view_rect_seq", snap.ViewRectSeq).1374+ Int("render_failures", renderFailures).
1322- Uint64("screen_info_seq", snap.ScreenInfoSeq).
1323- Uint64("paint_seq", snap.PaintSeq).
1324- Uint64("resize_seq", snap.ResizeSeq).
1325- Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq).
1326- Uint64("gl_render_seq", snap.GLRenderSeq).
1327- Uint64("paint_total", snap.PaintCount).
1328- Uint64("queue_total", snap.QueueRenderCount).
1329- Uint64("render_total", snap.RenderCount).
1330- Uint64("upload_total", snap.UploadCount).
1331- Uint64("full_upload_total", snap.FullUploadCount).
1332 Msg("cef: loading watchdog")1375 Msg("cef: loading watchdog")
1333 }1376 }
1334 1377
@@ -1524,7 +1567,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() {
1524 }1567 }
1525 1568
1526 func (wv *WebView) startBeginFrameLoop() {1569 func (wv *WebView) startBeginFrameLoop() {
1527- if wv.destroyed.Load() || wv.pipeline == nil || wv.pipeline.glArea == nil {1570+ if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {
1528 return1571 return
1529 }1572 }
1530 1573
@@ -1534,7 +1577,7 @@ func (wv *WebView) startBeginFrameLoop() {
1534 return1577 return
1535 }1578 }
1536 1579
1537- glArea := wv.pipeline.glArea1580+ glArea := wv.viewBridge.GLArea()
1538 cb := new(gtk.TickCallback)1581 cb := new(gtk.TickCallback)
1539 *cb = func(_, _, _ uintptr) bool {1582 *cb = func(_, _, _ uintptr) bool {
1540 if wv.destroyed.Load() {1583 if wv.destroyed.Load() {
@@ -1560,7 +1603,7 @@ func (wv *WebView) startBeginFrameLoop() {
1560 }1603 }
1561 1604
1562 func (wv *WebView) stopBeginFrameLoop() {1605 func (wv *WebView) stopBeginFrameLoop() {
1563- if wv.pipeline == nil || wv.pipeline.glArea == nil {1606+ if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil {
1564 return1607 return
1565 }1608 }
1566 1609
@@ -1568,7 +1611,7 @@ func (wv *WebView) stopBeginFrameLoop() {
1568 tickID := wv.beginFrameTickID1611 tickID := wv.beginFrameTickID
1569 wv.beginFrameTickID = 01612 wv.beginFrameTickID = 0
1570 wv.beginFrameTick = nil1613 wv.beginFrameTick = nil
1571- glArea := wv.pipeline.glArea1614+ glArea := wv.viewBridge.GLArea()
1572 wv.mu.Unlock()1615 wv.mu.Unlock()
1573 1616
1574 if tickID != 0 {1617 if tickID != 0 {
M internal/infrastructure/cef/webview_audio_test.go +3 -5
@@ -32,7 +32,7 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) {
32 assert.Equal(t, audioFactory, opts.audioOutputFactory)32 assert.Equal(t, audioFactory, opts.audioOutputFactory)
33 33
34 // Verify the factory is passed through34 // Verify the factory is passed through
35- factory := newWebViewFactory(&Engine{}, &glLoader{}, opts)35+ factory := newWebViewFactory(&Engine{}, opts)
36 require.NotNil(t, factory.audioOutputFactory)36 require.NotNil(t, factory.audioOutputFactory)
37 }37 }
38 38
@@ -47,7 +47,7 @@ func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) {
47 }47 }
48 48
49 // Act49 // Act
50- factory := newWebViewFactory(&Engine{}, &glLoader{}, opts)50+ factory := newWebViewFactory(&Engine{}, opts)
51 51
52 // Assert52 // Assert
53 require.NotNil(t, factory)53 require.NotNil(t, factory)
@@ -423,7 +423,6 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
423 wv := &WebView{423 wv := &WebView{
424 ctx: context.Background(),424 ctx: context.Background(),
425 activeAudioStream: mockStream,425 activeAudioStream: mockStream,
426- pipeline: &renderPipeline{},
427 }426 }
428 wv.audioPlaying.Store(true)427 wv.audioPlaying.Store(true)
429 428
@@ -439,8 +438,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
439 // handles the case where no audio stream is active.438 // handles the case where no audio stream is active.
440 func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) {439 func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) {
441 wv := &WebView{440 wv := &WebView{
442- ctx: context.Background(),441+ ctx: context.Background(),
443- pipeline: &renderPipeline{},
444 }442 }
445 443
446 assert.NotPanics(t, func() {444 assert.NotPanics(t, func() {
M internal/infrastructure/cef/webview_handlers.go +67 -305
@@ -5,6 +5,7 @@ import (
5 "strings"5 "strings"
6 6
7 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
8+ cef2gtk "github.com/bnema/purego-cef2gtk"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 downloadutil "github.com/bnema/dumber/internal/domain/download"11 downloadutil "github.com/bnema/dumber/internal/domain/download"
@@ -27,7 +28,6 @@ type handlerSet struct {
27 // Compile-time interface checks.28 // Compile-time interface checks.
28 var (29 var (
29 _ purecef.Client = (*handlerSet)(nil)30 _ purecef.Client = (*handlerSet)(nil)
30- _ purecef.RenderHandler = (*handlerSet)(nil)
31 _ purecef.DisplayHandler = (*handlerSet)(nil)31 _ purecef.DisplayHandler = (*handlerSet)(nil)
32 _ purecef.LoadHandler = (*handlerSet)(nil)32 _ purecef.LoadHandler = (*handlerSet)(nil)
33 _ purecef.LifeSpanHandler = (*handlerSet)(nil)33 _ purecef.LifeSpanHandler = (*handlerSet)(nil)
@@ -69,8 +69,13 @@ func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return n
69 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }69 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
70 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }70 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
71 func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }71 func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
72-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }72+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
73-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }73+ if h == nil {
74+ return nil
75+ }
76+ return newDumberRenderHandler(h.wv)
77+}
78+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
74 79
75 func (h *handlerSet) OnProcessMessageReceived(80 func (h *handlerSet) OnProcessMessageReceived(
76 browser purecef.Browser,81 browser purecef.Browser,
@@ -136,291 +141,11 @@ func (h *handlerSet) OnProcessMessageReceived(
136 }141 }
137 142
138 // ===========================================================================143 // ===========================================================================
139-// RenderHandler (17 methods)144+// RenderHandler methods live in render_handler_adapter.go. handlerSet keeps
145+// Dumber's non-render CEF handlers and returns a delegating render handler from
146+// GetRenderHandler.
140 // ===========================================================================147 // ===========================================================================
141 148
142-func (h *handlerSet) GetAccessibilityHandler() purecef.AccessibilityHandler { return nil }
143-
144-func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
145-
146-// GetViewRect fills the rect struct with the pipeline dimensions.
147-func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
148- if rect == nil {
149- return
150- }
151- callSeq := h.wv.pipeline.nextViewRectSeq()
152- w, ht, s := h.wv.pipeline.viewRectSize()
153-
154- // CEF expects view geometry in DIP coordinates while OnPaint dimensions are
155- // in device pixels. The render pipeline tracks device pixels, so convert
156- // back to DIP before answering GetViewRect/GetScreenInfo.
157- w /= s
158- ht /= s
159-
160- // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area has
161- // not been realized yet.
162- if w <= 0 {
163- w = 1
164- }
165- if ht <= 0 {
166- ht = 1
167- }
168-
169- rect.X = 0
170- rect.Y = 0
171- rect.Width = w
172- rect.Height = ht
173- if h.wv != nil && h.wv.ctx != nil {
174- logging.FromContext(h.wv.ctx).Debug().
175- Uint64("webview_id", uint64(h.wv.id)).
176- Uint64("view_rect_seq", callSeq).
177- Int32("width", rect.Width).
178- Int32("height", rect.Height).
179- Int32("scale", s).
180- Msg("cef: GetViewRect")
181- }
182-}
183-
184-func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 {
185- return 0
186-}
187-
188-func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) int32 {
189- if info == nil {
190- return 0
191- }
192- callSeq := h.wv.pipeline.nextScreenInfoSeq()
193- w, ht, s := h.wv.pipeline.viewRectSize()
194- w /= s
195- ht /= s
196- if w <= 0 {
197- w = 1
198- }
199- if ht <= 0 {
200- ht = 1
201- }
202-
203- const (
204- screenDepth = 24
205- screenDepthPerComponent = 8
206- )
207- r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht}
208- si := purecef.NewScreenInfo()
209- si.DeviceScaleFactor = float32(s)
210- si.Depth = screenDepth
211- si.DepthPerComponent = screenDepthPerComponent
212- si.Rect = r
213- si.AvailableRect = r
214- *info = si
215- if h.wv != nil && h.wv.ctx != nil {
216- logging.FromContext(h.wv.ctx).Debug().
217- Uint64("webview_id", uint64(h.wv.id)).
218- Uint64("screen_info_seq", callSeq).
219- Int32("width", w).
220- Int32("height", ht).
221- Int32("scale", s).
222- Msg("cef: GetScreenInfo")
223- }
224- return 1
225-}
226-
227-func (h *handlerSet) OnPopupShow(_ purecef.Browser, show int32) {
228- if h.wv == nil || h.wv.pipeline == nil {
229- return
230- }
231- h.wv.runOnGTK(func() {
232- h.wv.pipeline.setPopupVisible(show != 0)
233- })
234-}
235-
236-func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) {
237- if h.wv == nil || h.wv.pipeline == nil || popupRect == nil {
238- return
239- }
240- popup := rect{
241- X: popupRect.X,
242- Y: popupRect.Y,
243- Width: popupRect.Width,
244- Height: popupRect.Height,
245- }
246- h.wv.runOnGTK(func() {
247- h.wv.pipeline.setPopupRect(popup)
248- })
249-}
250-
251-// OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
252-// to the render pipeline for GPU upload. Main-view paints are copied directly
253-// into the persistent staging buffer on the CEF UI thread; only the GTK
254-// QueueRender hop remains. Popups still copy their transient buffer before
255-// crossing threads because they are small and infrequent.
256-func (h *handlerSet) OnPaint(
257- _ purecef.Browser, elementType purecef.PaintElementType,
258- dirtyRects []purecef.Rect, buffer []byte, width, height int32,
259-) {
260- if len(buffer) == 0 || width <= 0 || height <= 0 {
261- return
262- }
263- paintSeq := h.wv.pipeline.nextPaintSeq()
264- resizeSeq, resizeAgeMs := uint64(0), int64(0)
265- if elementType != purecef.PaintElementTypePetPopup {
266- resizeSeq, resizeAgeMs = h.wv.pipeline.latestResizeDiagnostics()
267- }
268- if h.wv != nil && h.wv.ctx != nil {
269- log := logging.FromContext(h.wv.ctx).Trace().
270- Uint64("paint_seq", paintSeq).
271- Int32("width", width).
272- Int32("height", height).
273- Int("dirty_rect_count", len(dirtyRects)).
274- Int("buffer_len", len(buffer))
275- if elementType != purecef.PaintElementTypePetPopup {
276- log = log.
277- Uint64("resize_seq", resizeSeq).
278- Int64("time_since_resize_ms", resizeAgeMs)
279- }
280- log.Msg("cef: OnPaint begin")
281- }
282- if elementType != purecef.PaintElementTypePetPopup &&
283- !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) {
284- return
285- }
286- rects := make([]rect, len(dirtyRects))
287- for i, dr := range dirtyRects {
288- rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
289- }
290-
291- if elementType == purecef.PaintElementTypePetPopup {
292- pixels := make([]byte, len(buffer))
293- copy(pixels, buffer)
294- h.wv.runOnGTK(func() {
295- h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq)
296- })
297- if h.wv != nil && h.wv.ctx != nil {
298- logging.FromContext(h.wv.ctx).Trace().
299- Uint64("paint_seq", paintSeq).
300- Msg("cef: OnPaint queued popup to GTK")
301- }
302- return
303- }
304-
305- h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
306- h.wv.runOnGTK(func() {
307- h.wv.pipeline.queuePaintRender()
308- })
309- if h.wv != nil && h.wv.ctx != nil {
310- logging.FromContext(h.wv.ctx).Trace().
311- Uint64("paint_seq", paintSeq).
312- Msg("cef: OnPaint queued to GTK")
313- }
314-}
315-
316-func (h *handlerSet) shouldAcceptMainViewPaint(
317- width, height int32,
318- dirtyRects []purecef.Rect,
319- paintSeq uint64,
320- resizeSeq uint64,
321- resizeAgeMs int64,
322-) bool {
323- expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize()
324- sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight
325-
326- if h.wv.resizeReconciler != nil {
327- h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView)
328- }
329-
330- if h.wv.ctx != nil {
331- if sizeMatchesCurrentView {
332- _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()
333- if shouldLog {
334- logging.Trace().Mark("cef_first_content_paint")
335- logging.FromContext(h.wv.ctx).Debug().
336- Uint64("webview_id", uint64(h.wv.id)).
337- Uint64("paint_seq", paintSeq).
338- Uint64("resize_seq", resizeSeq).
339- Int64("time_since_resize_ms", resizeAgeMs).
340- Int32("width", width).
341- Int32("height", height).
342- Int("dirty_rect_count", len(dirtyRects)).
343- Msg("cef: first paint after resize")
344- }
345- } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) {
346- logging.FromContext(h.wv.ctx).Debug().
347- Uint64("webview_id", uint64(h.wv.id)).
348- Uint64("paint_seq", paintSeq).
349- Uint64("resize_seq", resizeSeq).
350- Int64("time_since_resize_ms", resizeAgeMs).
351- Int32("width", width).
352- Int32("height", height).
353- Int32("expected_width", expectedWidth).
354- Int32("expected_height", expectedHeight).
355- Int("dirty_rect_count", len(dirtyRects)).
356- Msg("cef: stale-size paint after resize")
357- }
358- }
359-
360- return sizeMatchesCurrentView
361-}
362-
363-func (h *handlerSet) OnAcceleratedPaint(
364- _ purecef.Browser,
365- _ purecef.PaintElementType,
366- _ []purecef.Rect,
367- _ *purecef.AcceleratedPaintInfo,
368-) {
369- count := h.wv.pipeline.recordAcceleratedPaint()
370- if count <= 5 || count%100 == 0 {
371- logging.FromContext(h.wv.ctx).Info().
372- Uint64("count", count).
373- Msg("cef: OnAcceleratedPaint")
374- }
375-}
376-
377-func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalAlignment, _ *purecef.Size) {
378-}
379-
380-func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {}
381-
382-func (h *handlerSet) StartDragging(
383- _ purecef.Browser,
384- _ purecef.DragData,
385- _ purecef.DragOperationsMask,
386- _, _ int32,
387-) int32 {
388- return 0
389-}
390-
391-func (h *handlerSet) UpdateDragCursor(_ purecef.Browser, _ purecef.DragOperationsMask) {}
392-
393-func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {}
394-
395-func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) {
396-}
397-
398-func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) {
399- if h == nil || h.wv == nil {
400- return
401- }
402- previous, changed := h.wv.setSelectedText(selectedText)
403- if !changed {
404- return
405- }
406- if h.wv.ctx != nil && selectedText == "" {
407- if previous != "" {
408- logging.FromContext(h.wv.ctx).Debug().
409- Int("prev_text_len", len(previous)).
410- Msg("cef: text selection cleared")
411- }
412- } else if h.wv.ctx != nil {
413- logging.FromContext(h.wv.ctx).Debug().
414- Int("text_len", len(selectedText)).
415- Msg("cef: text selection changed")
416- }
417- if h.wv.engine != nil {
418- h.wv.scheduleSelectionUpdate(selectedText)
419- }
420-}
421-
422-func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {}
423-
424 // ===========================================================================149 // ===========================================================================
425 // DisplayHandler (13 methods)150 // DisplayHandler (13 methods)
426 // ===========================================================================151 // ===========================================================================
@@ -528,9 +253,14 @@ func (h *handlerSet) OnCursorChange(
528 cursorType purecef.CursorType,253 cursorType purecef.CursorType,
529 _ *purecef.CursorInfo,254 _ *purecef.CursorInfo,
530 ) int32 {255 ) int32 {
256+ if h == nil || h.wv == nil {
257+ return 0
258+ }
531 name := cefCursorToGDKName(cursorType)259 name := cefCursorToGDKName(cursorType)
532 h.wv.runOnGTK(func() {260 h.wv.runOnGTK(func() {
533- h.wv.pipeline.glArea.SetCursorFromName(&name)261+ if h.wv.viewBridge != nil {
262+ h.wv.viewBridge.SetCursorFromName(name)
263+ }
534 })264 })
535 return 1 // handled265 return 1 // handled
536 }266 }
@@ -565,13 +295,17 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
565 Msg("cef: OnLoadingStateChange")295 Msg("cef: OnLoadingStateChange")
566 }296 }
567 297
568- if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() {298+ if !loading && h.wv.viewBridge != nil {
569- h.wv.mu.RLock()299+ h.wv.runOnGTK(func() {
570- host := h.wv.host300+ if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() {
571- h.wv.mu.RUnlock()301+ h.wv.mu.RLock()
572- if host != nil {302+ host := h.wv.host
573- syncWindowlessBrowserFocus(host)303+ h.wv.mu.RUnlock()
574- }304+ if host != nil {
305+ syncWindowlessBrowserFocus(host)
306+ }
307+ }
308+ })
575 }309 }
576 310
577 h.wv.mu.RLock()311 h.wv.mu.RLock()
@@ -783,13 +517,13 @@ func (h *handlerSet) attachAfterCreatedBrowser(
783 ) afterCreatedState {517 ) afterCreatedState {
784 state := afterCreatedState{}518 state := afterCreatedState{}
785 h.wv.mu.Lock()519 h.wv.mu.Lock()
786- defer h.wv.mu.Unlock()
787 520
788 if existing := h.wv.browser; existing != nil {521 if existing := h.wv.browser; existing != nil {
789 existingID := existing.GetIdentifier()522 existingID := existing.GetIdentifier()
790 if existingID != 0 && existingID != browserID {523 if existingID != 0 && existingID != browserID {
791 state.closeDuplicate = true524 state.closeDuplicate = true
792 state.duplicateBrowserID = existingID525 state.duplicateBrowserID = existingID
526+ h.wv.mu.Unlock()
793 return state527 return state
794 }528 }
795 }529 }
@@ -797,7 +531,6 @@ func (h *handlerSet) attachAfterCreatedBrowser(
797 h.wv.browser = browser531 h.wv.browser = browser
798 h.wv.host = host532 h.wv.host = host
799 h.wv.pendingCreate = nil533 h.wv.pendingCreate = nil
800- h.wv.input.setHost(host)
801 if h.wv.findCtrl != nil {534 if h.wv.findCtrl != nil {
802 h.wv.findCtrl.setHost(host)535 h.wv.findCtrl.setHost(host)
803 }536 }
@@ -806,23 +539,46 @@ func (h *handlerSet) attachAfterCreatedBrowser(
806 h.wv.nativePopupParent = nil539 h.wv.nativePopupParent = nil
807 h.wv.nativePopupID = 0540 h.wv.nativePopupID = 0
808 h.wv.nativePopupFallbackStarted = false541 h.wv.nativePopupFallbackStarted = false
542+ state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
543+ h.wv.mu.Unlock()
544+
545+ if h.wv.viewBridge != nil {
546+ wv := h.wv
547+ wv.runOnGTK(func() {
548+ if wv.viewBridge == nil {
549+ return
550+ }
551+ _ = wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{
552+ Scale: wv.viewBridgeScale(),
553+ OnMiddleClick: func(_, _ float64) bool {
554+ return wv.handleMiddleClickFromBridge()
555+ },
556+ SelectionText: wv.selectedTextSnapshot,
557+ OnClipboardShortcut: func(action, text string) {
558+ if wv.engine != nil {
559+ wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
560+ }
561+ },
562+ })
563+ })
564+ }
809 565
810 // Mark browser as visible — CEF OSR starts in hidden state and suppresses566 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
811 // painting/caret updates until explicitly told the browser is shown.567 // painting/caret updates until explicitly told the browser is shown.
812 host.WasHidden(0)568 host.WasHidden(0)
813 state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()569 state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
814- state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
815 return state570 return state
816 }571 }
817 572
818 func (h *handlerSet) afterCreatedShouldSyncFocus() bool {573 func (h *handlerSet) afterCreatedShouldSyncFocus() bool {
819- if h.wv.input == nil {574+ if h == nil || h.wv == nil || h.wv.viewBridge == nil {
820 return false575 return false
821 }576 }
822- if h.wv.input.hasGTKFocus() {577+ focused := false
823- return true578+ h.wv.runOnGTKSync(func() {
824- }579+ focused = h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus()
825- return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus()580+ })
581+ return focused
826 }582 }
827 583
828 func (h *handlerSet) finishAfterCreated(584 func (h *handlerSet) finishAfterCreated(
@@ -841,7 +597,7 @@ func (h *handlerSet) finishAfterCreated(
841 } else {597 } else {
842 // Request an initial OSR frame even before the first real navigation598 // Request an initial OSR frame even before the first real navigation
843 // commits. This restores the about:blank warm-up paint that the stable599 // commits. This restores the about:blank warm-up paint that the stable
844- // startup path relied on and prevents the render pipeline from staying idle.600+ // startup path relied on and prevents the rendering bridge from staying idle.
845 host.Invalidate(purecef.PaintElementTypePetView)601 host.Invalidate(purecef.PaintElementTypePetView)
846 }602 }
847 if state.hasPendingNavigation {603 if state.hasPendingNavigation {
@@ -880,12 +636,18 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
880 h.wv.mu.Lock()636 h.wv.mu.Lock()
881 h.wv.browser = nil637 h.wv.browser = nil
882 h.wv.host = nil638 h.wv.host = nil
883- h.wv.input.setHost(nil)
884 if h.wv.findCtrl != nil {639 if h.wv.findCtrl != nil {
885 h.wv.findCtrl.setHost(nil)640 h.wv.findCtrl.setHost(nil)
886 }641 }
887 h.wv.mu.Unlock()642 h.wv.mu.Unlock()
888- h.wv.resizeReconciler.stop()643+ if h.wv.viewBridge != nil {
644+ wv := h.wv
645+ wv.runOnGTK(func() {
646+ if wv.viewBridge != nil {
647+ _ = wv.viewBridge.SetInputHost(nil)
648+ }
649+ })
650+ }
889 h.wv.scheduleStopBeginFrameLoop()651 h.wv.scheduleStopBeginFrameLoop()
890 652
891 h.wv.mu.RLock()653 h.wv.mu.RLock()
M internal/infrastructure/cef/webview_handlers_test.go +18 -74
@@ -60,13 +60,6 @@ func (s *controlledSelectionScheduler) fire(index int) {
60 }60 }
61 }61 }
62 62
63-func newTestPipeline(w, h, s int32) *renderPipeline {
64- rp := &renderPipeline{scale: s}
65- rp.widthAtomic.Store(w)
66- rp.heightAtomic.Store(h)
67- return rp
68-}
69-
70 type stubFrame struct {63 type stubFrame struct {
71 main bool64 main bool
72 url string65 url string
@@ -309,7 +302,7 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
309 clipboardTextOrchestrator: orchestrator,302 clipboardTextOrchestrator: orchestrator,
310 },303 },
311 }304 }
312- h := &handlerSet{wv: wv}305+ h := &dumberRenderHandler{wv: wv}
313 306
314 h.OnTextSelectionChanged(nil, "selected text", nil)307 h.OnTextSelectionChanged(nil, "selected text", nil)
315 308
@@ -339,7 +332,7 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T)
339 clipboardTextOrchestrator: orchestrator,332 clipboardTextOrchestrator: orchestrator,
340 },333 },
341 }334 }
342- h := &handlerSet{wv: wv}335+ h := &dumberRenderHandler{wv: wv}
343 336
344 h.OnTextSelectionChanged(nil, "first selection", nil)337 h.OnTextSelectionChanged(nil, "first selection", nil)
345 h.OnTextSelectionChanged(nil, "second selection", nil)338 h.OnTextSelectionChanged(nil, "second selection", nil)
@@ -382,7 +375,8 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
382 clipboardTextOrchestrator: orchestrator,375 clipboardTextOrchestrator: orchestrator,
383 },376 },
384 }377 }
385- h := &handlerSet{wv: wv}378+ h := &dumberRenderHandler{wv: wv}
379+ clientHandlers := &handlerSet{wv: wv}
386 frame := cefmocks.NewMockFrame(t)380 frame := cefmocks.NewMockFrame(t)
387 oldFactory := newRendererBridgeProcessMessage381 oldFactory := newRendererBridgeProcessMessage
388 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })382 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
@@ -395,7 +389,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
395 args := message.GetArgumentList()389 args := message.GetArgumentList()
396 require.Equal(t, "editable_focus_changed", args.GetString(0))390 require.Equal(t, "editable_focus_changed", args.GetString(0))
397 require.Equal(t, "1", args.GetString(1))391 require.Equal(t, "1", args.GetString(1))
398- h.OnProcessMessageReceived(nil, nil, 0, message)392+ clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
399 }).Once()393 }).Once()
400 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})394 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
401 395
@@ -416,7 +410,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
416 args := message.GetArgumentList()410 args := message.GetArgumentList()
417 require.Equal(t, "editable_focus_changed", args.GetString(0))411 require.Equal(t, "editable_focus_changed", args.GetString(0))
418 require.Equal(t, "0", args.GetString(1))412 require.Equal(t, "0", args.GetString(1))
419- h.OnProcessMessageReceived(nil, nil, 0, message)413+ clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
420 }).Once()414 }).Once()
421 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})415 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
422 416
@@ -455,7 +449,7 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te
455 clipboardTextOrchestrator: orchestrator,449 clipboardTextOrchestrator: orchestrator,
456 },450 },
457 }451 }
458- h := &handlerSet{wv: wv}452+ h := &dumberRenderHandler{wv: wv}
459 453
460 h.OnTextSelectionChanged(nil, "selected text", nil)454 h.OnTextSelectionChanged(nil, "selected text", nil)
461 require.Len(t, scheduler.callbacks, 1)455 require.Len(t, scheduler.callbacks, 1)
@@ -500,49 +494,23 @@ func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return
500 func (n stubEditableDomnode) GetElementInnerText() string { return "" }494 func (n stubEditableDomnode) GetElementInnerText() string { return "" }
501 func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }495 func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }
502 496
503-func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
504- rect := &purecef.Rect{}
505- h := &handlerSet{
506- wv: &WebView{
507- ctx: context.Background(),
508- pipeline: newTestPipeline(800, 600, 2),
509- },
510- }
511-
512- h.GetViewRect(nil, rect)
513-
514- require.Equal(t, int32(0), rect.X)
515- require.Equal(t, int32(0), rect.Y)
516- require.Equal(t, int32(400), rect.Width)
517- require.Equal(t, int32(300), rect.Height)
518-}
519-
520-func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) {
521- si := purecef.NewScreenInfo()
522- info := &si
523- h := &handlerSet{
524- wv: &WebView{
525- ctx: context.Background(),
526- pipeline: newTestPipeline(1500, 900, 3),
527- },
528- }
529-
530- ok := h.GetScreenInfo(nil, info)
531-
532- require.Equal(t, int32(1), ok)
533- require.InEpsilon(t, float32(3), info.DeviceScaleFactor, 0.001)
534- require.Equal(t, int32(500), info.Rect.Width)
535- require.Equal(t, int32(300), info.Rect.Height)
536- require.Equal(t, int32(500), info.AvailableRect.Width)
537- require.Equal(t, int32(300), info.AvailableRect.Height)
538-}
539-
540 func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {497 func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
541 h := &handlerSet{}498 h := &handlerSet{}
542 499
543 // AudioHandler is always enabled (required for media decoding).500 // AudioHandler is always enabled (required for media decoding).
544 require.Same(t, h, h.GetAudioHandler())501 require.Same(t, h, h.GetAudioHandler())
545 require.Same(t, h, h.GetContextMenuHandler())502 require.Same(t, h, h.GetContextMenuHandler())
503+ require.IsType(t, &dumberRenderHandler{}, h.GetRenderHandler())
504+}
505+
506+func TestGetRenderHandlerPreservesTextSelectionCallback(t *testing.T) {
507+ wv := &WebView{ctx: context.Background()}
508+ h := &handlerSet{wv: wv}
509+
510+ rh := h.GetRenderHandler()
511+ rh.OnTextSelectionChanged(nil, "selected via client", nil)
512+
513+ require.Equal(t, "selected via client", wv.selectedTextSnapshot())
546 }514 }
547 515
548 func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {516 func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
@@ -558,27 +526,3 @@ func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
558 526
559 require.Same(t, h, h.GetDownloadHandler())527 require.Same(t, h, h.GetDownloadHandler())
560 }528 }
561-
562-func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
563- wv := &WebView{
564- id: 42,
565- pipeline: &renderPipeline{
566- ctx: context.Background(),
567- scale: 1,
568- },
569- }
570- wv.pipeline.widthAtomic.Store(1269)
571- wv.pipeline.heightAtomic.Store(1035)
572-
573- h := &handlerSet{wv: wv}
574- buffer := make([]byte, 1269*2106*4)
575-
576- h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106)
577-
578- require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load())
579- require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load())
580- require.Nil(t, wv.pipeline.staging)
581- require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load())
582- require.False(t, wv.pipeline.needsUpload)
583- require.Empty(t, wv.pipeline.dirtyRects)
584-}

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