v0.32.0 0617278

webview_viewport.go RawHistoryFolder

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	compensation := wv.pageZoomCompensation()
555	if !wv.shouldReapplyZoomForCompensation(compensation) {
556		return
557	}
558	task := cefNewTask(cefTaskFunc(func() {
559		if wv == nil || wv.destroyed.Load() {
560			return
561		}
562		wv.mu.RLock()
563		currentHost := wv.host
564		wv.mu.RUnlock()
565		if currentHost != host {
566			return
567		}
568		// Re-check on execution: a newer output scale may have superseded the
569		// compensation captured when this task was scheduled.
570		if !wv.shouldReapplyZoomForCompensation(wv.pageZoomCompensation()) {
571			return
572		}
573		wv.reapplyCurrentZoomForBackingScale(reason)
574	}))
575	if task == nil {
576		return
577	}
578	cefPostDelayedTask(purecef.ThreadIDTidUi, task, 0)
579}
580
581// applyEffectiveVisibility reports the initial effective visibility and later
582// transitions exactly once. CEF OSR begins hidden, so even an initially hidden
583// GTK widget must be explicitly reported to keep both lifecycles aligned.
584func (wv *WebView) applyEffectiveVisibility(host viewportSyncBrowserHost, visible bool) {
585	if wv == nil || host == nil || wv.destroyed.Load() {
586		return
587	}
588	wv.mu.Lock()
589	if wv.effectiveVisibilityKnown && wv.effectiveVisible == visible {
590		wv.mu.Unlock()
591		return
592	}
593	wv.effectiveVisibilityKnown = true
594	wv.effectiveVisible = visible
595	wv.mu.Unlock()
596	if visible {
597		host.WasHidden(0)
598		return
599	}
600	// The hidden transition retires scroll motion: a re-shown page never
601	// resumes old motion, and delayed cleanup cannot cancel a newer visible
602	// session because cleanup is epoch-scoped.
603	wv.invalidateScrollMotion()
604	host.WasHidden(1)
605}
606
607func notifyBrowserViewportSync(host viewportSyncBrowserHost, visible bool) {
608	if host == nil {
609		return
610	}
611	if visible {
612		host.WasHidden(0)
613	}
614	notifyBrowserViewportResize(host)
615}
616
617func notifyBrowserViewportResize(host viewportSyncBrowserHost) {
618	if host == nil {
619		return
620	}
621	host.NotifyScreenInfoChanged()
622	notifyBrowserResize(host)
623}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close