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