v0.30.0 eb3068f

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
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}

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