1package cef
2
3import (
4 "context"
5 "strings"
6 "time"
7
8 purecef "github.com/bnema/purego-cef/cef"
9 "github.com/bnema/puregotk/v4/gobject"
10 "github.com/bnema/puregotk/v4/gtk"
11
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/shared/syncdispatch"
14)
15
16type viewportSyncBrowserHost interface {
17 resizeNotifiableBrowserHost
18 NotifyScreenInfoChanged()
19 WasHidden(int32)
20}
21
22type pendingBrowserCreateObservedSizeBridge interface {
23 Size() (int32, int32)
24 RefreshObservedSizeOnGTKThread() (int32, int32)
25}
26
27const (
28 // pendingBrowserCreateObservedSizeRetryDelay spaces retries while waiting
29 // for the GTK bridge to observe the widget's allocated size.
30 pendingBrowserCreateObservedSizeRetryDelay = 16 * time.Millisecond
31 // pendingBrowserCreateObservedSizeMaxRetries caps observed-size retries
32 // before browser creation proceeds despite a remaining size mismatch.
33 pendingBrowserCreateObservedSizeMaxRetries = 8
34)
35
36type pendingBrowserCreateObservedSizeRetryAction uint8
37
38const (
39 pendingBrowserCreateObservedSizeRetryUnavailable pendingBrowserCreateObservedSizeRetryAction = iota
40 pendingBrowserCreateObservedSizeRetryAlreadyScheduled
41 pendingBrowserCreateObservedSizeRetryScheduled
42 pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
43)
44
45// SyncViewport requests an explicit viewport resync for CEF OSR browsers.
46//
47// This is a hardening path for UI lifecycle transitions that may not emit a
48// fresh size change (reparenting, stacked visibility toggles, floating pane
49// updates, popup show, sibling promotion after close, etc.). Calls are
50// coalesced and executed on the GTK thread before notifying the CEF host.
51func (wv *WebView) SyncViewport(ctx context.Context, reason string) {
52 if wv == nil || wv.destroyed.Load() {
53 return
54 }
55 reason = strings.TrimSpace(reason)
56 if reason == "" {
57 reason = "manual"
58 }
59
60 wv.viewportSyncMu.Lock()
61 wv.viewportSyncReason = reason
62 if wv.viewportSyncPending {
63 wv.viewportSyncMu.Unlock()
64 return
65 }
66 wv.viewportSyncPending = true
67 wv.viewportSyncMu.Unlock()
68
69 wv.runOnGTK(func() {
70 wv.syncViewportOnGTK(wv.viewportSyncContext(ctx))
71 })
72}
73
74func (wv *WebView) viewportSyncContext(ctx context.Context) context.Context {
75 if ctx != nil {
76 return ctx
77 }
78 if wv != nil && wv.ctx != nil {
79 return wv.ctx
80 }
81 return context.Background()
82}
83
84func (wv *WebView) takeViewportSyncReason() string {
85 wv.viewportSyncMu.Lock()
86 defer wv.viewportSyncMu.Unlock()
87 reason := wv.viewportSyncReason
88 wv.viewportSyncReason = ""
89 wv.viewportSyncPending = false
90 return reason
91}
92
93func (wv *WebView) syncViewportOnGTK(ctx context.Context) {
94 if wv == nil {
95 return
96 }
97 reason := wv.takeViewportSyncReason()
98 ctx = wv.viewportSyncContext(ctx)
99 if wv.syncViewportNowOnGTK(ctx, reason) {
100 wv.scheduleResizeRepaintPulse(ctx, reason)
101 }
102}
103
104func (wv *WebView) syncViewportNowOnGTK(ctx context.Context, reason string) bool {
105 if wv == nil {
106 return false
107 }
108 reason = strings.TrimSpace(reason)
109 if reason == "" {
110 reason = "manual"
111 }
112 if wv.destroyed.Load() || wv.viewBridge == nil {
113 return false
114 }
115
116 if wv.tryStartPendingBrowserCreateOnGTKThread(ctx, reason) {
117 return false
118 }
119
120 wv.mu.RLock()
121 host := wv.host
122 wv.mu.RUnlock()
123 if host == nil {
124 logging.FromContext(ctx).Debug().
125 Uint64("webview_id", uint64(wv.id)).
126 Str("reason", reason).
127 Msg("cef: viewport sync skipped; browser host not ready")
128 return false
129 }
130
131 widget := wv.viewBridge.Widget()
132 visible := widget != nil && widget.IsVisible()
133 wv.notifyViewportSyncOnCEFUIThread(host, visible)
134 wv.syncZoomForBackingScaleOnCEFUIThread(host, reason)
135 logging.FromContext(ctx).Debug().
136 Uint64("webview_id", uint64(wv.id)).
137 Bool("visible", visible).
138 Str("reason", reason).
139 Msg("cef: viewport sync requested")
140 return true
141}
142
143func (wv *WebView) syncResizeViewportOnGTK(ctx context.Context, reason string) bool {
144 if !wv.syncViewportNowOnGTK(ctx, reason) {
145 return false
146 }
147 wv.scheduleResizeRepaintPulse(ctx, reason)
148 return true
149}
150
151func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string) {
152 if wv == nil || wv.destroyed.Load() {
153 return
154 }
155 wv.mu.RLock()
156 host := wv.host
157 wv.mu.RUnlock()
158 if host == nil {
159 return
160 }
161 seq := wv.viewportResizePulseSeq.Add(1)
162 for _, delayMs := range [...]int64{16, 48} {
163 task := cefNewTask(cefTaskFunc(func() {
164 if wv == nil || wv.destroyed.Load() || wv.viewportResizePulseSeq.Load() != seq {
165 return
166 }
167 wv.mu.RLock()
168 currentHost := wv.host
169 wv.mu.RUnlock()
170 if currentHost != host {
171 return
172 }
173 notifyBrowserViewportResize(host)
174 logging.FromContext(ctx).Debug().
175 Uint64("webview_id", uint64(wv.id)).
176 Int64("delay_ms", delayMs).
177 Str("reason", reason).
178 Msg("cef: delayed resize viewport sync")
179 }))
180 if task == nil {
181 continue
182 }
183 cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
184 }
185}
186
187func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {
188 if wv == nil || wv.destroyed.Load() || wv.factory == nil || wv.viewBridge == nil {
189 return false
190 }
191
192 wv.mu.RLock()
193 hasHost := wv.host != nil
194 pending := wv.pendingCreate != nil
195 wv.mu.RUnlock()
196 if hasHost || !pending {
197 return false
198 }
199
200 widget := wv.viewBridge.Widget()
201 if widget == nil {
202 return false
203 }
204 width := int32(widget.GetAllocatedWidth())
205 height := int32(widget.GetAllocatedHeight())
206 if width <= 0 {
207 width = int32(widget.GetWidth())
208 }
209 if height <= 0 {
210 height = int32(widget.GetHeight())
211 }
212 if width <= 0 || height <= 0 {
213 logging.FromContext(ctx).Debug().
214 Uint64("webview_id", uint64(wv.id)).
215 Int32("width", width).
216 Int32("height", height).
217 Str("reason", reason).
218 Msg("cef: viewport sync skipped pending browser creation; widget has no allocation yet")
219 return false
220 }
221
222 observedWidth, observedHeight, observedReady := pendingBrowserCreateObservedSize(wv.viewBridge, width, height)
223 if !observedReady {
224 widget.QueueAllocate()
225 widget.QueueResize()
226 attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(ctx, reason)
227 switch action {
228 case pendingBrowserCreateObservedSizeRetryUnavailable:
229 return false
230 case pendingBrowserCreateObservedSizeRetryAlreadyScheduled:
231 logging.FromContext(ctx).Debug().
232 Uint64("webview_id", uint64(wv.id)).
233 Int32("observed_width", observedWidth).
234 Int32("observed_height", observedHeight).
235 Int32("width", width).
236 Int32("height", height).
237 Int("attempt", attempt).
238 Str("reason", reason).
239 Msg("cef: pending browser creation retry already scheduled while awaiting bridge size")
240 return false
241 case pendingBrowserCreateObservedSizeRetryScheduled:
242 logging.FromContext(ctx).Debug().
243 Uint64("webview_id", uint64(wv.id)).
244 Int32("observed_width", observedWidth).
245 Int32("observed_height", observedHeight).
246 Int32("width", width).
247 Int32("height", height).
248 Int("attempt", attempt).
249 Int("max_attempts", pendingBrowserCreateObservedSizeMaxRetries).
250 Str("reason", reason).
251 Msg("cef: delaying pending browser creation until bridge observes widget size")
252 return false
253 case pendingBrowserCreateObservedSizeRetryProceedWithoutDelay:
254 // Creating the browser here is intentional: after bounded retries we
255 // fall back to the current GTK allocation rather than stalling popup
256 // creation.
257 logging.FromContext(ctx).Debug().
258 Uint64("webview_id", uint64(wv.id)).
259 Int32("observed_width", observedWidth).
260 Int32("observed_height", observedHeight).
261 Int32("width", width).
262 Int32("height", height).
263 Int("attempt", attempt).
264 Str("reason", reason).
265 Msg("cef: bridge size observation did not settle before browser creation; continuing")
266 }
267 }
268
269 if wv.shouldDeferPendingBrowserCreateFromViewportSync() {
270 logging.FromContext(ctx).Debug().
271 Uint64("webview_id", uint64(wv.id)).
272 Int32("width", width).
273 Int32("height", height).
274 Str("reason", reason).
275 Msg("cef: viewport sync deferred pending browser creation while awaiting native popup attach")
276 return false
277 }
278
279 if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
280 logging.FromContext(ctx).Debug().
281 Err(err).
282 Uint64("webview_id", uint64(wv.id)).
283 Int32("width", width).
284 Int32("height", height).
285 Str("reason", reason).
286 Msg("cef: viewport sync could not prepare bridge for pending browser creation")
287 return false
288 }
289
290 wv.markInitialBrowserCreateResizeHandled()
291 wv.factory.postPendingBrowserCreate(ctx, wv, width, height)
292 logging.FromContext(ctx).Debug().
293 Uint64("webview_id", uint64(wv.id)).
294 Int32("width", width).
295 Int32("height", height).
296 Str("reason", reason).
297 Msg("cef: viewport sync nudged pending browser creation")
298 return true
299}
300
301func (wv *WebView) shouldDeferPendingBrowserCreateFromViewportSync() bool {
302 return wv != nil && wv.awaitsNativePopupAttachment()
303}
304
305func pendingBrowserCreateObservedSize(
306 bridge pendingBrowserCreateObservedSizeBridge,
307 allocatedWidth, allocatedHeight int32,
308) (observedWidth, observedHeight int32, ready bool) {
309 if bridge == nil {
310 return 0, 0, false
311 }
312 observedWidth, observedHeight = bridge.Size()
313 if observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight) {
314 return observedWidth, observedHeight, true
315 }
316 observedWidth, observedHeight = bridge.RefreshObservedSizeOnGTKThread()
317 return observedWidth, observedHeight, observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight)
318}
319
320// observedViewportSizeReady reports whether observedWidth/observedHeight match
321// allocatedWidth/allocatedHeight within a ±1px tolerance.
322//
323// Fallback observed sizes (<=1) and non-positive allocated dimensions are
324// treated as not ready.
325func observedViewportSizeReady(observedWidth, observedHeight, allocatedWidth, allocatedHeight int32) bool {
326 if allocatedWidth <= 0 || allocatedHeight <= 0 {
327 return false
328 }
329 if observedWidth <= 1 || observedHeight <= 1 {
330 return false
331 }
332 return abs32(observedWidth-allocatedWidth) <= 1 && abs32(observedHeight-allocatedHeight) <= 1
333}
334
335// abs32 returns the absolute value of v.
336func abs32(v int32) int32 {
337 if v < 0 {
338 return -v
339 }
340 return v
341}
342
343// preparePendingBrowserCreateObservedSizeRetry updates the observed-size retry
344// state machine for a pending browser create.
345//
346// It returns the current attempt count and the resulting action. Scheduling is
347// coalesced so only one delayed retry is pending at a time, while the max-retry
348// path proceeds without leaving the scheduled flag set.
349func (wv *WebView) preparePendingBrowserCreateObservedSizeRetry(
350 ctx context.Context,
351 reason string,
352) (attempt int, action pendingBrowserCreateObservedSizeRetryAction) {
353 if wv == nil {
354 return 0, pendingBrowserCreateObservedSizeRetryUnavailable
355 }
356
357 shouldSchedule := false
358 wv.mu.Lock()
359 if wv.pendingCreate == nil {
360 wv.mu.Unlock()
361 return 0, pendingBrowserCreateObservedSizeRetryUnavailable
362 }
363 if wv.pendingCreate.observedSizeRetryScheduled {
364 attempt = wv.pendingCreate.observedSizeRetries
365 wv.mu.Unlock()
366 return attempt, pendingBrowserCreateObservedSizeRetryAlreadyScheduled
367 }
368 wv.pendingCreate.observedSizeRetries++
369 attempt = wv.pendingCreate.observedSizeRetries
370 if attempt <= pendingBrowserCreateObservedSizeMaxRetries {
371 wv.pendingCreate.observedSizeRetryScheduled = true
372 shouldSchedule = true
373 }
374 wv.mu.Unlock()
375
376 if !shouldSchedule {
377 return attempt, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay
378 }
379 wv.schedulePendingBrowserCreateObservedSizeRetry(ctx, reason)
380 return attempt, pendingBrowserCreateObservedSizeRetryScheduled
381}
382
383// clearPendingBrowserCreateObservedSizeRetry clears the scheduled-retry flag
384// so a future observed-size reservation can schedule another attempt.
385func (wv *WebView) clearPendingBrowserCreateObservedSizeRetry() {
386 if wv == nil {
387 return
388 }
389 wv.mu.Lock()
390 defer wv.mu.Unlock()
391 if wv.pendingCreate != nil {
392 wv.pendingCreate.observedSizeRetryScheduled = false
393 }
394}
395
396// schedulePendingBrowserCreateObservedSizeRetry schedules a delayed viewport
397// sync retry using ctx after pendingBrowserCreateObservedSizeRetryDelay.
398//
399// It clears the scheduled flag before re-invoking syncViewportNowOnGTK with a
400// retry-specific suffix appended to reason.
401func (wv *WebView) schedulePendingBrowserCreateObservedSizeRetry(ctx context.Context, reason string) {
402 if wv == nil || wv.destroyed.Load() {
403 return
404 }
405 cefScheduleAfter(pendingBrowserCreateObservedSizeRetryDelay, func() {
406 if wv == nil || wv.destroyed.Load() {
407 return
408 }
409 wv.clearPendingBrowserCreateObservedSizeRetry()
410 wv.runOnGTK(func() {
411 wv.syncViewportNowOnGTK(ctx, reason+"-await-observed-size")
412 })
413 })
414}
415
416func (wv *WebView) installViewportSyncHooks() syncdispatch.SyncDispatchResult {
417 if wv == nil || wv.viewBridge == nil {
418 return syncdispatch.SyncDispatchResult{Label: "cef.install_viewport_sync_hooks", Status: syncdispatch.SyncDispatchInline}
419 }
420 return wv.runOnGTKSyncLabel("cef.install_viewport_sync_hooks", func() {
421 wv.installViewportSyncHooksOnGTKThread()
422 })
423}
424
425func (wv *WebView) installViewportSyncHooksOnGTKThread() {
426 if wv == nil || wv.viewBridge == nil || wv.viewportMapFunc != nil {
427 return
428 }
429 widget := wv.viewBridge.Widget()
430 if widget == nil {
431 return
432 }
433
434 wv.viewportMapFunc = func(gtk.Widget) {
435 wv.SyncViewport(wv.ctx, "gtk-map")
436 }
437 wv.viewportShowFunc = func(gtk.Widget) {
438 wv.SyncViewport(wv.ctx, "gtk-show")
439 }
440 wv.viewportRealizeFunc = func(gtk.Widget) {
441 wv.SyncViewport(wv.ctx, "gtk-realize")
442 }
443
444 wv.viewportMapSignalID = widget.ConnectMap(&wv.viewportMapFunc)
445 wv.viewportShowSignalID = widget.ConnectShow(&wv.viewportShowFunc)
446 wv.viewportRealizeSignalID = widget.ConnectRealize(&wv.viewportRealizeFunc)
447}
448
449func (wv *WebView) disconnectViewportSyncHooksOnGTKThread() {
450 if wv == nil || wv.viewBridge == nil {
451 return
452 }
453 widget := wv.viewBridge.Widget()
454 if widget != nil {
455 widgetPtr := widget.GoPointer()
456 if widgetPtr != 0 {
457 obj := gobject.ObjectNewFromInternalPtr(widgetPtr)
458 if wv.viewportMapSignalID != 0 {
459 gobject.SignalHandlerDisconnect(obj, wv.viewportMapSignalID)
460 wv.viewportMapSignalID = 0
461 }
462 if wv.viewportShowSignalID != 0 {
463 gobject.SignalHandlerDisconnect(obj, wv.viewportShowSignalID)
464 wv.viewportShowSignalID = 0
465 }
466 if wv.viewportRealizeSignalID != 0 {
467 gobject.SignalHandlerDisconnect(obj, wv.viewportRealizeSignalID)
468 wv.viewportRealizeSignalID = 0
469 }
470 }
471 }
472 wv.viewportMapFunc = nil
473 wv.viewportShowFunc = nil
474 wv.viewportRealizeFunc = nil
475}
476
477func (wv *WebView) notifyViewportSyncOnCEFUIThread(host viewportSyncBrowserHost, visible bool) {
478 if host == nil {
479 return
480 }
481 task := cefNewTask(cefTaskFunc(func() {
482 if wv == nil || wv.destroyed.Load() {
483 return
484 }
485 wv.mu.RLock()
486 currentHost := wv.host
487 wv.mu.RUnlock()
488 if currentHost != host {
489 return
490 }
491 notifyBrowserViewportSync(host, visible)
492 }))
493 if task == nil {
494 return
495 }
496 cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
497}
498
499func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost, reason string) {
500 if host == nil || wv == nil {
501 return
502 }
503 if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) {
504 return
505 }
506 task := cefNewTask(cefTaskFunc(func() {
507 if wv == nil || wv.destroyed.Load() {
508 return
509 }
510 wv.mu.RLock()
511 currentHost := wv.host
512 wv.mu.RUnlock()
513 if currentHost != host {
514 return
515 }
516 surfaceScale := wv.viewBridgeScale()
517 backingScale := wv.osrBackingScaleFactor()
518 if !wv.shouldReapplyZoomForScaleRatio(surfaceScale, backingScale) {
519 return
520 }
521 wv.reapplyCurrentZoomForBackingScale(reason)
522 }))
523 if task == nil {
524 return
525 }
526 cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
527}
528
529func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
530 if host == nil {
531 return
532 }
533 if visible {
534 host.WasHidden(0)
535 }
536 notifyBrowserViewportResize(host)
537}
538
539func notifyBrowserViewportResize(host viewportSyncBrowserHost) {
540 if host == nil {
541 return
542 }
543 host.NotifyScreenInfoChanged()
544 notifyBrowserResize(host)
545}