main 5041353

stacked.go RawHistoryFolder

1package layout
2
3import (
4	"context"
5	"errors"
6	"sync"
7
8	"github.com/bnema/dumber/internal/logging"
9	"github.com/bnema/puregotk/v4/gobject"
10	"github.com/bnema/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 uint
39	closeButton        ButtonWidget // stored for signal disconnection
40
41	// Retained callback for GestureClick to prevent GC
42	titleClickCallback any
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("pane_id", paneID).
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 any, closeSignalID uint) {
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 uint) {
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("pane_id", paneID).
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				Str("pane_id", pane.paneID).
482				Int("pane_index", i).
483				Int("active_index", sv.activeIndex).
484				Int("pane_count", len(sv.panes)).
485				Bool("is_active", isActive).
486				Bool("requested_visible", !isActive).
487				Msg("StackedView: titlebar visibility changed")
488		}
489
490		// Container is visible only for active pane
491		if pane.container != nil {
492			pane.container.SetVisible(isActive)
493			log.Debug().
494				Str("pane_id", pane.paneID).
495				Int("pane_index", i).
496				Int("active_index", sv.activeIndex).
497				Int("pane_count", len(sv.panes)).
498				Bool("is_active", isActive).
499				Bool("requested_visible", isActive).
500				Msg("StackedView: container visibility changed")
501		}
502
503		// Update CSS classes
504		if pane.titleBar != nil {
505			if isActive {
506				pane.titleBar.AddCssClass("active")
507			} else {
508				pane.titleBar.RemoveCssClass("active")
509			}
510		}
511	}
512}
513
514// ActiveIndex returns the index of the currently active pane.
515// Returns -1 if the stack is empty.
516func (sv *StackedView) ActiveIndex() int {
517	sv.mu.RLock()
518	defer sv.mu.RUnlock()
519
520	return sv.activeIndex
521}
522
523// Count returns the number of panes in the stack.
524func (sv *StackedView) Count() int {
525	sv.mu.RLock()
526	defer sv.mu.RUnlock()
527
528	return len(sv.panes)
529}
530
531// FindPaneIndex returns the index of the pane with the given ID.
532// Returns -1 if not found.
533func (sv *StackedView) FindPaneIndex(paneID string) int {
534	sv.mu.RLock()
535	defer sv.mu.RUnlock()
536
537	return sv.findPaneIndexInternal(paneID)
538}
539
540// findPaneIndexInternal returns the index of a pane by ID.
541// Caller must hold at least a read lock on sv.mu.
542func (sv *StackedView) findPaneIndexInternal(paneID string) int {
543	for i, pane := range sv.panes {
544		if pane.paneID == paneID {
545			return i
546		}
547	}
548	return -1
549}
550
551// SetOnActivate sets the callback for when a pane is activated via title bar click.
552func (sv *StackedView) SetOnActivate(fn func(index int)) {
553	sv.mu.Lock()
554	defer sv.mu.Unlock()
555
556	sv.onActivate = fn
557}
558
559// SetOnClosePane sets the callback for when a pane's close button is clicked.
560func (sv *StackedView) SetOnClosePane(fn func(paneID string)) {
561	sv.mu.Lock()
562	defer sv.mu.Unlock()
563
564	sv.onClosePane = fn
565}
566
567// UpdateTitle updates the title of a pane at the given index.
568func (sv *StackedView) UpdateTitle(index int, title string) error {
569	sv.mu.Lock()
570	defer sv.mu.Unlock()
571
572	if index < 0 || index >= len(sv.panes) {
573		return ErrIndexOutOfBounds
574	}
575
576	sv.panes[index].title = title
577	if sv.panes[index].label != nil {
578		sv.panes[index].label.SetText(title)
579	}
580
581	return nil
582}
583
584// UpdateFavicon updates the favicon of a pane at the given index using an icon name.
585func (sv *StackedView) UpdateFavicon(index int, iconName string) error {
586	sv.mu.Lock()
587	defer sv.mu.Unlock()
588
589	if index < 0 || index >= len(sv.panes) {
590		return ErrIndexOutOfBounds
591	}
592
593	if sv.panes[index].favicon != nil {
594		if iconName != "" {
595			sv.panes[index].favicon.SetFromIconName(iconName)
596		} else {
597			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
598		}
599	}
600
601	return nil
602}
603
604// UpdateFaviconTexture updates the favicon of a pane at the given index using a texture.
605// If texture is nil, falls back to the default web-browser-symbolic icon.
606func (sv *StackedView) UpdateFaviconTexture(index int, texture Paintable) error {
607	sv.mu.Lock()
608	defer sv.mu.Unlock()
609
610	if index < 0 || index >= len(sv.panes) {
611		return ErrIndexOutOfBounds
612	}
613
614	if sv.panes[index].favicon != nil {
615		if texture != nil {
616			sv.panes[index].favicon.SetFromPaintable(texture)
617		} else {
618			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
619		}
620	}
621
622	return nil
623}
624
625// GetContainer returns the container widget for the pane at the given index.
626func (sv *StackedView) GetContainer(index int) (Widget, error) {
627	sv.mu.RLock()
628	defer sv.mu.RUnlock()
629
630	if index < 0 || index >= len(sv.panes) {
631		return nil, ErrIndexOutOfBounds
632	}
633
634	return sv.panes[index].container, nil
635}
636
637// Widget returns the underlying BoxWidget for embedding in containers.
638func (sv *StackedView) Widget() Widget {
639	return sv.box
640}
641
642// Box returns the underlying BoxWidget for direct access.
643func (sv *StackedView) Box() BoxWidget {
644	return sv.box
645}
646
647// NavigateNext moves to the next pane in the stack (wraps around).
648func (sv *StackedView) NavigateNext(ctx context.Context) error {
649	log := logging.FromContext(ctx)
650	sv.mu.Lock()
651	defer sv.mu.Unlock()
652
653	if len(sv.panes) == 0 {
654		return ErrStackEmpty
655	}
656
657	newIndex := (sv.activeIndex + 1) % len(sv.panes)
658	log.Debug().
659		Int("old_index", sv.activeIndex).
660		Int("new_index", newIndex).
661		Msg("StackedView.NavigateNext")
662	sv.setActiveInternal(ctx, newIndex)
663	return nil
664}
665
666// NavigatePrevious moves to the previous pane in the stack (wraps around).
667func (sv *StackedView) NavigatePrevious(ctx context.Context) error {
668	log := logging.FromContext(ctx)
669	sv.mu.Lock()
670	defer sv.mu.Unlock()
671
672	if len(sv.panes) == 0 {
673		return ErrStackEmpty
674	}
675
676	newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)
677	log.Debug().
678		Int("old_index", sv.activeIndex).
679		Int("new_index", newIndex).
680		Msg("StackedView.NavigatePrevious")
681	sv.setActiveInternal(ctx, newIndex)
682	return nil
683}

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