v0.21.0 73bf644

stacked.go RawHistoryFolder

1package layout
2
3import (
4	"context"
5	"errors"
6	"sync"
7
8	"github.com/bnema/dumber/internal/logging"
9)
10
11// ErrStackEmpty is returned when operating on an empty stack.
12var ErrStackEmpty = errors.New("stack is empty")
13
14// ErrIndexOutOfBounds is returned when an index is out of range.
15var ErrIndexOutOfBounds = errors.New("index out of bounds")
16
17// ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
18var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
19
20const stackedTitleMaxWidthChars = 30
21
22// stackedPane represents a single pane within a stacked container.
23type stackedPane struct {
24	paneID    string    // unique identifier for this pane
25	titleBar  BoxWidget // horizontal box with favicon + title label
26	container Widget    // the pane view widget
27	title     string
28	favicon   ImageWidget
29	label     LabelWidget
30	isActive  bool
31}
32
33// StackedView manages a stack of panes where only one is visible at a time.
34// Inactive panes show title bars that can be clicked to activate them.
35type StackedView struct {
36	factory     WidgetFactory
37	box         BoxWidget // vertical container for all content
38	panes       []*stackedPane
39	activeIndex int
40
41	onActivate func(index int) // called when a pane is activated via title bar click
42
43	mu sync.RWMutex
44}
45
46// NewStackedView creates a new stacked pane container.
47func NewStackedView(factory WidgetFactory) *StackedView {
48	box := factory.NewBox(OrientationVertical, 0)
49	box.SetHexpand(true)
50	box.SetVexpand(true)
51
52	return &StackedView{
53		factory:     factory,
54		box:         box,
55		panes:       make([]*stackedPane, 0),
56		activeIndex: -1,
57	}
58}
59
60// AddPane adds a new pane to the stack.
61// The new pane becomes active (visible).
62func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
63	log := logging.FromContext(ctx)
64	sv.mu.Lock()
65	defer sv.mu.Unlock()
66
67	log.Debug().
68		Str("title", title).
69		Bool("container_nil", container == nil).
70		Int("current_count", len(sv.panes)).
71		Msg("StackedView.AddPane called")
72
73	// Create title bar - must not expand vertically
74	titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
75	titleBar.AddCssClass("stacked-pane-titlebar")
76	titleBar.SetVexpand(false)
77
78	// Create favicon image
79	favicon := sv.factory.NewImage()
80	if faviconIconName != "" {
81		favicon.SetFromIconName(faviconIconName)
82	} else {
83		favicon.SetFromIconName("web-browser-symbolic")
84	}
85	favicon.SetPixelSize(16)
86	titleBar.Append(favicon)
87
88	// Create title label
89	label := sv.factory.NewLabel(title)
90	label.SetEllipsize(EllipsizeEnd)
91	label.SetMaxWidthChars(stackedTitleMaxWidthChars)
92	label.SetHexpand(true)
93	label.SetXalign(0.0)
94	titleBar.Append(label)
95
96	// Make title bar clickable - ensure it doesn't expand vertically
97	titleButton := sv.factory.NewButton()
98	titleButton.SetChild(titleBar)
99	titleButton.AddCssClass("stacked-pane-title-button")
100	titleButton.SetFocusOnClick(false)
101	titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
102	titleButton.SetHexpand(true)  // But fill horizontal space
103
104	pane := &stackedPane{
105		paneID:    paneID,
106		titleBar:  titleBar,
107		container: container,
108		title:     title,
109		favicon:   favicon,
110		label:     label,
111		isActive:  false,
112	}
113
114	index := len(sv.panes)
115	sv.panes = append(sv.panes, pane)
116
117	// Connect click handler
118	titleButton.ConnectClicked(func() {
119		sv.mu.RLock()
120		callback := sv.onActivate
121		sv.mu.RUnlock()
122
123		if callback != nil {
124			callback(index)
125		}
126	})
127
128	// Add to container
129	log.Debug().
130		Int("index", index).
131		Msg("StackedView: appending titleButton and container to box")
132	sv.box.Append(titleButton)
133	if container != nil {
134		sv.box.Append(container)
135	}
136
137	// Set this pane as active
138	sv.setActiveInternal(ctx, index)
139
140	log.Debug().
141		Int("new_index", index).
142		Int("new_count", len(sv.panes)).
143		Msg("StackedView.AddPane completed")
144
145	return index
146}
147
148// InsertPaneAfter inserts a new pane after the specified index position.
149// Use afterIndex=-1 to insert at the beginning.
150// The new pane becomes active (visible).
151// Returns the index where the pane was inserted.
152func (sv *StackedView) InsertPaneAfter(
153	ctx context.Context, afterIndex int, paneID, title, faviconIconName string, container Widget,
154) int {
155	log := logging.FromContext(ctx)
156	sv.mu.Lock()
157	defer sv.mu.Unlock()
158
159	// Validate afterIndex - clamp to valid range
160	if afterIndex < -1 {
161		afterIndex = -1
162	}
163	if afterIndex >= len(sv.panes) {
164		afterIndex = len(sv.panes) - 1
165	}
166	insertIndex := afterIndex + 1
167
168	log.Debug().
169		Str("title", title).
170		Int("after_index", afterIndex).
171		Int("insert_index", insertIndex).
172		Int("current_count", len(sv.panes)).
173		Msg("StackedView.InsertPaneAfter called")
174
175	// Create title bar - must not expand vertically
176	titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
177	titleBar.AddCssClass("stacked-pane-titlebar")
178	titleBar.SetVexpand(false)
179
180	// Create favicon image
181	favicon := sv.factory.NewImage()
182	if faviconIconName != "" {
183		favicon.SetFromIconName(faviconIconName)
184	} else {
185		favicon.SetFromIconName("web-browser-symbolic")
186	}
187	favicon.SetPixelSize(16)
188	titleBar.Append(favicon)
189
190	// Create title label
191	label := sv.factory.NewLabel(title)
192	label.SetEllipsize(EllipsizeEnd)
193	label.SetMaxWidthChars(stackedTitleMaxWidthChars)
194	label.SetHexpand(true)
195	label.SetXalign(0.0)
196	titleBar.Append(label)
197
198	// Make title bar clickable - ensure it doesn't expand vertically
199	titleButton := sv.factory.NewButton()
200	titleButton.SetChild(titleBar)
201	titleButton.AddCssClass("stacked-pane-title-button")
202	titleButton.SetFocusOnClick(false)
203	titleButton.SetVexpand(false)
204	titleButton.SetHexpand(true)
205
206	pane := &stackedPane{
207		paneID:    paneID,
208		titleBar:  titleBar,
209		container: container,
210		title:     title,
211		favicon:   favicon,
212		label:     label,
213		isActive:  false,
214	}
215
216	// Insert into slice at correct position
217	sv.panes = append(sv.panes, nil)
218	copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
219	sv.panes[insertIndex] = pane
220
221	// Connect click handler - use captured index
222	capturedIndex := insertIndex
223	titleButton.ConnectClicked(func() {
224		sv.mu.RLock()
225		callback := sv.onActivate
226		sv.mu.RUnlock()
227
228		if callback != nil {
229			callback(capturedIndex)
230		}
231	})
232
233	// Insert widgets at correct position in GTK box
234	if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
235		// Insert after the previous pane's container
236		prevPane := sv.panes[insertIndex-1]
237		if prevPane.container != nil {
238			sv.box.InsertChildAfter(titleButton, prevPane.container)
239			if container != nil {
240				sv.box.InsertChildAfter(container, titleButton)
241			}
242		} else {
243			// No container, insert after title bar's parent (the button)
244			prevTitleParent := prevPane.titleBar.GetParent()
245			if prevTitleParent != nil {
246				sv.box.InsertChildAfter(titleButton, prevTitleParent)
247				if container != nil {
248					sv.box.InsertChildAfter(container, titleButton)
249				}
250			} else {
251				// Fallback to append
252				sv.box.Append(titleButton)
253				if container != nil {
254					sv.box.Append(container)
255				}
256			}
257		}
258	} else {
259		// Insert at beginning
260		if container != nil {
261			sv.box.Prepend(container)
262		}
263		sv.box.Prepend(titleButton)
264	}
265
266	// Set this pane as active
267	sv.setActiveInternal(ctx, insertIndex)
268
269	log.Debug().
270		Int("insert_index", insertIndex).
271		Int("new_count", len(sv.panes)).
272		Msg("StackedView.InsertPaneAfter completed")
273
274	return insertIndex
275}
276
277// RemovePane removes a pane from the stack by index.
278// Returns an error if trying to remove the last pane.
279func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
280	log := logging.FromContext(ctx)
281	sv.mu.Lock()
282	defer sv.mu.Unlock()
283
284	log.Debug().
285		Int("index", index).
286		Int("pane_count", len(sv.panes)).
287		Msg("StackedView.RemovePane called")
288
289	if len(sv.panes) == 0 {
290		return ErrStackEmpty
291	}
292	if index < 0 || index >= len(sv.panes) {
293		return ErrIndexOutOfBounds
294	}
295	if len(sv.panes) == 1 {
296		return ErrCannotRemoveLastPane
297	}
298
299	pane := sv.panes[index]
300
301	// Remove from container
302	if pane.titleBar != nil {
303		// The title bar is wrapped in a button, need to get parent
304		parent := pane.titleBar.GetParent()
305		if parent != nil {
306			sv.box.Remove(parent)
307		}
308	}
309	if pane.container != nil {
310		sv.box.Remove(pane.container)
311	}
312
313	// Remove from slice
314	sv.panes = append(sv.panes[:index], sv.panes[index+1:]...)
315
316	// Adjust active index
317	if sv.activeIndex >= len(sv.panes) {
318		sv.activeIndex = len(sv.panes) - 1
319	} else if sv.activeIndex > index {
320		sv.activeIndex--
321	}
322
323	// Update visibility
324	if sv.activeIndex >= 0 {
325		sv.updateVisibilityInternal(ctx)
326	}
327
328	return nil
329}
330
331// SetActive activates the pane at the given index.
332// The active pane's container is shown; inactive panes show only title bars.
333func (sv *StackedView) SetActive(ctx context.Context, index int) error {
334	log := logging.FromContext(ctx)
335	sv.mu.Lock()
336	defer sv.mu.Unlock()
337
338	log.Debug().
339		Int("index", index).
340		Int("pane_count", len(sv.panes)).
341		Msg("StackedView.SetActive called")
342
343	if len(sv.panes) == 0 {
344		return ErrStackEmpty
345	}
346	if index < 0 || index >= len(sv.panes) {
347		return ErrIndexOutOfBounds
348	}
349
350	sv.setActiveInternal(ctx, index)
351	return nil
352}
353
354// setActiveInternal sets the active pane without locking.
355func (sv *StackedView) setActiveInternal(ctx context.Context, index int) {
356	sv.activeIndex = index
357	sv.updateVisibilityInternal(ctx)
358}
359
360// updateVisibilityInternal updates visibility of all panes based on activeIndex.
361func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
362	log := logging.FromContext(ctx)
363
364	log.Debug().
365		Int("active_index", sv.activeIndex).
366		Int("pane_count", len(sv.panes)).
367		Msg("StackedView.updateVisibilityInternal called")
368
369	for i, pane := range sv.panes {
370		isActive := i == sv.activeIndex
371		pane.isActive = isActive
372
373		// Title bar is always visible for inactive panes, hidden for active
374		if pane.titleBar != nil {
375			parent := pane.titleBar.GetParent()
376			if parent != nil {
377				parent.SetVisible(!isActive)
378				log.Debug().
379					Int("pane_index", i).
380					Str("title", pane.title).
381					Bool("is_active", isActive).
382					Bool("titlebar_visible", !isActive).
383					Msg("StackedView: set titlebar visibility")
384			}
385		}
386
387		// Container is visible only for active pane
388		if pane.container != nil {
389			pane.container.SetVisible(isActive)
390			log.Debug().
391				Int("pane_index", i).
392				Str("title", pane.title).
393				Bool("is_active", isActive).
394				Bool("container_visible", isActive).
395				Msg("StackedView: set container visibility")
396		}
397
398		// Update CSS classes
399		if pane.titleBar != nil {
400			if isActive {
401				pane.titleBar.AddCssClass("active")
402			} else {
403				pane.titleBar.RemoveCssClass("active")
404			}
405		}
406	}
407}
408
409// ActiveIndex returns the index of the currently active pane.
410// Returns -1 if the stack is empty.
411func (sv *StackedView) ActiveIndex() int {
412	sv.mu.RLock()
413	defer sv.mu.RUnlock()
414
415	return sv.activeIndex
416}
417
418// Count returns the number of panes in the stack.
419func (sv *StackedView) Count() int {
420	sv.mu.RLock()
421	defer sv.mu.RUnlock()
422
423	return len(sv.panes)
424}
425
426// FindPaneIndex returns the index of the pane with the given ID.
427// Returns -1 if not found.
428func (sv *StackedView) FindPaneIndex(paneID string) int {
429	sv.mu.RLock()
430	defer sv.mu.RUnlock()
431
432	for i, pane := range sv.panes {
433		if pane.paneID == paneID {
434			return i
435		}
436	}
437	return -1
438}
439
440// SetOnActivate sets the callback for when a pane is activated via title bar click.
441func (sv *StackedView) SetOnActivate(fn func(index int)) {
442	sv.mu.Lock()
443	defer sv.mu.Unlock()
444
445	sv.onActivate = fn
446}
447
448// UpdateTitle updates the title of a pane at the given index.
449func (sv *StackedView) UpdateTitle(index int, title string) error {
450	sv.mu.Lock()
451	defer sv.mu.Unlock()
452
453	if index < 0 || index >= len(sv.panes) {
454		return ErrIndexOutOfBounds
455	}
456
457	sv.panes[index].title = title
458	if sv.panes[index].label != nil {
459		sv.panes[index].label.SetText(title)
460	}
461
462	return nil
463}
464
465// UpdateFavicon updates the favicon of a pane at the given index using an icon name.
466func (sv *StackedView) UpdateFavicon(index int, iconName string) error {
467	sv.mu.Lock()
468	defer sv.mu.Unlock()
469
470	if index < 0 || index >= len(sv.panes) {
471		return ErrIndexOutOfBounds
472	}
473
474	if sv.panes[index].favicon != nil {
475		if iconName != "" {
476			sv.panes[index].favicon.SetFromIconName(iconName)
477		} else {
478			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
479		}
480	}
481
482	return nil
483}
484
485// UpdateFaviconTexture updates the favicon of a pane at the given index using a texture.
486// If texture is nil, falls back to the default web-browser-symbolic icon.
487func (sv *StackedView) UpdateFaviconTexture(index int, texture Paintable) error {
488	sv.mu.Lock()
489	defer sv.mu.Unlock()
490
491	if index < 0 || index >= len(sv.panes) {
492		return ErrIndexOutOfBounds
493	}
494
495	if sv.panes[index].favicon != nil {
496		if texture != nil {
497			sv.panes[index].favicon.SetFromPaintable(texture)
498		} else {
499			sv.panes[index].favicon.SetFromIconName("web-browser-symbolic")
500		}
501	}
502
503	return nil
504}
505
506// GetContainer returns the container widget for the pane at the given index.
507func (sv *StackedView) GetContainer(index int) (Widget, error) {
508	sv.mu.RLock()
509	defer sv.mu.RUnlock()
510
511	if index < 0 || index >= len(sv.panes) {
512		return nil, ErrIndexOutOfBounds
513	}
514
515	return sv.panes[index].container, nil
516}
517
518// Widget returns the underlying BoxWidget for embedding in containers.
519func (sv *StackedView) Widget() Widget {
520	return sv.box
521}
522
523// Box returns the underlying BoxWidget for direct access.
524func (sv *StackedView) Box() BoxWidget {
525	return sv.box
526}
527
528// NavigateNext moves to the next pane in the stack (wraps around).
529func (sv *StackedView) NavigateNext(ctx context.Context) error {
530	log := logging.FromContext(ctx)
531	sv.mu.Lock()
532	defer sv.mu.Unlock()
533
534	if len(sv.panes) == 0 {
535		return ErrStackEmpty
536	}
537
538	newIndex := (sv.activeIndex + 1) % len(sv.panes)
539	log.Debug().
540		Int("old_index", sv.activeIndex).
541		Int("new_index", newIndex).
542		Msg("StackedView.NavigateNext")
543	sv.setActiveInternal(ctx, newIndex)
544	return nil
545}
546
547// NavigatePrevious moves to the previous pane in the stack (wraps around).
548func (sv *StackedView) NavigatePrevious(ctx context.Context) error {
549	log := logging.FromContext(ctx)
550	sv.mu.Lock()
551	defer sv.mu.Unlock()
552
553	if len(sv.panes) == 0 {
554		return ErrStackEmpty
555	}
556
557	newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes)
558	log.Debug().
559		Int("old_index", sv.activeIndex).
560		Int("new_index", newIndex).
561		Msg("StackedView.NavigatePrevious")
562	sv.setActiveInternal(ctx, newIndex)
563	return nil
564}

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