v0.21.0 73bf644

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

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