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