v0.27.1 c78ce83

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/ui/input"
10	"github.com/bnema/dumber/internal/ui/layout"
11)
12
13const (
14	// CSS class applied to active pane's border overlay
15	activePaneClass = "pane-active"
16)
17
18// PaneView is a container for a single WebView with active state indication.
19// It uses an overlay to display a border around the active pane.
20type PaneView struct {
21	factory       layout.WidgetFactory
22	overlay       layout.OverlayWidget
23	webViewWidget layout.Widget      // The actual WebView widget
24	borderBox     layout.BoxWidget   // Border overlay for active indication
25	progressBar   *ProgressBar       // Loading progress indicator
26	toaster       *Toaster           // Toast notification overlay
27	linkStatus    *LinkStatusOverlay // Link hover URL overlay
28	loading       *LoadingSkeleton   // Placeholder shown until WebView paints
29	paneID        entity.PaneID
30	isActive      bool
31
32	onFocusIn     func(paneID entity.PaneID)
33	onFocusOut    func(paneID entity.PaneID)
34	onHover       func(paneID entity.PaneID)
35	onMouseMotion func()
36
37	hoverHandler *input.HoverHandler
38
39	mu sync.RWMutex
40}
41
42// NewPaneView creates a new pane view container for a WebView widget.
43func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
44	overlay := factory.NewOverlay()
45	overlay.SetHexpand(true)
46	overlay.SetVexpand(true)
47	overlay.SetVisible(true)
48	overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
49
50	// Set the WebView as the main child
51	// Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
52	if webViewWidget != nil {
53		overlay.SetChild(webViewWidget)
54	}
55
56	// Loading skeleton overlay (shown until WebView paints)
57	loading := NewLoadingSkeleton(factory)
58	loading.Widget().SetCanFocus(false)
59	loading.Widget().SetCanTarget(false)
60	overlay.AddOverlay(loading.Widget())
61	// Don't let loading affect layout
62	overlay.SetClipOverlay(loading.Widget(), false)
63	overlay.SetMeasureOverlay(loading.Widget(), false)
64
65	// Create border overlay box (used for active pane indication)
66	// This is an empty box that gets styled via CSS to show a border
67	borderBox := factory.NewBox(layout.OrientationVertical, 0)
68	borderBox.SetCanFocus(false)
69	borderBox.SetCanTarget(false) // Don't intercept pointer events - let them pass through to WebView
70	borderBox.AddCssClass("pane-border")
71	// Position border to cover entire pane
72	borderBox.SetHexpand(true)
73	borderBox.SetVexpand(true)
74	overlay.AddOverlay(borderBox)
75
76	// Don't let border affect layout
77	overlay.SetClipOverlay(borderBox, false)
78	overlay.SetMeasureOverlay(borderBox, false)
79
80	// Progress bar is created lazily on first use to avoid GTK measurement
81	// issues with the internal progress gizmo before the overlay is realized
82
83	return &PaneView{
84		factory:       factory,
85		overlay:       overlay,
86		webViewWidget: webViewWidget,
87		borderBox:     borderBox,
88		progressBar:   nil, // Created lazily in ensureProgressBar()
89		loading:       loading,
90		paneID:        paneID,
91		isActive:      false,
92	}
93}
94
95// SetActive updates the active state of the pane.
96// Active panes display a visual border indicator.
97func (pv *PaneView) SetActive(active bool) {
98	pv.mu.Lock()
99	defer pv.mu.Unlock()
100
101	if pv.isActive == active {
102		return
103	}
104
105	pv.isActive = active
106
107	if active {
108		pv.borderBox.AddCssClass(activePaneClass)
109	} else {
110		pv.borderBox.RemoveCssClass(activePaneClass)
111	}
112}
113
114// IsActive returns whether this pane is currently active.
115func (pv *PaneView) IsActive() bool {
116	pv.mu.RLock()
117	defer pv.mu.RUnlock()
118
119	return pv.isActive
120}
121
122// PaneID returns the ID of the pane this view represents.
123func (pv *PaneView) PaneID() entity.PaneID {
124	return pv.paneID
125}
126
127// WebViewWidget returns the underlying WebView widget.
128func (pv *PaneView) WebViewWidget() layout.Widget {
129	pv.mu.RLock()
130	defer pv.mu.RUnlock()
131
132	return pv.webViewWidget
133}
134
135// SetWebViewWidget replaces the WebView widget.
136func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
137	pv.mu.Lock()
138	defer pv.mu.Unlock()
139
140	// Remove old widget from this overlay
141	if pv.webViewWidget != nil {
142		pv.overlay.SetChild(nil)
143	}
144
145	pv.webViewWidget = widget
146
147	if widget != nil {
148		// If this widget already had a parent, we're reparenting an existing WebView
149		// (e.g. after rebuilding/moving panes). In that case the WebView has likely
150		// already painted, so the loading skeleton should be hidden immediately.
151		hadParent := widget.GetParent() != nil
152		wasVisible := false
153		if hadParent {
154			wasVisible = widget.IsVisible()
155		}
156
157		// Unparent widget from any previous parent (critical for rebuild scenarios)
158		// In GTK4, a widget can only have one parent at a time
159		if hadParent {
160			widget.Unparent()
161		}
162		pv.overlay.SetChild(widget)
163
164		if hadParent && wasVisible {
165			widget.SetVisible(true)
166		}
167		if hadParent && pv.loading != nil {
168			pv.loading.SetVisible(false)
169		}
170	}
171}
172
173// GrabFocus attempts to focus the WebView.
174// Returns true if focus was successfully grabbed.
175func (pv *PaneView) GrabFocus() bool {
176	pv.mu.RLock()
177	wv := pv.webViewWidget
178	pv.mu.RUnlock()
179
180	if wv == nil {
181		return false
182	}
183	return wv.GrabFocus()
184}
185
186// HasFocus returns whether the WebView currently has focus.
187func (pv *PaneView) HasFocus() bool {
188	pv.mu.RLock()
189	wv := pv.webViewWidget
190	pv.mu.RUnlock()
191
192	if wv == nil {
193		return false
194	}
195	return wv.HasFocus()
196}
197
198// SetOnFocusIn sets the callback for when the pane gains focus.
199func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
200	pv.mu.Lock()
201	defer pv.mu.Unlock()
202
203	pv.onFocusIn = fn
204}
205
206// SetOnFocusOut sets the callback for when the pane loses focus.
207func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
208	pv.mu.Lock()
209	defer pv.mu.Unlock()
210
211	pv.onFocusOut = fn
212}
213
214// SetOnHover sets the callback for when the pane is hovered.
215func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
216	pv.mu.Lock()
217	defer pv.mu.Unlock()
218
219	pv.onHover = fn
220}
221
222// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
223// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
224func (pv *PaneView) SetOnMouseMotion(fn func()) {
225	pv.mu.Lock()
226	defer pv.mu.Unlock()
227
228	pv.onMouseMotion = fn
229}
230
231// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
232func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
233	pv.mu.Lock()
234	defer pv.mu.Unlock()
235
236	// Create hover handler
237	pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
238
239	// Wire up hover enter callback
240	pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
241		pv.mu.RLock()
242		callback := pv.onHover
243		pv.mu.RUnlock()
244
245		if callback != nil {
246			callback(paneID)
247		}
248	})
249
250	// Wire up mouse motion callback (intentional movement detection)
251	pv.hoverHandler.SetOnMotion(func() {
252		pv.mu.RLock()
253		callback := pv.onMouseMotion
254		pv.mu.RUnlock()
255
256		if callback != nil {
257			callback()
258		}
259	})
260
261	// Attach to overlay widget
262	gtkWidget := pv.overlay.GtkWidget()
263	if gtkWidget != nil {
264		pv.hoverHandler.AttachTo(gtkWidget)
265	}
266}
267
268// CancelPendingHover cancels any pending hover focus timer.
269// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
270func (pv *PaneView) CancelPendingHover() {
271	pv.mu.RLock()
272	handler := pv.hoverHandler
273	pv.mu.RUnlock()
274
275	if handler != nil {
276		handler.Cancel()
277	}
278}
279
280// Widget returns the underlying overlay widget for embedding in containers.
281func (pv *PaneView) Widget() layout.Widget {
282	return pv.overlay
283}
284
285// Overlay returns the underlying overlay widget for direct access.
286func (pv *PaneView) Overlay() layout.OverlayWidget {
287	return pv.overlay
288}
289
290// Show makes the pane visible.
291func (pv *PaneView) Show() {
292	pv.overlay.Show()
293}
294
295// Hide makes the pane invisible.
296func (pv *PaneView) Hide() {
297	pv.overlay.Hide()
298}
299
300// SetVisible sets the visibility of the pane.
301func (pv *PaneView) SetVisible(visible bool) {
302	pv.overlay.SetVisible(visible)
303}
304
305// IsVisible returns whether the pane is visible.
306func (pv *PaneView) IsVisible() bool {
307	return pv.overlay.IsVisible()
308}
309
310// AddCssClass adds a CSS class to the overlay.
311func (pv *PaneView) AddCssClass(class string) {
312	pv.overlay.AddCssClass(class)
313}
314
315// RemoveCssClass removes a CSS class from the overlay.
316func (pv *PaneView) RemoveCssClass(class string) {
317	pv.overlay.RemoveCssClass(class)
318}
319
320// AddOverlayWidget adds a widget as an overlay on this pane.
321// The widget will appear above the WebView and border.
322func (pv *PaneView) AddOverlayWidget(widget layout.Widget) {
323	pv.overlay.AddOverlay(widget)
324	pv.overlay.SetClipOverlay(widget, false)
325	pv.overlay.SetMeasureOverlay(widget, false)
326}
327
328// RemoveOverlayWidget removes an overlay widget from this pane.
329func (pv *PaneView) RemoveOverlayWidget(widget layout.Widget) {
330	pv.overlay.RemoveOverlay(widget)
331}
332
333// GetContentDimensions returns the pane's allocated width and height.
334func (pv *PaneView) GetContentDimensions() (width, height int) {
335	return pv.overlay.GetAllocatedWidth(), pv.overlay.GetAllocatedHeight()
336}
337
338// ensureProgressBar creates the progress bar lazily on first use.
339// This avoids GTK measurement issues with the internal progress gizmo
340// that can occur when the widget is added before the overlay is realized.
341// Must be called with write lock held.
342func (pv *PaneView) ensureProgressBar() *ProgressBar {
343	if pv.progressBar != nil {
344		return pv.progressBar
345	}
346
347	pb := NewProgressBar(pv.factory)
348	pv.overlay.AddOverlay(pb.Widget())
349	pv.overlay.SetClipOverlay(pb.Widget(), false)
350	pv.overlay.SetMeasureOverlay(pb.Widget(), false)
351	pv.progressBar = pb
352	return pb
353}
354
355// SetLoadProgress updates the progress bar with the current load progress.
356// progress should be between 0.0 and 1.0.
357func (pv *PaneView) SetLoadProgress(progress float64) {
358	pv.mu.Lock()
359	pb := pv.ensureProgressBar()
360	pv.mu.Unlock()
361
362	pb.SetProgress(progress)
363}
364
365// SetLoading shows or hides the progress bar.
366func (pv *PaneView) SetLoading(loading bool) {
367	pv.mu.Lock()
368	pb := pv.ensureProgressBar()
369	pv.mu.Unlock()
370
371	if loading {
372		pb.Show()
373	} else {
374		pb.Hide()
375	}
376}
377
378// HideLoadingSkeleton hides the loading skeleton overlay (if present).
379func (pv *PaneView) HideLoadingSkeleton() {
380	pv.mu.Lock()
381	loading := pv.loading
382	pv.mu.Unlock()
383
384	if loading != nil {
385		loading.SetVisible(false)
386	}
387}
388
389// ensureToaster creates the toaster lazily on first use.
390// Must be called with write lock held.
391func (pv *PaneView) ensureToaster() *Toaster {
392	if pv.toaster != nil {
393		return pv.toaster
394	}
395
396	t := NewToaster(pv.factory)
397	pv.overlay.AddOverlay(t.Widget())
398	pv.overlay.SetClipOverlay(t.Widget(), false)
399	pv.overlay.SetMeasureOverlay(t.Widget(), false)
400	pv.toaster = t
401	return t
402}
403
404// ShowToast displays a toast notification with the given message and level.
405// If a toast is already visible, updates the text and resets the dismiss timer.
406func (pv *PaneView) ShowToast(ctx context.Context, message string, level ToastLevel) {
407	pv.mu.Lock()
408	t := pv.ensureToaster()
409	pv.mu.Unlock()
410
411	t.Show(ctx, message, level)
412}
413
414// ShowZoomToast displays a zoom level toast notification.
415// Formats the zoom percentage with a % suffix.
416func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
417	pv.mu.Lock()
418	t := pv.ensureToaster()
419	pv.mu.Unlock()
420
421	t.ShowZoom(ctx, zoomPercent)
422}
423
424// ensureLinkStatus creates the link status overlay lazily on first use.
425// Must be called with write lock held.
426func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
427	if pv.linkStatus != nil {
428		return pv.linkStatus
429	}
430
431	ls := NewLinkStatusOverlay(pv.factory)
432	pv.overlay.AddOverlay(ls.Widget())
433	pv.overlay.SetClipOverlay(ls.Widget(), false)
434	pv.overlay.SetMeasureOverlay(ls.Widget(), false)
435	pv.linkStatus = ls
436	return ls
437}
438
439// ShowLinkStatus displays the link status overlay with the given URI.
440// If uri is empty, hides the overlay instead.
441func (pv *PaneView) ShowLinkStatus(uri string) {
442	pv.mu.Lock()
443	ls := pv.ensureLinkStatus()
444	pv.mu.Unlock()
445
446	ls.Show(uri)
447}
448
449// HideLinkStatus hides the link status overlay.
450func (pv *PaneView) HideLinkStatus() {
451	pv.mu.Lock()
452	ls := pv.linkStatus
453	pv.mu.Unlock()
454
455	if ls != nil {
456		ls.Hide()
457	}
458}
459
460// Cleanup removes the WebView widget from the overlay and clears references.
461// This must be called before destroying the WebView to ensure proper GTK cleanup.
462// After calling Cleanup, the PaneView should not be reused.
463func (pv *PaneView) Cleanup() {
464	pv.mu.Lock()
465	defer pv.mu.Unlock()
466
467	// Clear callbacks to prevent use-after-free
468	pv.onFocusIn = nil
469	pv.onFocusOut = nil
470	pv.onHover = nil
471
472	// Detach hover handler if present
473	if pv.hoverHandler != nil {
474		pv.hoverHandler.Detach()
475		pv.hoverHandler = nil
476	}
477
478	// Remove WebView from overlay (unparents it from GTK hierarchy)
479	if pv.webViewWidget != nil {
480		pv.overlay.SetChild(nil)
481		pv.webViewWidget = nil
482	}
483
484	// Clear other overlay children
485	if pv.progressBar != nil {
486		pv.overlay.RemoveOverlay(pv.progressBar.Widget())
487		pv.progressBar = nil
488	}
489	if pv.toaster != nil {
490		pv.overlay.RemoveOverlay(pv.toaster.Widget())
491		pv.toaster = nil
492	}
493	if pv.linkStatus != nil {
494		pv.linkStatus.Cleanup() // Cancel pending timers before removal
495		pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
496		pv.linkStatus = nil
497	}
498}

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