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