v0.20.0 c741a39

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

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