v0.23.1 6c872d5

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

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