v0.32.0 0617278

pane_view.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"sync"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/dumber/internal/ui/input"
11	"github.com/bnema/dumber/internal/ui/layout"
12)
13
14const (
15	// CSS class applied to active pane's border overlay
16	activePaneClass = "pane-active"
17
18	// vimModeActiveClass is added to the pane overlay when Vim mode is active.
19	// It produces a subtle local border accent using the pane mode color.
20	vimModeActiveClass = "vim-mode-active"
21
22	// vimModePulseClass triggers a normal scroll pulse on the pane overlay.
23	// Repeated calls must remove-then-add to restart the CSS animation.
24	vimModePulseClass = "vim-mode-pulse"
25
26	// vimModeFastPulseClass triggers a fast/stronger scroll pulse.
27	vimModeFastPulseClass = "vim-mode-pulse-fast"
28
29	// Alternate equivalent animations so repeated scrolls reliably restart
30	// the GTK CSS animation.
31	vimModePulseCycleClassA = "vim-mode-pulse-cycle-a"
32	vimModePulseCycleClassB = "vim-mode-pulse-cycle-b"
33
34	// Exported aliases for tests and external inspection.
35	VimModeActiveClass    = vimModeActiveClass
36	VimModePulseClass     = vimModePulseClass
37	VimModeFastPulseClass = vimModeFastPulseClass
38)
39
40// PaneView is a container for a single WebView with active state indication.
41// It uses an overlay to display a border around the active pane.
42type PaneView struct {
43	ctx           context.Context
44	factory       layout.WidgetFactory
45	overlay       layout.OverlayWidget
46	webViewWidget layout.Widget      // The actual WebView widget
47	borderBox     layout.BoxWidget   // Border overlay for active indication
48	progressBar   *ProgressBar       // Loading progress indicator
49	toaster       *Toaster           // Toast notification overlay
50	linkStatus    *LinkStatusOverlay // Link hover URL overlay
51	loading       *LoadingSkeleton   // Placeholder shown until WebView paints
52	paneID        entity.PaneID
53	isActive      bool
54
55	// Vim Mode pane-local visual state.
56	vimMode    bool
57	pulseCycle bool
58
59	onFocusIn     func(paneID entity.PaneID)
60	onFocusOut    func(paneID entity.PaneID)
61	onHover       func(paneID entity.PaneID)
62	onMouseMotion func()
63
64	hoverHandler *input.HoverHandler
65
66	mu sync.RWMutex
67}
68
69// NewPaneView creates a new pane view container for a WebView widget.
70func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
71	overlay := factory.NewOverlay()
72	overlay.SetHexpand(true)
73	overlay.SetVexpand(true)
74	overlay.SetVisible(true)
75	overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
76
77	// Set the WebView as the main child
78	// Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
79	if webViewWidget != nil {
80		overlay.SetChild(webViewWidget)
81	}
82
83	// Loading skeleton overlay (shown until WebView paints)
84	loading := NewLoadingSkeleton(factory)
85	loading.Widget().SetCanFocus(false)
86	loading.Widget().SetCanTarget(false)
87	overlay.AddOverlay(loading.Widget())
88	// Don't let loading affect layout
89	overlay.SetClipOverlay(loading.Widget(), false)
90	overlay.SetMeasureOverlay(loading.Widget(), false)
91
92	// Create border overlay box (used for active pane indication)
93	// This is an empty box that gets styled via CSS to show a border
94	borderBox := factory.NewBox(layout.OrientationVertical, 0)
95	borderBox.SetCanFocus(false)
96	borderBox.SetCanTarget(false) // Don't intercept pointer events - let them pass through to WebView
97	borderBox.AddCssClass("pane-border")
98	// Position border to cover entire pane
99	borderBox.SetHexpand(true)
100	borderBox.SetVexpand(true)
101	overlay.AddOverlay(borderBox)
102
103	// Don't let border affect layout
104	overlay.SetClipOverlay(borderBox, false)
105	overlay.SetMeasureOverlay(borderBox, false)
106
107	// Progress bar is created lazily on first use to avoid GTK measurement
108	// issues with the internal progress gizmo before the overlay is realized
109
110	return &PaneView{
111		ctx:           ctx,
112		factory:       factory,
113		overlay:       overlay,
114		webViewWidget: webViewWidget,
115		borderBox:     borderBox,
116		progressBar:   nil, // Created lazily in ensureProgressBar()
117		loading:       loading,
118		paneID:        paneID,
119		isActive:      false,
120		vimMode:       false,
121	}
122}
123
124// SetActive updates the active state of the pane.
125// Active panes display a visual border indicator.
126func (pv *PaneView) SetActive(active bool) {
127	pv.mu.Lock()
128	defer pv.mu.Unlock()
129
130	if pv.isActive == active {
131		return
132	}
133
134	pv.isActive = active
135
136	if active {
137		pv.borderBox.AddCssClass(activePaneClass)
138	} else {
139		pv.borderBox.RemoveCssClass(activePaneClass)
140	}
141}
142
143// IsActive returns whether this pane is currently active.
144func (pv *PaneView) IsActive() bool {
145	pv.mu.RLock()
146	defer pv.mu.RUnlock()
147
148	return pv.isActive
149}
150
151// PaneID returns the ID of the pane this view represents.
152func (pv *PaneView) PaneID() entity.PaneID {
153	return pv.paneID
154}
155
156// WebViewWidget returns the underlying WebView widget.
157func (pv *PaneView) WebViewWidget() layout.Widget {
158	pv.mu.RLock()
159	defer pv.mu.RUnlock()
160
161	return pv.webViewWidget
162}
163
164// SetWebViewWidget replaces the WebView widget as not-yet-revealed content.
165// Call AttachWebViewWidget when the content coordinator knows the WebView has
166// already painted.
167func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
168	pv.AttachWebViewWidget(widget, false)
169}
170
171// AttachWebViewWidget replaces the WebView widget and applies its explicit
172// content reveal state. GTK parentage is used only to satisfy GTK's one-parent
173// ownership rule; it must not be used to infer whether CEF has painted.
174func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
175	pv.mu.Lock()
176	defer pv.mu.Unlock()
177
178	// Remove old widget from this overlay.
179	if pv.webViewWidget != nil {
180		pv.overlay.SetChild(nil)
181	}
182
183	pv.webViewWidget = widget
184	if widget == nil {
185		return
186	}
187
188	overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
189	overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
190	widgetAllocWidthBefore := widget.GetAllocatedWidth()
191	widgetAllocHeightBefore := widget.GetAllocatedHeight()
192	hadParent := widget.GetParent() != nil
193	wasVisible := widget.IsVisible()
194
195	// GTK4 widgets may have only one parent. Cleanup during a WorkspaceView
196	// rebuild leaves a reusable native widget unparented, which says nothing
197	// about whether its content was previously revealed.
198	if hadParent {
199		widget.Unparent()
200	}
201	pv.overlay.SetChild(widget)
202
203	if hadParent && wasVisible {
204		widget.SetVisible(true)
205	}
206	// A replacement can follow an already-revealed WebView in the same pane.
207	// Apply both states explicitly: an unrevealed current WebView must restart
208	// the skeleton rather than inheriting the previous view's hidden overlay.
209	if pv.loading != nil {
210		pv.loading.SetVisible(!revealed)
211	}
212
213	ctx := pv.ctx
214	if ctx == nil {
215		ctx = context.Background()
216	}
217	logging.FromContext(ctx).Debug().
218		Str("pane_id", string(pv.paneID)).
219		Bool("had_parent", hadParent).
220		Bool("was_visible", wasVisible).
221		Bool("revealed", revealed).
222		Int("overlay_alloc_width_before", overlayAllocWidthBefore).
223		Int("overlay_alloc_height_before", overlayAllocHeightBefore).
224		Int("widget_alloc_width_before", widgetAllocWidthBefore).
225		Int("widget_alloc_height_before", widgetAllocHeightBefore).
226		Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
227		Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
228		Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
229		Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
230		Msg("pane view webview widget attached")
231}
232
233// GrabFocus attempts to focus the WebView.
234// Returns true if focus was successfully grabbed.
235func (pv *PaneView) GrabFocus() bool {
236	pv.mu.RLock()
237	wv := pv.webViewWidget
238	pv.mu.RUnlock()
239
240	if wv == nil {
241		return false
242	}
243	return wv.GrabFocus()
244}
245
246// HasFocus returns whether the WebView currently has focus.
247func (pv *PaneView) HasFocus() bool {
248	pv.mu.RLock()
249	wv := pv.webViewWidget
250	pv.mu.RUnlock()
251
252	if wv == nil {
253		return false
254	}
255	return wv.HasFocus()
256}
257
258// SetOnFocusIn sets the callback for when the pane gains focus.
259func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
260	pv.mu.Lock()
261	defer pv.mu.Unlock()
262
263	pv.onFocusIn = fn
264}
265
266// SetOnFocusOut sets the callback for when the pane loses focus.
267func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
268	pv.mu.Lock()
269	defer pv.mu.Unlock()
270
271	pv.onFocusOut = fn
272}
273
274// SetOnHover sets the callback for when the pane is hovered.
275func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
276	pv.mu.Lock()
277	defer pv.mu.Unlock()
278
279	pv.onHover = fn
280}
281
282// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
283// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
284func (pv *PaneView) SetOnMouseMotion(fn func()) {
285	pv.mu.Lock()
286	defer pv.mu.Unlock()
287
288	pv.onMouseMotion = fn
289}
290
291// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
292func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
293	pv.mu.Lock()
294	defer pv.mu.Unlock()
295
296	// Create hover handler
297	pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
298
299	// Wire up hover enter callback
300	pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
301		pv.mu.RLock()
302		callback := pv.onHover
303		pv.mu.RUnlock()
304
305		if callback != nil {
306			callback(paneID)
307		}
308	})
309
310	// Wire up mouse motion callback (intentional movement detection)
311	pv.hoverHandler.SetOnMotion(func() {
312		pv.mu.RLock()
313		callback := pv.onMouseMotion
314		pv.mu.RUnlock()
315
316		if callback != nil {
317			callback()
318		}
319	})
320
321	// Attach to overlay widget
322	gtkWidget := pv.overlay.GtkWidget()
323	if gtkWidget != nil {
324		pv.hoverHandler.AttachTo(gtkWidget)
325	}
326}
327
328// CancelPendingHover cancels any pending hover focus timer.
329// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
330func (pv *PaneView) CancelPendingHover() {
331	pv.mu.RLock()
332	handler := pv.hoverHandler
333	pv.mu.RUnlock()
334
335	if handler != nil {
336		handler.Cancel()
337	}
338}
339
340// Widget returns the underlying overlay widget for embedding in containers.
341func (pv *PaneView) Widget() layout.Widget {
342	return pv.overlay
343}
344
345// Overlay returns the underlying overlay widget for direct access.
346func (pv *PaneView) Overlay() layout.OverlayWidget {
347	return pv.overlay
348}
349
350// Show makes the pane visible.
351func (pv *PaneView) Show() {
352	pv.overlay.Show()
353}
354
355// Hide makes the pane invisible.
356func (pv *PaneView) Hide() {
357	pv.overlay.Hide()
358}
359
360// SetVisible sets the visibility of the pane.
361func (pv *PaneView) SetVisible(visible bool) {
362	pv.overlay.SetVisible(visible)
363}
364
365// IsVisible returns whether the pane is visible.
366func (pv *PaneView) IsVisible() bool {
367	return pv.overlay.IsVisible()
368}
369
370// AddCssClass adds a CSS class to the overlay.
371func (pv *PaneView) AddCssClass(class string) {
372	pv.overlay.AddCssClass(class)
373}
374
375// RemoveCssClass removes a CSS class from the overlay.
376func (pv *PaneView) RemoveCssClass(class string) {
377	pv.overlay.RemoveCssClass(class)
378}
379
380// AddOverlayWidget adds a widget as an overlay on this pane.
381// The widget will appear above the WebView and border.
382func (pv *PaneView) AddOverlayWidget(widget layout.Widget) {
383	pv.overlay.AddOverlay(widget)
384	pv.overlay.SetClipOverlay(widget, false)
385	pv.overlay.SetMeasureOverlay(widget, false)
386}
387
388// RemoveOverlayWidget removes an overlay widget from this pane.
389func (pv *PaneView) RemoveOverlayWidget(widget layout.Widget) {
390	pv.overlay.RemoveOverlay(widget)
391}
392
393// GetContentDimensions returns the pane's allocated width and height.
394func (pv *PaneView) GetContentDimensions() (width, height int) {
395	return pv.overlay.GetAllocatedWidth(), pv.overlay.GetAllocatedHeight()
396}
397
398// ensureProgressBar creates the progress bar lazily on first use.
399// This avoids GTK measurement issues with the internal progress gizmo
400// that can occur when the widget is added before the overlay is realized.
401// Must be called with write lock held.
402func (pv *PaneView) ensureProgressBar() *ProgressBar {
403	if pv.progressBar != nil {
404		return pv.progressBar
405	}
406
407	ctx := pv.ctx
408	if ctx == nil {
409		ctx = context.Background()
410	}
411	logging.FromContext(ctx).
412		Debug().
413		Str("pane_id", string(pv.paneID)).
414		Msg("creating progress bar")
415
416	pb := NewProgressBar(pv.ctx, pv.factory)
417	pv.overlay.AddOverlay(pb.Widget())
418	pv.overlay.SetClipOverlay(pb.Widget(), false)
419	pv.overlay.SetMeasureOverlay(pb.Widget(), false)
420	pv.progressBar = pb
421	return pb
422}
423
424// SetLoadProgress updates the progress bar with the current load progress.
425// progress should be between 0.0 and 1.0.
426func (pv *PaneView) SetLoadProgress(progress float64) {
427	pv.mu.Lock()
428	pb := pv.ensureProgressBar()
429	ctx := pv.ctx
430	if ctx == nil {
431		ctx = context.Background()
432	}
433	logging.FromContext(ctx).
434		Trace().
435		Str("pane_id", string(pv.paneID)).
436		Float64("progress", progress).
437		Msg("setting load progress")
438	pv.mu.Unlock()
439
440	pb.SetProgress(progress)
441}
442
443// SetLoading shows or hides the progress bar.
444func (pv *PaneView) SetLoading(loading bool) {
445	pv.mu.Lock()
446	hadProgressBar := pv.progressBar != nil
447	pb := pv.ensureProgressBar()
448	ctx := pv.ctx
449	if ctx == nil {
450		ctx = context.Background()
451	}
452	logging.FromContext(ctx).
453		Debug().
454		Str("pane_id", string(pv.paneID)).
455		Bool("loading", loading).
456		Bool("progress_bar_existed", hadProgressBar).
457		Msg("setting loading state")
458	pv.mu.Unlock()
459
460	if loading {
461		pb.Show()
462	} else {
463		pb.HideWithReason("loading_false")
464	}
465}
466
467// HideLoadingSkeleton hides the loading skeleton overlay (if present).
468func (pv *PaneView) HideLoadingSkeleton() {
469	pv.mu.Lock()
470	loading := pv.loading
471	pv.mu.Unlock()
472
473	if loading != nil {
474		loading.SetVisible(false)
475	}
476}
477
478// ensureToaster creates the toaster lazily on first use.
479// Must be called with write lock held.
480func (pv *PaneView) ensureToaster() *Toaster {
481	if pv.toaster != nil {
482		return pv.toaster
483	}
484
485	t := NewToaster(pv.factory)
486	pv.overlay.AddOverlay(t.Widget())
487	pv.overlay.SetClipOverlay(t.Widget(), false)
488	pv.overlay.SetMeasureOverlay(t.Widget(), false)
489	pv.toaster = t
490	return t
491}
492
493// ShowToast displays a toast notification with the given message and level.
494// If a toast is already visible, updates the text and resets the dismiss timer.
495func (pv *PaneView) ShowToast(ctx context.Context, message string, level ToastLevel) {
496	pv.mu.Lock()
497	t := pv.ensureToaster()
498	pv.mu.Unlock()
499
500	t.Show(ctx, message, level)
501}
502
503// ShowZoomToast displays a zoom level toast notification.
504// Formats the zoom percentage with a % suffix.
505func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
506	pv.mu.Lock()
507	t := pv.ensureToaster()
508	pv.mu.Unlock()
509
510	t.ShowZoom(ctx, zoomPercent)
511}
512
513// ensureLinkStatus creates the link status overlay lazily on first use.
514// Must be called with write lock held.
515func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
516	if pv.linkStatus != nil {
517		return pv.linkStatus
518	}
519
520	ls := NewLinkStatusOverlay(pv.factory)
521	pv.overlay.AddOverlay(ls.Widget())
522	pv.overlay.SetClipOverlay(ls.Widget(), false)
523	pv.overlay.SetMeasureOverlay(ls.Widget(), false)
524	pv.linkStatus = ls
525	return ls
526}
527
528// ShowLinkStatus displays the link status overlay with the given URI.
529// If uri is empty, hides the overlay instead.
530func (pv *PaneView) ShowLinkStatus(uri string) {
531	pv.mu.Lock()
532	ls := pv.ensureLinkStatus()
533	pv.mu.Unlock()
534
535	ls.Show(uri)
536}
537
538// HideLinkStatus hides the link status overlay.
539func (pv *PaneView) HideLinkStatus() {
540	pv.mu.Lock()
541	ls := pv.linkStatus
542	pv.mu.Unlock()
543
544	if ls != nil {
545		ls.Hide()
546	}
547}
548
549// SetVimMode activates or deactivates the pane-local Vim Mode accent.
550func (pv *PaneView) SetVimMode(active bool) {
551	pv.mu.Lock()
552	defer pv.mu.Unlock()
553
554	if pv.vimMode == active {
555		return
556	}
557
558	pv.vimMode = active
559
560	if active {
561		pv.overlay.AddCssClass(vimModeActiveClass)
562	} else {
563		pv.overlay.RemoveCssClass(vimModeActiveClass)
564	}
565}
566
567// IsVimMode returns whether Vim mode is currently active on this pane.
568func (pv *PaneView) IsVimMode() bool {
569	pv.mu.RLock()
570	defer pv.mu.RUnlock()
571
572	return pv.vimMode
573}
574
575// TriggerVimModePulse triggers a normal scroll pulse on the pane overlay.
576func (pv *PaneView) TriggerVimModePulse() {
577	pv.triggerOverlayPulse(false)
578}
579
580// TriggerVimModePulseFast triggers a stronger, longer pane-overlay pulse.
581func (pv *PaneView) TriggerVimModePulseFast() {
582	pv.triggerOverlayPulse(true)
583}
584
585// triggerOverlayPulse adds a transient pulse CSS class to the pane overlay.
586// fast=true uses the stronger/longer pulse animation. Both pulse classes are
587// removed first to reliably re-trigger the animation.
588func (pv *PaneView) triggerOverlayPulse(fast bool) {
589	pv.mu.Lock()
590	defer pv.mu.Unlock()
591
592	pv.overlay.RemoveCssClass(vimModePulseClass)
593	pv.overlay.RemoveCssClass(vimModeFastPulseClass)
594	pv.overlay.RemoveCssClass(vimModePulseCycleClassA)
595	pv.overlay.RemoveCssClass(vimModePulseCycleClassB)
596	if fast {
597		pv.overlay.AddCssClass(vimModeFastPulseClass)
598	} else {
599		pv.overlay.AddCssClass(vimModePulseClass)
600	}
601	cycle := vimModePulseCycleClassA
602	if pv.pulseCycle {
603		cycle = vimModePulseCycleClassB
604	}
605	pv.pulseCycle = !pv.pulseCycle
606	pv.overlay.AddCssClass(cycle)
607}
608
609// Cleanup removes the WebView widget from the overlay and clears references.
610// This must be called before destroying the WebView to ensure proper GTK cleanup.
611// After calling Cleanup, the PaneView should not be reused.
612func (pv *PaneView) Cleanup() {
613	pv.mu.Lock()
614	defer pv.mu.Unlock()
615
616	// Clear callbacks to prevent use-after-free
617	pv.onFocusIn = nil
618	pv.onFocusOut = nil
619	pv.onHover = nil
620	pv.onMouseMotion = nil
621
622	// Detach hover handler if present
623	if pv.hoverHandler != nil {
624		pv.hoverHandler.Detach()
625		pv.hoverHandler = nil
626	}
627
628	// Remove WebView from overlay (unparents it from GTK hierarchy)
629	if pv.webViewWidget != nil {
630		pv.overlay.SetChild(nil)
631		pv.webViewWidget = nil
632	}
633
634	// Clear other overlay children
635	if pv.loading != nil {
636		pv.overlay.RemoveOverlay(pv.loading.Widget())
637		pv.loading = nil
638	}
639	if pv.progressBar != nil {
640		pv.overlay.RemoveOverlay(pv.progressBar.Widget())
641		pv.progressBar = nil
642	}
643	if pv.toaster != nil {
644		pv.overlay.RemoveOverlay(pv.toaster.Widget())
645		pv.toaster = nil
646	}
647	if pv.linkStatus != nil {
648		pv.linkStatus.Cleanup() // Cancel pending timers before removal
649		pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
650		pv.linkStatus = nil
651	}
652}

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