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}