1package browser
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "sync/atomic"
8 "time"
9
10 "github.com/diamondburned/gotk4/pkg/gdk/v4"
11 "github.com/diamondburned/gotk4/pkg/gtk/v4"
12
13 "github.com/bnema/dumber/pkg/webkit"
14)
15
16const (
17 // faviconPlaceholder is the emoji displayed when no favicon is available
18 faviconPlaceholder = "🌐"
19)
20
21// StackedPaneManager handles all stacked pane operations
22type StackedPaneManager struct {
23 wm *WorkspaceManager
24 titleBarToPane map[uint64]*paneNode
25 nextTitleBarID uint64
26}
27
28// NewStackedPaneManager creates a new stacked pane manager
29func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
30 return &StackedPaneManager{
31 wm: wm,
32 titleBarToPane: make(map[uint64]*paneNode),
33 }
34}
35
36// StackPane creates a new pane stacked on top of the target pane.
37// This is the main entry point for creating stacked panes.
38func (spm *StackedPaneManager) StackPane(target *paneNode) (*paneNode, error) {
39 if target == nil || !target.isLeaf || target.pane == nil {
40 return nil, errors.New("stack target must be a leaf pane")
41 }
42
43 // Create the new pane first
44 newLeaf, err := spm.prepareNewStackedPane()
45 if err != nil {
46 return nil, fmt.Errorf("failed to prepare new stacked pane: %w", err)
47 }
48
49 var stackNode *paneNode
50 var insertIndex int
51
52 // Check if target is already in a stack
53 if target.parent != nil && target.parent.isStacked {
54 // Target is already in a stack - add to existing stack
55 stackNode, insertIndex, err = spm.addPaneToExistingStack(target, newLeaf)
56 if err != nil {
57 return nil, fmt.Errorf("failed to add pane to existing stack: %w", err)
58 }
59 } else {
60 // Target is not stacked - create initial stack
61 stackNode, insertIndex, err = spm.convertToStackedContainer(target, newLeaf)
62 if err != nil {
63 return nil, fmt.Errorf("failed to convert to stacked container: %w", err)
64 }
65 }
66
67 // Finalize the stack creation
68 return spm.finalizeStackCreation(stackNode, newLeaf, insertIndex)
69}
70
71// prepareNewStackedPane creates a new pane and its container
72func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
73 newView, err := spm.wm.createWebView()
74 if err != nil {
75 return nil, err
76 }
77
78 newPane, err := spm.wm.createPane(newView)
79 if err != nil {
80 return nil, err
81 }
82
83 if handler := newPane.MessageHandler(); handler != nil {
84 handler.SetWorkspaceObserver(spm.wm)
85 }
86
87 newContainer := newPane.webView.RootWidget()
88 if newContainer == nil {
89 return nil, errors.New("new pane missing container")
90 }
91 webkit.WidgetSetHExpand(newContainer, true)
92 webkit.WidgetSetVExpand(newContainer, true)
93
94 // Create the new leaf node
95 newLeaf := &paneNode{
96 pane: newPane,
97 isLeaf: true,
98 }
99 // Initialize widgets properly using workspace manager helper
100 spm.wm.initializePaneWidgets(newLeaf, newContainer)
101
102 // Create title bar for the new pane
103 newTitleBar := spm.createTitleBar(newLeaf)
104 spm.wm.setTitleBar(newLeaf, newTitleBar)
105
106 // Keep new container hidden initially - will be shown after transition
107 webkit.WidgetSetVisible(newContainer, false)
108 if newTitleBar != nil {
109 webkit.WidgetSetVisible(newTitleBar, false)
110 }
111
112 return newLeaf, nil
113}
114
115// addPaneToExistingStack adds a new pane to an existing stack
116func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) (*paneNode, int, error) {
117 stackNode := target.parent
118
119 // Find the current position of the target pane in the stack
120 currentIndex := -1
121 for i, pane := range stackNode.stackedPanes {
122 if pane == target {
123 currentIndex = i
124 break
125 }
126 }
127
128 if currentIndex == -1 {
129 return nil, 0, errors.New("target pane not found in stack")
130 }
131
132 // Insert the new pane right after the current pane
133 insertIndex := currentIndex + 1
134 log.Printf("[workspace] adding to existing stack: currentIndex=%d insertIndex=%d stackSize=%d",
135 currentIndex, insertIndex, len(stackNode.stackedPanes))
136
137 // Set parent relationship
138 newLeaf.parent = stackNode
139
140 // Insert the new pane at the correct position in the slice
141 stackNode.stackedPanes = append(stackNode.stackedPanes, nil) // Expand slice
142 copy(stackNode.stackedPanes[insertIndex+1:], stackNode.stackedPanes[insertIndex:]) // Shift elements
143 stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
144
145 // Unparent the new container before adding it to the stack (only if it has a parent)
146 if newLeaf.container != nil {
147 if webkit.WidgetGetParent(newLeaf.container) != nil {
148 webkit.WidgetUnparent(newLeaf.container)
149 }
150 }
151
152 // Insert widgets at correct position for Zellij-style layout
153 // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
154
155 // Find the widget to insert after (based on insertIndex)
156 var insertAfterWidget gtk.Widgetter
157 if insertIndex > 0 {
158 // Insert after the previous pane's container
159 prevPane := stackNode.stackedPanes[insertIndex-1]
160 if prevPane.container != nil {
161 insertAfterWidget = prevPane.container
162 }
163 }
164
165 if insertAfterWidget != nil {
166 // Insert titleBar after the previous pane's container
167 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
168 box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
169 }
170 // Insert container after the newly inserted titleBar
171 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
172 box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
173 }
174 log.Printf("[workspace] inserted widgets at position %d (after widget %p)", insertIndex, insertAfterWidget)
175 } else {
176 // Insert at the beginning (insertIndex = 0)
177 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
178 box.Prepend(newLeaf.container)
179 }
180 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
181 box.Prepend(newLeaf.titleBar)
182 }
183 log.Printf("[workspace] prepended widgets at position 0")
184 }
185
186 return stackNode, insertIndex, nil
187}
188
189// convertToStackedContainer converts a simple pane to a stacked container
190func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNode) (*paneNode, int, error) {
191 log.Printf("[workspace] converting pane to stacked: %p", target)
192
193 // Create the wrapper container - this is what will be used by splitNode
194 stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
195 if stackWrapperContainer == nil {
196 return nil, 0, errors.New("failed to create stack wrapper container")
197 }
198 stackWrapperContainer.SetHExpand(true)
199 stackWrapperContainer.SetVExpand(true)
200 // Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
201
202 // Add CSS class to the stack container for proper styling
203 stackWrapperContainer.AddCSSClass(stackContainerClass)
204
205 // Create the internal box for the actual stacked widgets (titles + webviews)
206 stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
207 if stackInternalBox == nil {
208 return nil, 0, errors.New("failed to create stack internal box")
209 }
210 stackInternalBox.SetHExpand(true)
211 stackInternalBox.SetVExpand(true)
212
213 // The internal box goes inside the wrapper
214 stackWrapperContainer.Append(stackInternalBox)
215
216 // Get the existing container and parent info
217 existingContainer := target.container
218 parent := target.parent
219
220 // Create title bar for the existing pane
221 titleBar := spm.createTitleBar(target)
222
223 // Keep existing container visible during transition to prevent rendering glitch
224 if titleBar != nil {
225 webkit.WidgetSetVisible(titleBar, false)
226 }
227
228 // Detach existing container from its current parent first
229 spm.detachFromParent(target, parent)
230
231 // Build the complete stack structure with hidden widgets
232 if titleBar != nil {
233 stackInternalBox.Append(titleBar)
234 }
235 if existingContainer != nil {
236 stackInternalBox.Append(existingContainer)
237 }
238
239 // Immediately reattach the stack wrapper to minimize visibility gap
240 spm.reattachToParent(stackWrapperContainer, target, parent)
241
242 // Keep target as a regular leaf node (it's just inside a stack now)
243 // Individual panes within a stack should NOT be marked as isStacked
244 // Only the stack container itself has isStacked=true
245 target.isStacked = false
246 target.isLeaf = true
247 // container stays the same (existingContainer)
248 spm.wm.setTitleBar(target, titleBar)
249
250 // Create the stack container node - container points to wrapper, stackWrapper points to internal box
251 stackNode := &paneNode{
252 isStacked: true,
253 isLeaf: false,
254 stackedPanes: []*paneNode{target},
255 activeStackIndex: 0, // KEEP CURRENT PANE ACTIVE during transition (index 0)
256 parent: parent,
257 }
258 spm.wm.setContainer(stackNode, stackWrapperContainer, "stack-wrapper") // Wrapper for GTK operations (splits, etc.)
259 spm.wm.setStackWrapper(stackNode, stackInternalBox) // Internal box for stack operations
260
261 // Update target's parent to be the stack node
262 target.parent = stackNode
263
264 // Update parent references (GTK operations already done above)
265 if parent == nil {
266 spm.wm.root = stackNode
267 } else {
268 if parent.left == target {
269 parent.left = stackNode
270 } else if parent.right == target {
271 parent.right = stackNode
272 }
273 }
274
275 // Set up the new pane
276 newLeaf.parent = stackNode
277
278 // Insert the new pane at index 1 (after the original pane)
279 insertIndex := 1
280 stackNode.stackedPanes = append(stackNode.stackedPanes, newLeaf)
281
282 // Unparent the new container before adding it to the stack
283 // This is critical: even freshly created WebViews might have internal parent refs
284 if newLeaf.container != nil {
285 parentWidget := webkit.WidgetGetParent(newLeaf.container)
286 if parentWidget != nil {
287 log.Printf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget)
288 webkit.WidgetUnparent(newLeaf.container)
289 // Verify unparent succeeded
290 if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
291 log.Printf("[workspace] WARNING: container %p still has parent %p after unparent", newLeaf.container, finalParent)
292 }
293 }
294 }
295
296 // Add the new widgets to the internal stack box
297 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
298 box.Append(newLeaf.titleBar)
299 }
300 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
301 box.Append(newLeaf.container)
302 }
303
304 return stackNode, insertIndex, nil
305}
306
307// detachFromParent removes a pane from its current parent
308func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
309 if parent == nil {
310 // Target is the root - clear it (handles both tab and non-tab environments)
311 spm.wm.clearRootContainer()
312 // Unparent if it has a GTK parent
313 if target.container != nil {
314 if webkit.WidgetGetParent(target.container) != nil {
315 webkit.WidgetUnparent(target.container)
316 }
317 }
318 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
319 // Target has a parent paned - remove it (automatically unparents in GTK4)
320 if parent.left == target {
321 paned.SetStartChild(nil)
322 } else if parent.right == target {
323 paned.SetEndChild(nil)
324 }
325 }
326}
327
328// reattachToParent attaches a container to the parent
329func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
330 if parent == nil {
331 // Attach as root (handles both tab and non-tab environments)
332 spm.wm.setRootContainer(container)
333 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
334 if parent.left == target {
335 paned.SetStartChild(container)
336 } else if parent.right == target {
337 paned.SetEndChild(container)
338 }
339 }
340}
341
342// finalizeStackCreation completes the stack creation process
343func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
344 // Update workspace state first
345 spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
346 spm.wm.setupPopupHandling(newLeaf.pane.webView, newLeaf)
347 spm.wm.ensureHover(newLeaf)
348 spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
349 if newLeaf.pane.zoomController != nil {
350 newLeaf.pane.zoomController.ApplyInitialZoom()
351 }
352
353 // Mark stack operation timestamp to prevent focus conflicts
354 spm.wm.lastStackOperation = time.Now()
355
356 // This ensures Zellij-style layout where the previously active pane shows its current title
357 currentActiveIndex := stackNode.activeStackIndex
358 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
359 currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
360 if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
361 currentTitle := currentlyActivePane.pane.webView.GetTitle()
362 if currentTitle != "" {
363 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
364 log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
365 }
366 }
367 }
368
369 // Immediately switch to the new pane (don't use deprecated IdleAdd)
370 stackNode.activeStackIndex = insertIndex
371 spm.UpdateStackVisibility(stackNode)
372
373 // Set focus on the new pane synchronously
374 spm.wm.SetActivePane(newLeaf, SourceSplit)
375
376 log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
377 stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
378 return newLeaf, nil
379}
380
381// UpdateStackVisibility updates the visibility of panes in a stack
382func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
383 log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
384
385 if stackNode == nil {
386 log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
387 return
388 }
389
390 if !stackNode.isStacked {
391 log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
392 return
393 }
394
395 if len(stackNode.stackedPanes) == 0 {
396 log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
397 return
398 }
399
400 activeIndex := stackNode.activeStackIndex
401 if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
402 log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
403 activeIndex = 0
404 stackNode.activeStackIndex = activeIndex
405 }
406
407 log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
408
409 // CRITICAL: Process ALL panes in a single pass to prevent flickering
410 for i, pane := range stackNode.stackedPanes {
411 if i == activeIndex {
412 // Active pane: show container, NEVER show title bar
413 if pane.container != nil {
414 webkit.WidgetSetVisible(pane.container, true)
415 }
416 if pane.titleBar != nil {
417 webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
418 }
419 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
420 } else {
421 // Inactive panes: hide container, show title bar
422 if pane.container != nil {
423 webkit.WidgetSetVisible(pane.container, false)
424 }
425 if pane.titleBar != nil {
426 webkit.WidgetSetVisible(pane.titleBar, true)
427
428 // Refresh title bar to pick up any newly downloaded favicons
429 if pane.pane != nil && pane.pane.webView != nil {
430 title := pane.pane.webView.GetTitle()
431 if title == "" {
432 title = NewTabTitle
433 }
434 spm.updateTitleBarLabel(pane, title)
435 }
436 }
437 }
438 }
439}
440
441// NavigateStack handles navigation within a stacked pane container
442func (spm *StackedPaneManager) NavigateStack(direction string) bool {
443 if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
444 return false
445 }
446
447 // Find the stack container this pane belongs to
448 var stackNode *paneNode
449 current := spm.wm.GetActiveNode()
450
451 // Check if current pane is directly in a stack
452 if current.parent != nil && current.parent.isStacked {
453 stackNode = current.parent
454 } else {
455 // Current pane might be the stack container itself if it was the first pane converted to stack
456 if current.isStacked {
457 stackNode = current
458 }
459 }
460
461 if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
462 return false // Not in a stack or stack has only one pane
463 }
464
465 // Use the stackNode's activeStackIndex instead of searching for current pane
466 // This is more reliable than trying to match panes
467 currentIndex := stackNode.activeStackIndex
468 if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
469 log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
470 currentIndex = 0
471 stackNode.activeStackIndex = 0
472 }
473
474 // Calculate new index based on direction
475 var newIndex int
476 switch direction {
477 case DirectionUp:
478 newIndex = currentIndex - 1
479 if newIndex < 0 {
480 // At the top of the stack - allow navigation outside
481 return false
482 }
483 case DirectionDown:
484 newIndex = currentIndex + 1
485 if newIndex >= len(stackNode.stackedPanes) {
486 // At the bottom of the stack - allow navigation outside
487 return false
488 }
489 default:
490 return false
491 }
492
493 if newIndex == currentIndex {
494 return false // No change
495 }
496
497 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
498 // This ensures the collapsed pane shows its current page title (Zellij-style layout)
499 currentActivePane := stackNode.stackedPanes[currentIndex]
500 if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
501 currentTitle := currentActivePane.pane.webView.GetTitle()
502 if currentTitle != "" {
503 spm.updateTitleBarLabel(currentActivePane, currentTitle)
504 log.Printf("[workspace] updated title bar for pane transitioning to INACTIVE: %s", currentTitle)
505 }
506 }
507
508 // Update active stack index and visibility
509 stackNode.activeStackIndex = newIndex
510 spm.UpdateStackVisibility(stackNode)
511
512 // Focus the new active pane
513 newActivePane := stackNode.stackedPanes[newIndex]
514 spm.wm.SetActivePane(newActivePane, SourceStackNav)
515
516 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
517 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
518 return true
519}
520
521// createTitleBar creates a title bar widget for a pane in a stack
522func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
523 // Get the actual title and URL from the WebView
524 var titleText, pageURL string
525 if pane.pane != nil && pane.pane.webView != nil {
526 titleText = pane.pane.webView.GetTitle()
527 if titleText == "" {
528 titleText = NewTabTitle // Fallback only when title is actually empty
529 }
530 pageURL = pane.pane.webView.GetCurrentURL()
531 } else {
532 titleText = NewTabTitle
533 }
534
535 titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
536 if titleBar != nil {
537 // Store the mapping from titleBar ID to pane
538 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
539 spm.titleBarToPane[titleBarID] = pane
540
541 // Attach click handler
542 webkit.WidgetAttachClickHandler(titleBar, func() {
543 spm.handleTitleBarClick(titleBarID)
544 })
545 }
546
547 return titleBar
548}
549
550// UpdateTitleBar updates the title bar label for a WebView in stacked panes
551func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
552 if spm.wm == nil || webView == nil || title == "" {
553 return
554 }
555
556 // Find the pane node for this WebView
557 node, exists := spm.wm.viewToNode[webView]
558 if !exists || node == nil || !node.isLeaf {
559 return
560 }
561
562 // Check if this pane is in a stack and has a title bar
563 if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
564 // CRITICAL: Only update title bar for INACTIVE panes
565 // Active panes should never show their title bar
566 stackNode := node.parent
567 activeIndex := stackNode.activeStackIndex
568
569 // Find this pane's index in the stack
570 paneIndex := -1
571 for i, pane := range stackNode.stackedPanes {
572 if pane == node {
573 paneIndex = i
574 break
575 }
576 }
577
578 if paneIndex != -1 && paneIndex != activeIndex {
579 // This is an INACTIVE pane - safe to update title bar
580 spm.updateTitleBarLabel(node, title)
581 log.Printf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title)
582 } else {
583 // This is the ACTIVE pane - title bar should remain hidden
584 log.Printf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title)
585 }
586 }
587}
588
589// updateTitleBarLabel updates the label widget within a title bar by recreating it
590func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
591 if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
592 return
593 }
594
595 // Get the current URL from the WebView for favicon
596 var pageURL string
597 if node.pane != nil && node.pane.webView != nil {
598 pageURL = node.pane.webView.GetCurrentURL()
599 }
600
601 // Create new title bar with updated title and favicon
602 newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
603 if newTitleBar == nil {
604 log.Printf("[workspace] failed to create new title bar")
605 return
606 }
607
608 // Store the mapping from titleBar ID to pane and attach click handler
609 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
610 spm.titleBarToPane[titleBarID] = node
611
612 // Attach click handler
613 webkit.WidgetAttachClickHandler(newTitleBar, func() {
614 spm.handleTitleBarClick(titleBarID)
615 })
616
617 // Replace the old title bar in the stack
618 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
619 // Find the correct insertion position based on the pane's index in the stack
620 paneIndex := -1
621 for i, stackedPane := range node.parent.stackedPanes {
622 if stackedPane == node {
623 paneIndex = i
624 break
625 }
626 }
627
628 // Remove old title bar
629 if node.titleBar != nil {
630 webkit.WidgetUnparent(node.titleBar)
631 }
632
633 // Update the node's title bar reference
634 spm.wm.setTitleBar(node, newTitleBar)
635
636 // Insert the new title bar at the correct position
637 if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
638 if paneIndex == 0 {
639 // First pane - insert at the beginning
640 box.Prepend(newTitleBar)
641 } else {
642 // Insert after the previous pane's container
643 prevPane := node.parent.stackedPanes[paneIndex-1]
644 if prevPane.container != nil {
645 box.InsertChildAfter(newTitleBar, prevPane.container)
646 }
647 }
648 }
649
650 log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
651 }
652}
653
654// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
655func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
656 titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
657 if titleBox == nil {
658 return nil
659 }
660
661 titleBox.AddCSSClass("stacked-pane-title")
662 titleBox.SetHExpand(true)
663 titleBox.SetVExpand(false)
664
665 placeholderLabel := gtk.NewLabel(faviconPlaceholder)
666 if placeholderLabel != nil {
667 placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
668 titleBox.Append(placeholderLabel)
669 webkit.WidgetSetVisible(placeholderLabel, true)
670 }
671
672 if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
673 spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
674 if err != nil {
675 log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
676 return
677 }
678
679 if texture != nil && titleBox != nil {
680
681 // Create favicon image with UI scaling
682 faviconImg := gtk.NewImageFromPaintable(texture)
683 // Scale favicon size based on UI scale (base: 16px)
684 faviconSize := 16
685 if spm.wm != nil && spm.wm.app != nil && spm.wm.app.config != nil {
686 uiScale := spm.wm.app.config.Workspace.Styling.UIScale
687 if uiScale > 0 {
688 faviconSize = int(16 * uiScale)
689 }
690 }
691 faviconImg.SetPixelSize(faviconSize)
692 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
693
694 // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
695 // This handles the case where multiple async callbacks fire for the same title bar
696 if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
697 titleBox.Remove(placeholderLabel)
698 }
699
700 // Prepend favicon image (multiple calls are safe - GTK handles it)
701 titleBox.Prepend(faviconImg)
702 webkit.WidgetSetVisible(faviconImg, true)
703 }
704 })
705 }
706
707 // Create title label with the specified title
708 titleLabel := gtk.NewLabel(title)
709 if titleLabel == nil {
710 return titleBox
711 }
712
713 titleLabel.AddCSSClass("stacked-pane-title-text")
714 titleLabel.SetEllipsize(2)
715
716 titleBox.Append(titleLabel)
717 webkit.WidgetSetVisible(titleBox, true)
718 webkit.WidgetSetVisible(titleLabel, true)
719
720 return titleBox
721}
722
723// TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30)
724//
725// CloseStackedPane handles closing a pane that is part of a stack.
726// The logic mirrors the regular close path: we rebuild the tree, update focus,
727// and let WorkspaceManager perform the actual cleanup/destroy work so that all
728// bookkeeping stays consistent.
729func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
730 if node == nil || node.parent == nil || !node.parent.isStacked {
731 return errors.New("node is not part of a stacked pane")
732 }
733
734 stackNode := node.parent
735
736 // Locate the pane inside the stack slice.
737 nodeIndex := -1
738 for i, stackedPane := range stackNode.stackedPanes {
739 if stackedPane == node {
740 nodeIndex = i
741 break
742 }
743 }
744 if nodeIndex == -1 {
745 return errors.New("node not found in stack")
746 }
747
748 log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
749
750 // Detach hover/focus controllers before tearing down widgets to avoid GTK
751 // callbacks referencing freed memory during destruction.
752 if spm.wm != nil {
753 spm.wm.detachHover(node)
754 spm.wm.detachFocus(node)
755 }
756
757 // Remove the pane widgets from the stack container before mutating the tree.
758 stackBox := stackNode.stackWrapper
759 if stackBox == nil {
760 stackBox = stackNode.container
761 }
762 if box, ok := stackBox.(*gtk.Box); ok && box != nil {
763 if node.titleBar != nil {
764 box.Remove(node.titleBar)
765 }
766 if node.container != nil {
767 box.Remove(node.container)
768 }
769 }
770
771 // Remove the pane from the stack slice.
772 stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
773
774 switch len(stackNode.stackedPanes) {
775 case 0:
776 // Stack container is now empty: remove it from the layout entirely.
777 parent := stackNode.parent
778 if parent == nil {
779 // Stack was the root of the workspace — closing this pane exits the app.
780 if stackNode.container != nil {
781 if webkit.WidgetGetParent(stackNode.container) != nil {
782 webkit.WidgetUnparent(stackNode.container)
783 }
784 }
785 // Clear root container (handles both tab and non-tab environments)
786 spm.wm.clearRootContainer()
787 _, err := spm.wm.cleanupAndExit(node)
788 return err
789 }
790
791 // Detach the empty stack container from its GtkPaned parent.
792 if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
793 if parent.left == stackNode {
794 paned.SetStartChild(nil)
795 } else if parent.right == stackNode {
796 paned.SetEndChild(nil)
797 }
798 webkit.WidgetQueueAllocate(paned)
799 }
800 if stackNode.container != nil {
801 if webkit.WidgetGetParent(stackNode.container) != nil {
802 webkit.WidgetUnparent(stackNode.container)
803 }
804 }
805
806 sibling := spm.wm.getSibling(stackNode)
807 grandparent := parent.parent
808
809 spm.wm.promoteSibling(grandparent, parent, sibling)
810 spm.wm.swapContainers(grandparent, parent, sibling)
811
812 generation := spm.wm.nextCleanupGeneration()
813 spm.wm.cleanupPane(node, generation)
814 spm.wm.decommissionParent(parent, generation)
815
816 if sibling != nil {
817 spm.wm.setFocusToLeaf(sibling)
818 } else {
819 spm.wm.updateMainPane()
820 }
821
822 stackNode.parent = nil
823 stackNode.container = nil
824 stackNode.stackWrapper = nil
825 stackNode.stackedPanes = nil
826
827 return nil
828
829 case 1:
830 // Promote the remaining pane back to a regular leaf node.
831 lastPane := stackNode.stackedPanes[0]
832 parent := stackNode.parent
833 lastPaneContainer := lastPane.container
834
835 if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
836 box.Remove(lastPane.titleBar)
837 }
838 if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
839 box.Remove(lastPaneContainer)
840 }
841 if stackNode.container != nil {
842 if webkit.WidgetGetParent(stackNode.container) != nil {
843 webkit.WidgetUnparent(stackNode.container)
844 }
845 }
846
847 if parent == nil {
848 // Stack was root - make last pane the new root
849 spm.wm.root = lastPane
850 lastPane.parent = nil
851 if lastPaneContainer != nil {
852 spm.wm.setRootContainer(lastPaneContainer)
853 webkit.WidgetQueueAllocate(lastPaneContainer)
854 webkit.WidgetShow(lastPaneContainer)
855 }
856 } else {
857 if parent.left == stackNode {
858 parent.left = lastPane
859 } else if parent.right == stackNode {
860 parent.right = lastPane
861 }
862 lastPane.parent = parent
863 if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
864 if parent.left == lastPane {
865 paned.SetStartChild(lastPaneContainer)
866 } else {
867 paned.SetEndChild(lastPaneContainer)
868 }
869 webkit.WidgetQueueAllocate(paned)
870 }
871 if lastPaneContainer != nil {
872 webkit.WidgetSetVisible(lastPaneContainer, true)
873 }
874 }
875
876 lastPane.isStacked = false
877 lastPane.stackedPanes = nil
878 lastPane.titleBar = nil
879 spm.wm.viewToNode[lastPane.pane.webView] = lastPane
880 spm.wm.setupPopupHandling(lastPane.pane.webView, lastPane)
881
882 generation := spm.wm.nextCleanupGeneration()
883 spm.wm.cleanupPane(node, generation)
884
885 spm.wm.SetActivePane(lastPane, SourceClose)
886 if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
887 webkit.WidgetShow(lastPaneWidget)
888 webkit.WidgetGrabFocus(lastPaneWidget)
889 }
890
891 stackNode.parent = nil
892 stackNode.container = nil
893 stackNode.stackWrapper = nil
894 stackNode.stackedPanes = nil
895
896 log.Printf("[workspace] converted stack back to regular pane")
897 return nil
898
899 default:
900 // Still multiple panes in stack — adjust active index and visibility.
901 if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
902 stackNode.activeStackIndex--
903 }
904 if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
905 stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
906 }
907
908 spm.UpdateStackVisibility(stackNode)
909 if spm.wm.GetActiveNode() == node {
910 newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
911 spm.wm.SetActivePane(newActivePane, SourceClose)
912 }
913
914 generation := spm.wm.nextCleanupGeneration()
915 spm.wm.cleanupPane(node, generation)
916
917 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
918 len(stackNode.stackedPanes), stackNode.activeStackIndex)
919 return nil
920 }
921}
922
923// handleTitleBarClick handles clicks on title bars to switch the active pane
924func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
925 pane, exists := spm.titleBarToPane[titleBarID]
926 if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
927 return
928 }
929
930 stackNode := pane.parent
931
932 // Find the index of the clicked pane
933 for i, p := range stackNode.stackedPanes {
934 if p == pane {
935 // Only switch if it's not already active
936 if i != stackNode.activeStackIndex {
937 log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
938 stackNode.activeStackIndex, i)
939
940 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
941 currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
942 if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
943 currentTitle := currentActivePane.pane.webView.GetTitle()
944 if currentTitle != "" {
945 spm.updateTitleBarLabel(currentActivePane, currentTitle)
946 }
947 }
948
949 stackNode.activeStackIndex = i
950 spm.UpdateStackVisibility(stackNode)
951 spm.wm.SetActivePane(pane, SourceStackNav)
952 }
953 return
954 }
955 }
956}