v0.25.0 2e6c485

stacked.go RawHistoryFolder

1package layout
2
3import (
4	"context"
5	"errors"
6	"sync"
7
8	"github.com/bnema/dumber/internal/logging"
9	"github.com/jwijenbergh/puregotk/v4/gobject"
10	"github.com/jwijenbergh/puregotk/v4/gtk"
11)
12
13// ErrStackEmpty is returned when operating on an empty stack.
14var ErrStackEmpty = errors.New("stack is empty")
15
16// ErrIndexOutOfBounds is returned when an index is out of range.
17var ErrIndexOutOfBounds = errors.New("index out of bounds")
18
19// ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
20var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
21
22const (
23	stackedTitleMaxWidthChars = 30
24	stackedPaneCloseIcon      = "window-close-symbolic"
25)
26
27// stackedPane represents a single pane within a stacked container.
28type stackedPane struct {
29	paneID    string    // unique identifier for this pane
30	titleBar  BoxWidget // horizontal box with favicon + title label
31	container Widget    // the pane view widget
32	title     string
33	favicon   ImageWidget
34	label     LabelWidget
35	isActive  bool
36
37	// Signal handler IDs for cleanup on removal
38	closeClickSignalID uint32
39	closeButton        ButtonWidget // stored for signal disconnection
40
41	// Retained callback for GestureClick to prevent GC
42	titleClickCallback interface{}
43}
44
45// StackedView manages a stack of panes where only one is visible at a time.
46// Inactive panes show title bars that can be clicked to activate them.
47type StackedView struct {
48	factory     WidgetFactory
49	box         BoxWidget // vertical container for all content
50	panes       []*stackedPane
51	activeIndex int
52
53	onActivate  func(index int)     // called when a pane is activated via title bar click
54	onClosePane func(paneID string) // called when a pane's close button is clicked
55
56	mu sync.RWMutex
57}
58
59// NewStackedView creates a new stacked pane container.
60func NewStackedView(factory WidgetFactory) *StackedView {
61	box := factory.NewBox(OrientationVertical, 0)
62	box.SetHexpand(true)
63	box.SetVexpand(true)
64
65	return &StackedView{
66		factory:     factory,
67		box:         box,
68		panes:       make([]*stackedPane, 0),
69		activeIndex: -1,
70	}
71}
72
73// titleBarComponents holds the widgets created for a pane's title bar.
74type titleBarComponents struct {
75	titleBar BoxWidget
76	closeBtn ButtonWidget
77	favicon  ImageWidget
78	label    LabelWidget
79}
80
81// createTitleBar creates the title bar widgets for a pane.
82func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents {
83	// Create title bar container - must not expand vertically
84	// This is now the clickable container directly (using GestureClick), not wrapped in a button
85	titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
86	titleBar.AddCssClass("stacked-pane-titlebar")
87	titleBar.AddCssClass("stacked-pane-title-clickable") // For hover styling
88	titleBar.SetVexpand(false)
89	titleBar.SetHexpand(true) // Fill horizontal space
90
91	// Create favicon image
92	favicon := sv.factory.NewImage()
93	if faviconIconName != "" {
94		favicon.SetFromIconName(faviconIconName)
95	} else {
96		favicon.SetFromIconName("web-browser-symbolic")
97	}
98	favicon.SetPixelSize(16)
99	titleBar.Append(favicon)
100
101	// Create title label
102	label := sv.factory.NewLabel(title)
103	label.SetEllipsize(EllipsizeEnd)
104	label.SetMaxWidthChars(stackedTitleMaxWidthChars)
105	label.SetHexpand(true)
106	label.SetXalign(0.0)
107	titleBar.Append(label)
108
109	// Create close button using GTK's native icon button support
110	closeBtn := sv.factory.NewButton()
111	closeBtn.SetIconName(stackedPaneCloseIcon)
112	closeBtn.AddCssClass("stacked-pane-close-button")
113	closeBtn.SetFocusOnClick(false)
114	closeBtn.SetVexpand(false)
115	closeBtn.SetHexpand(false)
116	titleBar.Append(closeBtn)
117
118	return titleBarComponents{
119		titleBar: titleBar,
120		closeBtn: closeBtn,
121		favicon:  favicon,
122		label:    label,
123	}
124}
125
126// AddPane adds a new pane to the stack.
127// The new pane becomes active (visible).
128func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
129	log := logging.FromContext(ctx)
130	sv.mu.Lock()
131	defer sv.mu.Unlock()
132
133	log.Debug().
134		Str("title", title).
135		Bool("container_nil", container == nil).
136		Int("current_count", len(sv.panes)).
137		Msg("StackedView.AddPane called")
138
139	// Create title bar components
140	tb := sv.createTitleBar(title, faviconIconName)
141
142	// Connect click handlers using paneID (not index, to handle removals)
143	titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID)
144
145	pane := &stackedPane{
146		paneID:             paneID,
147		titleBar:           tb.titleBar,
148		container:          container,
149		title:              title,
150		favicon:            tb.favicon,
151		label:              tb.label,
152		isActive:           false,
153		closeClickSignalID: closeSignalID,
154		closeButton:        tb.closeBtn,
155		titleClickCallback: titleClickCb,
156	}
157
158	index := len(sv.panes)
159	sv.panes = append(sv.panes, pane)
160
161	// Add to container - now we add the titleBar directly (not wrapped in a button)
162	log.Debug().
163		Int("index", index).
164		Msg("StackedView: appending titleBar and container to box")
165	sv.box.Append(tb.titleBar)
166	if container != nil {
167		sv.box.Append(container)
168	}
169
170	// Set this pane as active
171	sv.setActiveInternal(ctx, index)
172
173	log.Debug().
174		Int("new_index", index).
175		Int("new_count", len(sv.panes)).
176		Msg("StackedView.AddPane completed")
177
178	return index
179}
180
181// connectTitleBarHandlers connects the click handlers for a title bar.
182// Uses paneID lookup instead of captured index to handle pane removals correctly.
183// Returns the retained callback (to prevent GC) and close signal ID for disconnection.
184func (sv *StackedView) connectTitleBarHandlers(
185	tb titleBarComponents, paneID string,
186) (titleClickCallback interface{}, closeSignalID uint32) {
187	// Connect title bar click handler using GestureClick
188	// This prevents event propagation issues with nested buttons
189	clickCtrl := gtk.NewGestureClick()
190
191	// Store reference to close button for hit testing
192	closeBtn := tb.closeBtn
193
194	clickCb := func(_ gtk.GestureClick, _ int, clickX float64, clickY float64) {
195		// Check if click is on the close button - if so, don't activate
196		// The close button handles its own click event
197		if closeBtn != nil {
198			closeBtnWidget := closeBtn.GtkWidget()
199			if closeBtnWidget != nil {
200				// Get close button's allocated position and size
201				btnWidth := closeBtn.GetAllocatedWidth()
202				btnHeight := closeBtn.GetAllocatedHeight()
203
204				// Get close button's position relative to the title bar
205				btnX, btnY, ok := closeBtn.ComputePoint(tb.titleBar)
206				if ok && btnWidth > 0 && btnHeight > 0 {
207					// Check if click is within close button bounds
208					if clickX >= btnX && clickX <= btnX+float64(btnWidth) &&
209						clickY >= btnY && clickY <= btnY+float64(btnHeight) {
210						// Click is on close button, don't activate
211						return
212					}
213				}
214			}
215		}
216
217		sv.mu.RLock()
218		callback := sv.onActivate
219		currentIndex := sv.findPaneIndexInternal(paneID)
220		sv.mu.RUnlock()
221
222		if callback != nil && currentIndex >= 0 {
223			callback(currentIndex)
224		}
225	}
226	clickCtrl.ConnectPressed(&clickCb)
227	tb.titleBar.AddController(&clickCtrl.EventController)
228
229	// Connect close button click handler
230	closeSignalID = tb.closeBtn.ConnectClicked(func() {
231		sv.mu.RLock()
232		onClose := sv.onClosePane
233		sv.mu.RUnlock()
234
235		if onClose != nil {
236			onClose(paneID)
237		}
238	})
239
240	return clickCb, closeSignalID
241}
242
243// disconnectPaneSignals disconnects signal handlers from a pane's buttons.
244// This prevents memory leaks when panes are removed from the stack.
245// Note: This is a no-op when using mock widgets in tests (GtkWidget returns nil).
246// Note: GestureClick callbacks are cleaned up automatically when the widget is destroyed.
247func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) {
248	if pane == nil {
249		return
250	}
251
252	// Clear retained callback reference to allow GC
253	// The GestureClick controller is owned by the widget and will be cleaned up when the widget is destroyed
254	pane.titleClickCallback = nil
255
256	// Disconnect close button click signal
257	disconnectButtonSignal(pane.closeButton, pane.closeClickSignalID)
258}
259
260// disconnectButtonSignal safely disconnects a signal from a button widget.
261// Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations.
262func disconnectButtonSignal(btn ButtonWidget, signalID uint32) {
263	if btn == nil || signalID == 0 {
264		return
265	}
266
267	gtkWidget := btn.GtkWidget()
268	if gtkWidget == nil {
269		return
270	}
271
272	ptr := gtkWidget.GoPointer()
273	if ptr == 0 {
274		return
275	}
276
277	obj := gobject.ObjectNewFromInternalPtr(ptr)
278	gobject.SignalHandlerDisconnect(obj, signalID)
279}
280
281// InsertPaneAfter inserts a new pane after the specified index position.
282// Use afterIndex=-1 to insert at the beginning.
283// The new pane becomes active (visible).
284// Returns the index where the pane was inserted.
285func (sv *StackedView) InsertPaneAfter(
286	ctx context.Context, afterIndex int, paneID, title, faviconIconName string, container Widget,
287) int {
288	log := logging.FromContext(ctx)
289	sv.mu.Lock()
290	defer sv.mu.Unlock()
291
292	// Validate afterIndex - clamp to valid range
293	if afterIndex < -1 {
294		afterIndex = -1
295	}
296	if afterIndex >= len(sv.panes) {
297		afterIndex = len(sv.panes) - 1
298	}
299	insertIndex := afterIndex + 1
300
301	log.Debug().
302		Str("title", title).
303		Int("after_index", afterIndex).
304		Int("insert_index", insertIndex).
305		Int("current_count", len(sv.panes)).
306		Msg("StackedView.InsertPaneAfter called")
307
308	// Create title bar components
309	tb := sv.createTitleBar(title, faviconIconName)
310
311	// Connect click handlers using paneID (not index, to handle removals)
312	titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID)
313
314	pane := &stackedPane{
315		paneID:             paneID,
316		titleBar:           tb.titleBar,
317		container:          container,
318		title:              title,
319		favicon:            tb.favicon,
320		label:              tb.label,
321		isActive:           false,
322		closeClickSignalID: closeSignalID,
323		closeButton:        tb.closeBtn,
324		titleClickCallback: titleClickCb,
325	}
326
327	// Insert into slice at correct position
328	sv.panes = append(sv.panes, nil)
329	copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
330	sv.panes[insertIndex] = pane
331
332	// Insert widgets at correct position in GTK box
333	sv.insertTitleBarWidgets(tb.titleBar, container, insertIndex)
334
335	// Set this pane as active
336	sv.setActiveInternal(ctx, insertIndex)
337
338	log.Debug().
339		Int("insert_index", insertIndex).
340		Int("new_count", len(sv.panes)).
341		Msg("StackedView.InsertPaneAfter completed")
342
343	return insertIndex
344}
345
346// insertTitleBarWidgets inserts the title bar and container at the correct position.
347func (sv *StackedView) insertTitleBarWidgets(titleBar BoxWidget, container Widget, insertIndex int) {
348	if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
349		// Insert after the previous pane's container
350		prevPane := sv.panes[insertIndex-1]
351		if prevPane.container != nil {
352			sv.box.InsertChildAfter(titleBar, prevPane.container)
353			if container != nil {
354				sv.box.InsertChildAfter(container, titleBar)
355			}
356		} else {
357			// No container, insert after the previous pane's title bar directly
358			if prevPane.titleBar != nil {
359				sv.box.InsertChildAfter(titleBar, prevPane.titleBar)
360				if container != nil {
361					sv.box.InsertChildAfter(container, titleBar)
362				}
363			} else {
364				// Fallback to append
365				sv.box.Append(titleBar)
366				if container != nil {
367					sv.box.Append(container)
368				}
369			}
370		}
371	} else {
372		// Insert at beginning
373		if container != nil {
374			sv.box.Prepend(container)
375		}
376		sv.box.Prepend(titleBar)
377	}
378}
379
380// RemovePane removes a pane from the stack by index.
381// Returns an error if trying to remove the last pane.
382func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
383	log := logging.FromContext(ctx)
384	sv.mu.Lock()
385	defer sv.mu.Unlock()
386
387	log.Debug().
388		Int("index", index).
389		Int("pane_count", len(sv.panes)).
390		Msg("StackedView.RemovePane called")
391
392	if len(sv.panes) == 0 {
393		return ErrStackEmpty
394	}
395	if index < 0 || index >= len(sv.panes) {
396		return ErrIndexOutOfBounds
397	}
398	if len(sv.panes) == 1 {
399		return ErrCannotRemoveLastPane
400	}
401
402	pane := sv.panes[index]
403
404	// Disconnect signal handlers before removing widgets to prevent memory leaks
405	sv.disconnectPaneSignals(pane)
406
407	// Remove from container
408	if pane.titleBar != nil {
409		// The title bar is now added directly to the box (no button wrapper)
410		sv.box.Remove(pane.titleBar)
411	}
412	if pane.container != nil {
413		sv.box.Remove(pane.container)
414	}
415
416	// Remove from slice
417	sv.panes = append(sv.panes[:index], sv.panes[index+1:]...)
418
419	// Adjust active index
420	if sv.activeIndex >= len(sv.panes) {
421		sv.activeIndex = len(sv.panes) - 1
422	} else if sv.activeIndex > index {
423		sv.activeIndex--
424	}
425
426	// Update visibility
427	if sv.activeIndex >= 0 {
428		sv.updateVisibilityInternal(ctx)
429	}
430
431	return nil
432}
433
434// SetActive activates the pane at the given index.
435// The active pane's container is shown; inactive panes show only title bars.
436func (sv *StackedView) SetActive(ctx context.Context, index int) error {
437	log := logging.FromContext(ctx)
438	sv.mu.Lock()
439	defer sv.mu.Unlock()
440
441	log.Debug().
442		Int("index", index).
443		Int("pane_count", len(sv.panes)).
444		Msg("StackedView.SetActive called")
445
446	if len(sv.panes) == 0 {
447		return ErrStackEmpty
448	}
449	if index < 0 || index >= len(sv.panes) {
450		return ErrIndexOutOfBounds
451	}
452
453	sv.setActiveInternal(ctx, index)
454	return nil
455}
456
457// setActiveInternal sets the active pane without locking.
458func (sv *StackedView) setActiveInternal(ctx context.Context, index int) {
459	sv.activeIndex = index
460	sv.updateVisibilityInternal(ctx)
461}
462
463// updateVisibilityInternal updates visibility of all panes based on activeIndex.
464func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
465	log := logging.FromContext(ctx)
466
467	log.Debug().
468		Int("active_index", sv.activeIndex).
469		Int("pane_count", len(sv.panes)).
470		Msg("StackedView.updateVisibilityInternal called")
471
472	for i, pane := range sv.panes {
473		isActive := i == sv.activeIndex
474		pane.isActive = isActive
475
476		// Title bar is always visible for inactive panes, hidden for active
477		// The title bar is now added directly to the box (no button wrapper)
478		if pane.titleBar != nil {
479			pane.titleBar.SetVisible(!isActive)
480			log.Debug().
481				Int("pane_index", i).
482				Str("title", pane.title).
483				Bool("is_active", isActive).
484				Bool("titlebar_visible", !isActive).
485				Msg("StackedView: set titlebar visibility")
486		}
487
488		// Container is visible only for active pane
489		if pane.container != nil {
490			pane.container.SetVisible(isActive)
491			log.Debug().
492				Int("pane_index", i).
493				Str("title", pane.title).
494				Bool("is_active", isActive).
495				Bool("container_visible", isActive).
496				Msg("StackedView: set container visibility")
497		}
498
499		// Update CSS classes
500		if pane.titleBar != nil {
501			if isActive {
502				pane.titleBar.AddCssClass("active")
503			} else {
504				pane.titleBar.RemoveCssClass("active")
505			}
506		}
507	}
508}
509
510// ActiveIndex returns the index of the currently active pane.
511// Returns -1 if the stack is empty.
512func (sv *StackedView) ActiveIndex() int {
513	sv.mu.RLock()
514	defer sv.mu.RUnlock()
515
516	return sv.activeIndex
517}
518
519// Count returns the number of panes in the stack.
520func (sv *StackedView) Count() int {
521	sv.mu.RLock()
522	defer sv.mu.RUnlock()
523
524	return len(sv.panes)
525}
526
527// FindPaneIndex returns the index of the pane with the given ID.
528// Returns -1 if not found.
529func (sv *StackedView) FindPaneIndex(paneID string) int {
530	sv.mu.RLock()
531	defer sv.mu.RUnlock()
532
533	return sv.findPaneIndexInternal(paneID)
534}
535
536// findPaneIndexInternal returns the index of a pane by ID.
537// Caller must hold at least a read lock on sv.mu.
538func (sv *StackedView) findPaneIndexInternal(paneID string) int {
539	for i, pane := range sv.panes {
540		if pane.paneID == paneID {
541			return i
542		}
543	}
544	return -1
545}
546
547// SetOnActivate sets the callback for when a pane is activated via title bar click.
548func (sv *StackedView) SetOnActivate(fn func(index int)) {
549	sv.mu.Lock()
550	defer sv.mu.Unlock()
551
552	sv.onActivate = fn
553}
554
555// SetOnClosePane sets the callback for when a pane's close button is clicked.
556func (sv *StackedView) SetOnClosePane(fn func(paneID string)) {
557	sv.mu.Lock()
558	defer sv.mu.Unlock()
559
560	sv.onClosePane = fn
561}
562
563// UpdateTitle updates the title of a pane at the given index.
564func (sv *StackedView) UpdateTitle(index int, title string) error {
565	sv.mu.Lock()
566	defer sv.mu.Unlock()
567
568	if index < 0 || index >= len(sv.panes) {
569		return ErrIndexOutOfBounds
570	}
571
572	sv.panes[index].title = title
573	if sv.panes[index].label != nil {
574		sv.panes[index].label.SetText(title)
575	}
576
577	return nil
578}
579
580// UpdateFavicon updates the favicon of a pane at the given index using an icon name.
581func (sv *StackedView) UpdateFavicon(index int, iconName string) error {
582	sv.mu.Lock()
583	defer sv.mu.Unlock()
584
585	if index < 0 || index >= len(sv.panes) {
586		return ErrIndexOutOfBounds
587	}
588
589	if sv.panes[index].favicon != nil {
590		if iconName != "" {
591			sv.panes[index].favicon.SetFromIconName(iconName)
592		} else {
593			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
594		}
595	}
596
597	return nil
598}
599
600// UpdateFaviconTexture updates the favicon of a pane at the given index using a texture.
601// If texture is nil, falls back to the default web-browser-symbolic icon.
602func (sv *StackedView) UpdateFaviconTexture(index int, texture Paintable) error {
603	sv.mu.Lock()
604	defer sv.mu.Unlock()
605
606	if index < 0 || index >= len(sv.panes) {
607		return ErrIndexOutOfBounds
608	}
609
610	if sv.panes[index].favicon != nil {
611		if texture != nil {
612			sv.panes[index].favicon.SetFromPaintable(texture)
613		} else {
614			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
615		}
616	}
617
618	return nil
619}
620
621// GetContainer returns the container widget for the pane at the given index.
622func (sv *StackedView) GetContainer(index int) (Widget, error) {
623	sv.mu.RLock()
624	defer sv.mu.RUnlock()
625
626	if index < 0 || index >= len(sv.panes) {
627		return nil, ErrIndexOutOfBounds
628	}
629
630	return sv.panes[index].container, nil
631}
632
633// Widget returns the underlying BoxWidget for embedding in containers.
634func (sv *StackedView) Widget() Widget {
635	return sv.box
636}
637
638// Box returns the underlying BoxWidget for direct access.
639func (sv *StackedView) Box() BoxWidget {
640	return sv.box
641}
642
643// NavigateNext moves to the next pane in the stack (wraps around).
644func (sv *StackedView) NavigateNext(ctx context.Context) error {
645	log := logging.FromContext(ctx)
646	sv.mu.Lock()
647	defer sv.mu.Unlock()
648
649	if len(sv.panes) == 0 {
650		return ErrStackEmpty
651	}
652
653	newIndex := (sv.activeIndex + 1) % len(sv.panes)
654	log.Debug().
655		Int("old_index", sv.activeIndex).
656		Int("new_index", newIndex).
657		Msg("StackedView.NavigateNext")
658	sv.setActiveInternal(ctx, newIndex)
659	return nil
660}
661
662// NavigatePrevious moves to the previous pane in the stack (wraps around).
663func (sv *StackedView) NavigatePrevious(ctx context.Context) error {
664	log := logging.FromContext(ctx)
665	sv.mu.Lock()
666	defer sv.mu.Unlock()
667
668	if len(sv.panes) == 0 {
669		return ErrStackEmpty
670	}
671
672	newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)
673	log.Debug().
674		Int("old_index", sv.activeIndex).
675		Int("new_index", newIndex).
676		Msg("StackedView.NavigatePrevious")
677	sv.setActiveInternal(ctx, newIndex)
678	return nil
679}

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