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}