Use purego-cef2gtk for CEF webviews
- commit
- 5eb26e149fe794599101813d87fb078ee34871e0
- author
- brice · · 2026-04-29 19:43 UTC
- parent
- e77e492
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | 58 | // NativeWidget returns the uintptr of the GLArea for embedding via | |
| 59 | - | // port.NativeWidgetProvider. | |
| 59 | + | // port.NativeWidgetProvider. Call on the GTK main thread. | |
| 60 | 60 | func (a *Cef2gtkAdapter) NativeWidget() uintptr { | |
| 61 | 61 | area := a.GLArea() | |
| 62 | 62 | if area == nil { | |
| @@ -173,8 +173,9 @@ func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics { | |||
| 173 | 173 | ||
| 174 | 174 | // Destroy releases GL resources owned by purego-cef2gtk and disconnects GTK | |
| 175 | 175 | // signal handlers. Must be called on the GTK main thread. Not safe for | |
| 176 | - | // concurrent use. After Destroy, the adapter is marked destroyed and all | |
| 177 | - | // subsequent operations return ErrAdapterDestroyed. | |
| 176 | + | // concurrent use; callers must ensure no concurrent adapter reads are in flight. | |
| 177 | + | // After Destroy, the adapter is marked destroyed and all subsequent operations | |
| 178 | + | // return ErrAdapterDestroyed. | |
| 178 | 179 | func (a *Cef2gtkAdapter) Destroy() error { | |
| 179 | 180 | if a == nil { | |
| 180 | 181 | 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 | 67 | 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 | 71 | return 0 | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale) | |
| 75 | - | glArea := h.wv.pipeline.glArea | |
| 76 | - | logContextMenuPopupRequest(h, glArea, params, x, y) | |
| 74 | + | x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale()) | |
| 77 | 75 | executor := h.contextMenuExecutor() | |
| 78 | - | ||
| 79 | - | NewRenderer(h.wv.runOnGTK).Show( | |
| 80 | - | items, | |
| 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() { | |
| 76 | + | wv := h.wv | |
| 77 | + | wv.runOnGTK(func() { | |
| 78 | + | if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil { | |
| 92 | 79 | callback.Cancel() | |
| 93 | - | }, | |
| 94 | - | ) | |
| 80 | + | return | |
| 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 | 101 | return 1 | |
| 96 | 102 | } | |
| 97 | 103 | ||
| @@ -123,7 +129,7 @@ func logContextMenuPopupRequest( | |||
| 123 | 129 | params purecef.ContextMenuParams, | |
| 124 | 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 | 133 | return | |
| 128 | 134 | } | |
| 129 | 135 | rawX, rawY := contextMenuRawPosition(params) | |
| @@ -133,7 +139,7 @@ func logContextMenuPopupRequest( | |||
| 133 | 139 | Int32("raw_y", rawY). | |
| 134 | 140 | Int32("popup_x", x). | |
| 135 | 141 | Int32("popup_y", y). | |
| 136 | - | Int32("scale", h.wv.pipeline.scale). | |
| 142 | + | Int32("scale", h.wv.viewBridgeScale()). | |
| 137 | 143 | Int("anchor_width", glArea.Widget.GetAllocatedWidth()). | |
| 138 | 144 | Int("anchor_height", glArea.Widget.GetAllocatedHeight()). | |
| 139 | 145 | Int("parent_width", cefWidgetAllocatedWidth(parent)). | |
M internal/infrastructure/cef/engine.go +0 -1
| @@ -24,7 +24,6 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | |||
| 24 | 24 | // It manages the CEF lifecycle and provides access to all engine subsystems. | |
| 25 | 25 | type Engine struct { | |
| 26 | 26 | ctx context.Context | |
| 27 | - | gl *glLoader | |
| 28 | 27 | factory *WebViewFactory | |
| 29 | 28 | 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 | 159 | 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 | 163 | func wireEngine( | |
| 164 | 164 | ctx context.Context, eng *Engine, | |
| 165 | 165 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 166 | 166 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| 167 | 167 | ) (*Engine, error) { | |
| 168 | - | gl, err := newGLLoader() | |
| 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{ | |
| 168 | + | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | |
| 176 | 169 | scale: detectHiDPIScale(logger), | |
| 177 | 170 | windowlessFrameRate: windowlessFrameRate, | |
| 178 | 171 | audioOutputFactory: audioFactory, | |
M internal/infrastructure/cef/factory.go +64 -86
| @@ -17,11 +17,10 @@ import ( | |||
| 17 | 17 | var _ port.WebViewFactory = (*WebViewFactory)(nil) | |
| 18 | 18 | ||
| 19 | 19 | // WebViewFactory creates new CEF off-screen browser WebViews. Each WebView | |
| 20 | - | // gets a unique ID, its own renderPipeline and inputBridge, and an | |
| 21 | - | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | |
| 20 | + | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | |
| 21 | + | // CEF browser (via BrowserHostCreateBrowser). | |
| 22 | 22 | type WebViewFactory struct { | |
| 23 | 23 | engine *Engine | |
| 24 | - | gl *glLoader | |
| 25 | 24 | nextID atomic.Uint64 | |
| 26 | 25 | scale int32 | |
| 27 | 26 | windowlessFrameRate int32 | |
| @@ -48,8 +47,8 @@ const ( | |||
| 48 | 47 | ) | |
| 49 | 48 | ||
| 50 | 49 | // newWebViewFactory returns a factory that will create WebViews using the | |
| 51 | - | // given GL loader and HiDPI scale factor. | |
| 52 | - | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | |
| 50 | + | // given HiDPI scale factor. | |
| 51 | + | func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory { | |
| 53 | 52 | if opts.scale < 1 { | |
| 54 | 53 | opts.scale = 1 | |
| 55 | 54 | } | |
| @@ -58,10 +57,9 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 58 | 57 | } | |
| 59 | 58 | return &WebViewFactory{ | |
| 60 | 59 | engine: engine, | |
| 61 | - | gl: gl, | |
| 62 | 60 | scale: opts.scale, | |
| 63 | 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 | 91 | 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 | 95 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | |
| 98 | 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 | 99 | return wv, nil | |
| 102 | 100 | } | |
| 103 | 101 | ||
| 104 | 102 | func (f *WebViewFactory) newWebView(ctx context.Context) *WebView { | |
| 105 | 103 | id := port.WebViewID(f.nextID.Add(1)) | |
| 106 | - | pipeline := newRenderPipeline(ctx, f.gl, f.scale, id) | |
| 104 | + | viewBridge := NewCef2gtkAdapter() | |
| 107 | 105 | ||
| 108 | 106 | wv := &WebView{ | |
| 109 | 107 | id: id, | |
| 110 | 108 | ctx: ctx, | |
| 111 | 109 | engine: f.engine, | |
| 112 | - | pipeline: pipeline, | |
| 110 | + | viewBridge: viewBridge, | |
| 113 | 111 | audioOutputFactory: f.audioOutputFactory, | |
| 114 | - | resizeReconciler: newResizeReconciler(ctx, id), | |
| 115 | 112 | windowlessFrameRate: f.windowlessFrameRate, | |
| 116 | 113 | backgroundColor: f.bgColor.Load(), | |
| 117 | 114 | } | |
| 118 | 115 | ||
| 119 | - | handlers := &handlerSet{ | |
| 120 | - | wv: wv, | |
| 121 | - | } | |
| 116 | + | handlers := &handlerSet{wv: wv} | |
| 122 | 117 | wv.handlers = handlers | |
| 123 | 118 | wv.findCtrl = newFindController() | |
| 124 | 119 | ||
| 125 | - | input := newInputBridge(ctx, f.scale) | |
| 126 | - | input.selectionText = wv.selectedTextSnapshot | |
| 127 | - | input.explicitCopyText = func(action, text string) { | |
| 128 | - | if f.engine != nil { | |
| 129 | - | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 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) | |
| 120 | + | if viewBridge != nil { | |
| 121 | + | wv.runOnGTKSync(func() { | |
| 122 | + | if err := viewBridge.PrepareOnGTKThread(); err != nil && ctx != nil { | |
| 123 | + | logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef: failed to prepare cef2gtk view") | |
| 124 | + | } | |
| 147 | 125 | }) | |
| 148 | 126 | } | |
| 149 | 127 | ||
| @@ -154,64 +132,65 @@ func (f *WebViewFactory) newWebView(ctx context.Context) *WebView { | |||
| 154 | 132 | } | |
| 155 | 133 | ||
| 156 | 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 | 136 | windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings, | |
| 137 | + | onFirstResize func(w, h int32), | |
| 159 | 138 | ) { | |
| 160 | 139 | wv.pendingCreate = &pendingBrowserCreate{ | |
| 161 | 140 | windowInfo: windowInfo, | |
| 162 | 141 | client: client, | |
| 163 | 142 | settings: settings, | |
| 164 | 143 | } | |
| 165 | - | ||
| 166 | - | pipeline.onFirstResize = func(w, h int32) { | |
| 167 | - | logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 168 | - | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 144 | + | if onFirstResize == nil { | |
| 145 | + | onFirstResize = func(w, h int32) { | |
| 146 | + | logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 147 | + | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 148 | + | } | |
| 169 | 149 | } | |
| 170 | - | ||
| 171 | - | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | |
| 172 | - | pipeline.onResizeCB = func(w, h int32) { | |
| 173 | - | log := logging.FromContext(ctx) | |
| 174 | - | wv.mu.RLock() | |
| 175 | - | host := wv.host | |
| 176 | - | wv.mu.RUnlock() | |
| 177 | - | if host == nil { | |
| 150 | + | if wv.viewBridge == nil { | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | firstResize := true | |
| 154 | + | wv.runOnGTKSync(func() { | |
| 155 | + | wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) { | |
| 156 | + | if firstResize { | |
| 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 | 177 | log.Debug(). | |
| 179 | 178 | Uint64("webview_id", uint64(wv.id)). | |
| 180 | 179 | Int32("resize_width", w). | |
| 181 | 180 | Int32("resize_height", h). | |
| 182 | - | Bool("host_nil", true). | |
| 183 | - | Bool("browser_ready", false). | |
| 184 | - | Msg("cef: resize observed before browser host existed") | |
| 185 | - | return | |
| 186 | - | } | |
| 187 | - | log.Debug(). | |
| 188 | - | Uint64("webview_id", uint64(wv.id)). | |
| 189 | - | Int32("resize_width", w). | |
| 190 | - | Int32("resize_height", h). | |
| 191 | - | Bool("host_nil", false). | |
| 192 | - | Bool("browser_ready", true). | |
| 193 | - | Msg("cef: browser resize observed before WasResized") | |
| 194 | - | notifyBrowserResize(host) | |
| 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 | - | } | |
| 181 | + | Bool("host_nil", false). | |
| 182 | + | Bool("browser_ready", true). | |
| 183 | + | Msg("cef: browser resize observed before WasResized") | |
| 184 | + | notifyBrowserResize(host) | |
| 185 | + | log.Debug(). | |
| 186 | + | Uint64("webview_id", uint64(wv.id)). | |
| 187 | + | Int32("resize_width", w). | |
| 188 | + | Int32("resize_height", h). | |
| 189 | + | Bool("host_nil", false). | |
| 190 | + | Bool("browser_ready", true). | |
| 191 | + | Msg("cef: browser host WasResized invoked") | |
| 192 | + | }) | |
| 193 | + | }) | |
| 215 | 194 | } | |
| 216 | 195 | ||
| 217 | 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 | 315 | settings.BackgroundColor = bg | |
| 337 | 316 | } | |
| 338 | 317 | ||
| 339 | - | f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings) | |
| 340 | - | popupWV.pipeline.onFirstResize = func(w, h int32) { | |
| 318 | + | f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) { | |
| 341 | 319 | log := logging.FromContext(ctx) | |
| 342 | 320 | if popupWV.awaitsNativePopupAttachment() { | |
| 343 | 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 | 337 | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly") | |
| 360 | 338 | f.postPendingBrowserCreate(ctx, popupWV, w, h) | |
| 361 | - | } | |
| 339 | + | }) | |
| 362 | 340 | 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 | 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 rendered | |
| 68 | - | // through a renderPipeline and driven by an inputBridge. | |
| 67 | + | // WebView implements port.WebView using a CEF off-screen browser rendered and | |
| 68 | + | // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the | |
| 69 | + | // bridge owns GTK rendering and input forwarding. | |
| 69 | 70 | type WebView struct { | |
| 70 | - | id port.WebViewID | |
| 71 | - | ctx context.Context | |
| 72 | - | engine *Engine | |
| 73 | - | browser purecef.Browser | |
| 74 | - | host purecef.BrowserHost | |
| 75 | - | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| 76 | - | pipeline *renderPipeline | |
| 77 | - | input *inputBridge | |
| 78 | - | handlers *handlerSet | |
| 79 | - | findCtrl *cefFindController | |
| 80 | - | resizeReconciler *resizeReconciler | |
| 71 | + | id port.WebViewID | |
| 72 | + | ctx context.Context | |
| 73 | + | engine *Engine | |
| 74 | + | browser purecef.Browser | |
| 75 | + | host purecef.BrowserHost | |
| 76 | + | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| 77 | + | viewBridge *Cef2gtkAdapter | |
| 78 | + | handlers *handlerSet | |
| 79 | + | findCtrl *cefFindController | |
| 80 | + | ||
| 81 | + | removeSizeObserver func() | |
| 81 | 82 | ||
| 82 | 83 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 83 | 84 | // widget is visible. Access is guarded by mu. | |
| @@ -1009,9 +1010,6 @@ func (wv *WebView) Destroy() { | |||
| 1009 | 1010 | wv.mu.Unlock() | |
| 1010 | 1011 | wv.stopNativePopupFallbackTimer() | |
| 1011 | 1012 | wv.cancelSelectionDebounce() | |
| 1012 | - | if wv.resizeReconciler != nil { | |
| 1013 | - | wv.resizeReconciler.stop() | |
| 1014 | - | } | |
| 1015 | 1013 | wv.closeAudioStream() | |
| 1016 | 1014 | wv.scheduleStopBeginFrameLoop() | |
| 1017 | 1015 | wv.mu.RLock() | |
| @@ -1022,8 +1020,19 @@ func (wv *WebView) Destroy() { | |||
| 1022 | 1020 | } else { | |
| 1023 | 1021 | wv.runCloseCallbacks() | |
| 1024 | 1022 | } | |
| 1025 | - | if wv.pipeline != nil { | |
| 1026 | - | wv.pipeline.destroy() | |
| 1023 | + | if wv.viewBridge != nil { | |
| 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 | 1040 | // 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 | 1044 | func (wv *WebView) NativeWidget() uintptr { | |
| 1036 | - | if wv.pipeline == nil || wv.pipeline.glArea == nil { | |
| 1045 | + | if wv.viewBridge == nil { | |
| 1037 | 1046 | 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 | 1278 | 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 | 1310 | func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) { | |
| 1267 | 1311 | for _, delay := range cefLoadWatchdogDelays { | |
| 1268 | 1312 | d := delay | |
| @@ -1294,9 +1338,18 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |||
| 1294 | 1338 | return | |
| 1295 | 1339 | } | |
| 1296 | 1340 | ||
| 1297 | - | snap := renderPipelineSnapshot{} | |
| 1298 | - | if wv.pipeline != nil { | |
| 1299 | - | snap = wv.pipeline.diagnosticSnapshot() | |
| 1341 | + | var acceleratedPaints, unsupportedPaints, acceleratedPaintErrors, importFailures, renderFailures int | |
| 1342 | + | var surfaceWidth, surfaceHeight int32 = 1, 1 | |
| 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 | 1355 | now := time.Now() | |
| @@ -1311,24 +1364,14 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |||
| 1311 | 1364 | Str("title", title). | |
| 1312 | 1365 | Float64("progress", progress). | |
| 1313 | 1366 | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 1314 | - | Int32("surface_width", snap.Width). | |
| 1315 | - | Int32("surface_height", snap.Height). | |
| 1316 | - | Int32("surface_scale", snap.Scale). | |
| 1317 | - | Bool("gl_ready", snap.GLReady). | |
| 1318 | - | Bool("needs_upload", snap.NeedsUpload). | |
| 1319 | - | Bool("size_changed", snap.SizeChanged). | |
| 1320 | - | Bool("force_full_upload", snap.ForceFullUpload). | |
| 1321 | - | Uint64("view_rect_seq", snap.ViewRectSeq). | |
| 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). | |
| 1367 | + | Int32("surface_width", surfaceWidth). | |
| 1368 | + | Int32("surface_height", surfaceHeight). | |
| 1369 | + | Float32("surface_scale", surfaceScale). | |
| 1370 | + | Int("accelerated_paints", acceleratedPaints). | |
| 1371 | + | Int("unsupported_paints", unsupportedPaints). | |
| 1372 | + | Int("accelerated_paint_errors", acceleratedPaintErrors). | |
| 1373 | + | Int("import_failures", importFailures). | |
| 1374 | + | Int("render_failures", renderFailures). | |
| 1332 | 1375 | Msg("cef: loading watchdog") | |
| 1333 | 1376 | } | |
| 1334 | 1377 | ||
| @@ -1524,7 +1567,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() { | |||
| 1524 | 1567 | } | |
| 1525 | 1568 | ||
| 1526 | 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 | 1571 | return | |
| 1529 | 1572 | } | |
| 1530 | 1573 | ||
| @@ -1534,7 +1577,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1534 | 1577 | return | |
| 1535 | 1578 | } | |
| 1536 | 1579 | ||
| 1537 | - | glArea := wv.pipeline.glArea | |
| 1580 | + | glArea := wv.viewBridge.GLArea() | |
| 1538 | 1581 | cb := new(gtk.TickCallback) | |
| 1539 | 1582 | *cb = func(_, _, _ uintptr) bool { | |
| 1540 | 1583 | if wv.destroyed.Load() { | |
| @@ -1560,7 +1603,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1560 | 1603 | } | |
| 1561 | 1604 | ||
| 1562 | 1605 | func (wv *WebView) stopBeginFrameLoop() { | |
| 1563 | - | if wv.pipeline == nil || wv.pipeline.glArea == nil { | |
| 1606 | + | if wv.viewBridge == nil || wv.viewBridge.GLArea() == nil { | |
| 1564 | 1607 | return | |
| 1565 | 1608 | } | |
| 1566 | 1609 | ||
| @@ -1568,7 +1611,7 @@ func (wv *WebView) stopBeginFrameLoop() { | |||
| 1568 | 1611 | tickID := wv.beginFrameTickID | |
| 1569 | 1612 | wv.beginFrameTickID = 0 | |
| 1570 | 1613 | wv.beginFrameTick = nil | |
| 1571 | - | glArea := wv.pipeline.glArea | |
| 1614 | + | glArea := wv.viewBridge.GLArea() | |
| 1572 | 1615 | wv.mu.Unlock() | |
| 1573 | 1616 | ||
| 1574 | 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 | 32 | assert.Equal(t, audioFactory, opts.audioOutputFactory) | |
| 33 | 33 | ||
| 34 | 34 | // Verify the factory is passed through | |
| 35 | - | factory := newWebViewFactory(&Engine{}, &glLoader{}, opts) | |
| 35 | + | factory := newWebViewFactory(&Engine{}, opts) | |
| 36 | 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 | 49 | // Act | |
| 50 | - | factory := newWebViewFactory(&Engine{}, &glLoader{}, opts) | |
| 50 | + | factory := newWebViewFactory(&Engine{}, opts) | |
| 51 | 51 | ||
| 52 | 52 | // Assert | |
| 53 | 53 | require.NotNil(t, factory) | |
| @@ -423,7 +423,6 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||
| 423 | 423 | wv := &WebView{ | |
| 424 | 424 | ctx: context.Background(), | |
| 425 | 425 | activeAudioStream: mockStream, | |
| 426 | - | pipeline: &renderPipeline{}, | |
| 427 | 426 | } | |
| 428 | 427 | wv.audioPlaying.Store(true) | |
| 429 | 428 | ||
| @@ -439,8 +438,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||
| 439 | 438 | // handles the case where no audio stream is active. | |
| 440 | 439 | func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) { | |
| 441 | 440 | wv := &WebView{ | |
| 442 | - | ctx: context.Background(), | |
| 443 | - | pipeline: &renderPipeline{}, | |
| 441 | + | ctx: context.Background(), | |
| 444 | 442 | } | |
| 445 | 443 | ||
| 446 | 444 | assert.NotPanics(t, func() { | |
M internal/infrastructure/cef/webview_handlers.go +67 -305
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| @@ -27,7 +28,6 @@ type handlerSet struct { | |||
| 27 | 28 | // Compile-time interface checks. | |
| 28 | 29 | var ( | |
| 29 | 30 | _ purecef.Client = (*handlerSet)(nil) | |
| 30 | - | _ purecef.RenderHandler = (*handlerSet)(nil) | |
| 31 | 31 | _ purecef.DisplayHandler = (*handlerSet)(nil) | |
| 32 | 32 | _ purecef.LoadHandler = (*handlerSet)(nil) | |
| 33 | 33 | _ purecef.LifeSpanHandler = (*handlerSet)(nil) | |
| @@ -69,8 +69,13 @@ func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return n | |||
| 69 | 69 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | |
| 70 | 70 | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |
| 71 | 71 | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } | |
| 72 | - | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 73 | - | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 72 | + | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { | |
| 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 | 80 | func (h *handlerSet) OnProcessMessageReceived( | |
| 76 | 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 | 150 | // DisplayHandler (13 methods) | |
| 426 | 151 | // =========================================================================== | |
| @@ -528,9 +253,14 @@ func (h *handlerSet) OnCursorChange( | |||
| 528 | 253 | cursorType purecef.CursorType, | |
| 529 | 254 | _ *purecef.CursorInfo, | |
| 530 | 255 | ) int32 { | |
| 256 | + | if h == nil || h.wv == nil { | |
| 257 | + | return 0 | |
| 258 | + | } | |
| 531 | 259 | name := cefCursorToGDKName(cursorType) | |
| 532 | 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 | 265 | return 1 // handled | |
| 536 | 266 | } | |
| @@ -565,13 +295,17 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac | |||
| 565 | 295 | Msg("cef: OnLoadingStateChange") | |
| 566 | 296 | } | |
| 567 | 297 | ||
| 568 | - | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 569 | - | h.wv.mu.RLock() | |
| 570 | - | host := h.wv.host | |
| 571 | - | h.wv.mu.RUnlock() | |
| 572 | - | if host != nil { | |
| 573 | - | syncWindowlessBrowserFocus(host) | |
| 574 | - | } | |
| 298 | + | if !loading && h.wv.viewBridge != nil { | |
| 299 | + | h.wv.runOnGTK(func() { | |
| 300 | + | if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() { | |
| 301 | + | h.wv.mu.RLock() | |
| 302 | + | host := h.wv.host | |
| 303 | + | h.wv.mu.RUnlock() | |
| 304 | + | if host != nil { | |
| 305 | + | syncWindowlessBrowserFocus(host) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | }) | |
| 575 | 309 | } | |
| 576 | 310 | ||
| 577 | 311 | h.wv.mu.RLock() | |
| @@ -783,13 +517,13 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 783 | 517 | ) afterCreatedState { | |
| 784 | 518 | state := afterCreatedState{} | |
| 785 | 519 | h.wv.mu.Lock() | |
| 786 | - | defer h.wv.mu.Unlock() | |
| 787 | 520 | ||
| 788 | 521 | if existing := h.wv.browser; existing != nil { | |
| 789 | 522 | existingID := existing.GetIdentifier() | |
| 790 | 523 | if existingID != 0 && existingID != browserID { | |
| 791 | 524 | state.closeDuplicate = true | |
| 792 | 525 | state.duplicateBrowserID = existingID | |
| 526 | + | h.wv.mu.Unlock() | |
| 793 | 527 | return state | |
| 794 | 528 | } | |
| 795 | 529 | } | |
| @@ -797,7 +531,6 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 797 | 531 | h.wv.browser = browser | |
| 798 | 532 | h.wv.host = host | |
| 799 | 533 | h.wv.pendingCreate = nil | |
| 800 | - | h.wv.input.setHost(host) | |
| 801 | 534 | if h.wv.findCtrl != nil { | |
| 802 | 535 | h.wv.findCtrl.setHost(host) | |
| 803 | 536 | } | |
| @@ -806,23 +539,46 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 806 | 539 | h.wv.nativePopupParent = nil | |
| 807 | 540 | h.wv.nativePopupID = 0 | |
| 808 | 541 | 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 | 566 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | |
| 811 | 567 | // painting/caret updates until explicitly told the browser is shown. | |
| 812 | 568 | host.WasHidden(0) | |
| 813 | 569 | state.shouldSyncFocus = h.afterCreatedShouldSyncFocus() | |
| 814 | - | state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |
| 815 | 570 | return state | |
| 816 | 571 | } | |
| 817 | 572 | ||
| 818 | 573 | func (h *handlerSet) afterCreatedShouldSyncFocus() bool { | |
| 819 | - | if h.wv.input == nil { | |
| 574 | + | if h == nil || h.wv == nil || h.wv.viewBridge == nil { | |
| 820 | 575 | return false | |
| 821 | 576 | } | |
| 822 | - | if h.wv.input.hasGTKFocus() { | |
| 823 | - | return true | |
| 824 | - | } | |
| 825 | - | return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() | |
| 577 | + | focused := false | |
| 578 | + | h.wv.runOnGTKSync(func() { | |
| 579 | + | focused = h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() | |
| 580 | + | }) | |
| 581 | + | return focused | |
| 826 | 582 | } | |
| 827 | 583 | ||
| 828 | 584 | func (h *handlerSet) finishAfterCreated( | |
| @@ -841,7 +597,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 841 | 597 | } else { | |
| 842 | 598 | // Request an initial OSR frame even before the first real navigation | |
| 843 | 599 | // 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 | 601 | host.Invalidate(purecef.PaintElementTypePetView) | |
| 846 | 602 | } | |
| 847 | 603 | if state.hasPendingNavigation { | |
| @@ -880,12 +636,18 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 880 | 636 | h.wv.mu.Lock() | |
| 881 | 637 | h.wv.browser = nil | |
| 882 | 638 | h.wv.host = nil | |
| 883 | - | h.wv.input.setHost(nil) | |
| 884 | 639 | if h.wv.findCtrl != nil { | |
| 885 | 640 | h.wv.findCtrl.setHost(nil) | |
| 886 | 641 | } | |
| 887 | 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 | 651 | h.wv.scheduleStopBeginFrameLoop() | |
| 890 | 652 | ||
| 891 | 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 | 63 | type stubFrame struct { | |
| 71 | 64 | main bool | |
| 72 | 65 | url string | |
| @@ -309,7 +302,7 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test | |||
| 309 | 302 | clipboardTextOrchestrator: orchestrator, | |
| 310 | 303 | }, | |
| 311 | 304 | } | |
| 312 | - | h := &handlerSet{wv: wv} | |
| 305 | + | h := &dumberRenderHandler{wv: wv} | |
| 313 | 306 | ||
| 314 | 307 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 315 | 308 | ||
| @@ -339,7 +332,7 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) | |||
| 339 | 332 | clipboardTextOrchestrator: orchestrator, | |
| 340 | 333 | }, | |
| 341 | 334 | } | |
| 342 | - | h := &handlerSet{wv: wv} | |
| 335 | + | h := &dumberRenderHandler{wv: wv} | |
| 343 | 336 | ||
| 344 | 337 | h.OnTextSelectionChanged(nil, "first selection", nil) | |
| 345 | 338 | h.OnTextSelectionChanged(nil, "second selection", nil) | |
| @@ -382,7 +375,8 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 382 | 375 | clipboardTextOrchestrator: orchestrator, | |
| 383 | 376 | }, | |
| 384 | 377 | } | |
| 385 | - | h := &handlerSet{wv: wv} | |
| 378 | + | h := &dumberRenderHandler{wv: wv} | |
| 379 | + | clientHandlers := &handlerSet{wv: wv} | |
| 386 | 380 | frame := cefmocks.NewMockFrame(t) | |
| 387 | 381 | oldFactory := newRendererBridgeProcessMessage | |
| 388 | 382 | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |
| @@ -395,7 +389,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 395 | 389 | args := message.GetArgumentList() | |
| 396 | 390 | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 397 | 391 | require.Equal(t, "1", args.GetString(1)) | |
| 398 | - | h.OnProcessMessageReceived(nil, nil, 0, message) | |
| 392 | + | clientHandlers.OnProcessMessageReceived(nil, nil, 0, message) | |
| 399 | 393 | }).Once() | |
| 400 | 394 | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | |
| 401 | 395 | ||
| @@ -416,7 +410,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 416 | 410 | args := message.GetArgumentList() | |
| 417 | 411 | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 418 | 412 | require.Equal(t, "0", args.GetString(1)) | |
| 419 | - | h.OnProcessMessageReceived(nil, nil, 0, message) | |
| 413 | + | clientHandlers.OnProcessMessageReceived(nil, nil, 0, message) | |
| 420 | 414 | }).Once() | |
| 421 | 415 | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false}) | |
| 422 | 416 | ||
| @@ -455,7 +449,7 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te | |||
| 455 | 449 | clipboardTextOrchestrator: orchestrator, | |
| 456 | 450 | }, | |
| 457 | 451 | } | |
| 458 | - | h := &handlerSet{wv: wv} | |
| 452 | + | h := &dumberRenderHandler{wv: wv} | |
| 459 | 453 | ||
| 460 | 454 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 461 | 455 | require.Len(t, scheduler.callbacks, 1) | |
| @@ -500,49 +494,23 @@ func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return | |||
| 500 | 494 | func (n stubEditableDomnode) GetElementInnerText() string { return "" } | |
| 501 | 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 | 497 | func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |
| 541 | 498 | h := &handlerSet{} | |
| 542 | 499 | ||
| 543 | 500 | // AudioHandler is always enabled (required for media decoding). | |
| 544 | 501 | require.Same(t, h, h.GetAudioHandler()) | |
| 545 | 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 | 516 | func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |
| @@ -558,27 +526,3 @@ func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |||
| 558 | 526 | ||
| 559 | 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 | - | } | |