v0.20.1 d9a391f

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

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