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}