v0.29.0 21b3150

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

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