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.ensureHover(newLeaf)
347 spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
348 if newLeaf.pane.zoomController != nil {
349 newLeaf.pane.zoomController.ApplyInitialZoom()
350 }
351
352 // Mark stack operation timestamp to prevent focus conflicts
353 spm.wm.lastStackOperation = time.Now()
354
355 // This ensures Zellij-style layout where the previously active pane shows its current title
356 currentActiveIndex := stackNode.activeStackIndex
357 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
358 currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
359 if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
360 currentTitle := currentlyActivePane.pane.webView.GetTitle()
361 if currentTitle != "" {
362 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
363 log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
364 }
365 }
366 }
367
368 // Immediately switch to the new pane (don't use deprecated IdleAdd)
369 stackNode.activeStackIndex = insertIndex
370 spm.UpdateStackVisibility(stackNode)
371
372 // Set focus on the new pane synchronously
373 spm.wm.SetActivePane(newLeaf, SourceSplit)
374
375 log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
376 stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
377 return newLeaf, nil
378}
379
380// UpdateStackVisibility updates the visibility of panes in a stack
381func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
382 log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
383
384 if stackNode == nil {
385 log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
386 return
387 }
388
389 if !stackNode.isStacked {
390 log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
391 return
392 }
393
394 if len(stackNode.stackedPanes) == 0 {
395 log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
396 return
397 }
398
399 activeIndex := stackNode.activeStackIndex
400 if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
401 log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
402 activeIndex = 0
403 stackNode.activeStackIndex = activeIndex
404 }
405
406 log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
407
408 // CRITICAL: Process ALL panes in a single pass to prevent flickering
409 for i, pane := range stackNode.stackedPanes {
410 if i == activeIndex {
411 // Active pane: show container, NEVER show title bar
412 if pane.container != nil {
413 webkit.WidgetSetVisible(pane.container, true)
414 }
415 if pane.titleBar != nil {
416 webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
417 }
418 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
419 } else {
420 // Inactive panes: hide container, show title bar
421 if pane.container != nil {
422 webkit.WidgetSetVisible(pane.container, false)
423 }
424 if pane.titleBar != nil {
425 webkit.WidgetSetVisible(pane.titleBar, true)
426
427 // Refresh title bar to pick up any newly downloaded favicons
428 if pane.pane != nil && pane.pane.webView != nil {
429 title := pane.pane.webView.GetTitle()
430 if title == "" {
431 title = NewTabTitle
432 }
433 spm.updateTitleBarLabel(pane, title)
434 }
435 }
436 }
437 }
438}
439
440// NavigateStack handles navigation within a stacked pane container
441func (spm *StackedPaneManager) NavigateStack(direction string) bool {
442 if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
443 return false
444 }
445
446 // Find the stack container this pane belongs to
447 var stackNode *paneNode
448 current := spm.wm.GetActiveNode()
449
450 // Check if current pane is directly in a stack
451 if current.parent != nil && current.parent.isStacked {
452 stackNode = current.parent
453 } else {
454 // Current pane might be the stack container itself if it was the first pane converted to stack
455 if current.isStacked {
456 stackNode = current
457 }
458 }
459
460 if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
461 return false // Not in a stack or stack has only one pane
462 }
463
464 // Use the stackNode's activeStackIndex instead of searching for current pane
465 // This is more reliable than trying to match panes
466 currentIndex := stackNode.activeStackIndex
467 if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
468 log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
469 currentIndex = 0
470 stackNode.activeStackIndex = 0
471 }
472
473 // Calculate new index based on direction
474 var newIndex int
475 switch direction {
476 case DirectionUp:
477 newIndex = currentIndex - 1
478 if newIndex < 0 {
479 // At the top of the stack - allow navigation outside
480 return false
481 }
482 case DirectionDown:
483 newIndex = currentIndex + 1
484 if newIndex >= len(stackNode.stackedPanes) {
485 // At the bottom of the stack - allow navigation outside
486 return false
487 }
488 default:
489 return false
490 }
491
492 if newIndex == currentIndex {
493 return false // No change
494 }
495
496 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
497 // This ensures the collapsed pane shows its current page title (Zellij-style layout)
498 currentActivePane := stackNode.stackedPanes[currentIndex]
499 if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
500 currentTitle := currentActivePane.pane.webView.GetTitle()
501 if currentTitle != "" {
502 spm.updateTitleBarLabel(currentActivePane, currentTitle)
503 log.Printf("[workspace] updated title bar for pane transitioning to INACTIVE: %s", currentTitle)
504 }
505 }
506
507 // Update active stack index and visibility
508 stackNode.activeStackIndex = newIndex
509 spm.UpdateStackVisibility(stackNode)
510
511 // Focus the new active pane
512 newActivePane := stackNode.stackedPanes[newIndex]
513 spm.wm.SetActivePane(newActivePane, SourceStackNav)
514
515 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
516 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
517 return true
518}
519
520// createTitleBar creates a title bar widget for a pane in a stack
521func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
522 // Get the actual title and URL from the WebView
523 var titleText, pageURL string
524 if pane.pane != nil && pane.pane.webView != nil {
525 titleText = pane.pane.webView.GetTitle()
526 if titleText == "" {
527 titleText = NewTabTitle // Fallback only when title is actually empty
528 }
529 pageURL = pane.pane.webView.GetCurrentURL()
530 } else {
531 titleText = NewTabTitle
532 }
533
534 titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
535 if titleBar != nil {
536 // Store the mapping from titleBar ID to pane
537 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
538 spm.titleBarToPane[titleBarID] = pane
539
540 // Attach click handler
541 webkit.WidgetAttachClickHandler(titleBar, func() {
542 spm.handleTitleBarClick(titleBarID)
543 })
544 }
545
546 return titleBar
547}
548
549// UpdateTitleBar updates the title bar label for a WebView in stacked panes
550func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
551 if spm.wm == nil || webView == nil || title == "" {
552 return
553 }
554
555 // Find the pane node for this WebView
556 node, exists := spm.wm.viewToNode[webView]
557 if !exists || node == nil || !node.isLeaf {
558 return
559 }
560
561 // Check if this pane is in a stack and has a title bar
562 if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
563 // CRITICAL: Only update title bar for INACTIVE panes
564 // Active panes should never show their title bar
565 stackNode := node.parent
566 activeIndex := stackNode.activeStackIndex
567
568 // Find this pane's index in the stack
569 paneIndex := -1
570 for i, pane := range stackNode.stackedPanes {
571 if pane == node {
572 paneIndex = i
573 break
574 }
575 }
576
577 if paneIndex != -1 && paneIndex != activeIndex {
578 // This is an INACTIVE pane - safe to update title bar
579 spm.updateTitleBarLabel(node, title)
580 log.Printf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title)
581 } else {
582 // This is the ACTIVE pane - title bar should remain hidden
583 log.Printf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title)
584 }
585 }
586}
587
588// updateTitleBarLabel updates the label widget within a title bar by recreating it
589func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
590 if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
591 return
592 }
593
594 // Get the current URL from the WebView for favicon
595 var pageURL string
596 if node.pane != nil && node.pane.webView != nil {
597 pageURL = node.pane.webView.GetCurrentURL()
598 }
599
600 // Create new title bar with updated title and favicon
601 newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
602 if newTitleBar == nil {
603 log.Printf("[workspace] failed to create new title bar")
604 return
605 }
606
607 // Store the mapping from titleBar ID to pane and attach click handler
608 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
609 spm.titleBarToPane[titleBarID] = node
610
611 // Attach click handler
612 webkit.WidgetAttachClickHandler(newTitleBar, func() {
613 spm.handleTitleBarClick(titleBarID)
614 })
615
616 // Replace the old title bar in the stack
617 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
618 // Find the correct insertion position based on the pane's index in the stack
619 paneIndex := -1
620 for i, stackedPane := range node.parent.stackedPanes {
621 if stackedPane == node {
622 paneIndex = i
623 break
624 }
625 }
626
627 // Remove old title bar
628 if node.titleBar != nil {
629 webkit.WidgetUnparent(node.titleBar)
630 }
631
632 // Update the node's title bar reference
633 spm.wm.setTitleBar(node, newTitleBar)
634
635 // Insert the new title bar at the correct position
636 if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
637 if paneIndex == 0 {
638 // First pane - insert at the beginning
639 box.Prepend(newTitleBar)
640 } else {
641 // Insert after the previous pane's container
642 prevPane := node.parent.stackedPanes[paneIndex-1]
643 if prevPane.container != nil {
644 box.InsertChildAfter(newTitleBar, prevPane.container)
645 }
646 }
647 }
648
649 log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
650 }
651}
652
653// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
654func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
655 titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
656 if titleBox == nil {
657 return nil
658 }
659
660 titleBox.AddCSSClass("stacked-pane-title")
661 titleBox.SetHExpand(true)
662 titleBox.SetVExpand(false)
663
664 placeholderLabel := gtk.NewLabel(faviconPlaceholder)
665 if placeholderLabel != nil {
666 placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
667 titleBox.Append(placeholderLabel)
668 webkit.WidgetSetVisible(placeholderLabel, true)
669 }
670
671 if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
672 spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
673 if err != nil {
674 log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
675 return
676 }
677
678 if texture != nil && titleBox != nil {
679
680 // Create favicon image with UI scaling
681 faviconImg := gtk.NewImageFromPaintable(texture)
682 // Scale favicon size based on UI scale (base: 16px)
683 faviconSize := 16
684 if spm.wm != nil && spm.wm.app != nil && spm.wm.app.config != nil {
685 uiScale := spm.wm.app.config.Workspace.Styling.UIScale
686 if uiScale > 0 {
687 faviconSize = int(16 * uiScale)
688 }
689 }
690 faviconImg.SetPixelSize(faviconSize)
691 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
692
693 // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
694 // This handles the case where multiple async callbacks fire for the same title bar
695 if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
696 titleBox.Remove(placeholderLabel)
697 }
698
699 // Prepend favicon image (multiple calls are safe - GTK handles it)
700 titleBox.Prepend(faviconImg)
701 webkit.WidgetSetVisible(faviconImg, true)
702 }
703 })
704 }
705
706 // Create title label with the specified title
707 titleLabel := gtk.NewLabel(title)
708 if titleLabel == nil {
709 return titleBox
710 }
711
712 titleLabel.AddCSSClass("stacked-pane-title-text")
713 titleLabel.SetEllipsize(2)
714
715 titleBox.Append(titleLabel)
716 webkit.WidgetSetVisible(titleBox, true)
717 webkit.WidgetSetVisible(titleLabel, true)
718
719 return titleBox
720}
721
722// TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30)
723//
724// CloseStackedPane handles closing a pane that is part of a stack.
725// The logic mirrors the regular close path: we rebuild the tree, update focus,
726// and let WorkspaceManager perform the actual cleanup/destroy work so that all
727// bookkeeping stays consistent.
728func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
729 if node == nil || node.parent == nil || !node.parent.isStacked {
730 return errors.New("node is not part of a stacked pane")
731 }
732
733 stackNode := node.parent
734
735 // Locate the pane inside the stack slice.
736 nodeIndex := -1
737 for i, stackedPane := range stackNode.stackedPanes {
738 if stackedPane == node {
739 nodeIndex = i
740 break
741 }
742 }
743 if nodeIndex == -1 {
744 return errors.New("node not found in stack")
745 }
746
747 log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
748
749 // Detach hover/focus controllers before tearing down widgets to avoid GTK
750 // callbacks referencing freed memory during destruction.
751 if spm.wm != nil {
752 spm.wm.detachHover(node)
753 spm.wm.detachFocus(node)
754 }
755
756 // Remove the pane widgets from the stack container before mutating the tree.
757 stackBox := stackNode.stackWrapper
758 if stackBox == nil {
759 stackBox = stackNode.container
760 }
761 if box, ok := stackBox.(*gtk.Box); ok && box != nil {
762 if node.titleBar != nil {
763 box.Remove(node.titleBar)
764 }
765 if node.container != nil {
766 box.Remove(node.container)
767 }
768 }
769
770 // Remove the pane from the stack slice.
771 stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
772
773 switch len(stackNode.stackedPanes) {
774 case 0:
775 // Stack container is now empty: remove it from the layout entirely.
776 parent := stackNode.parent
777 if parent == nil {
778 // Stack was the root of the workspace — closing this pane exits the app.
779 if stackNode.container != nil {
780 if webkit.WidgetGetParent(stackNode.container) != nil {
781 webkit.WidgetUnparent(stackNode.container)
782 }
783 }
784 // Clear root container (handles both tab and non-tab environments)
785 spm.wm.clearRootContainer()
786 _, err := spm.wm.cleanupAndExit(node)
787 return err
788 }
789
790 // Detach the empty stack container from its GtkPaned parent.
791 if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
792 if parent.left == stackNode {
793 paned.SetStartChild(nil)
794 } else if parent.right == stackNode {
795 paned.SetEndChild(nil)
796 }
797 webkit.WidgetQueueAllocate(paned)
798 }
799 if stackNode.container != nil {
800 if webkit.WidgetGetParent(stackNode.container) != nil {
801 webkit.WidgetUnparent(stackNode.container)
802 }
803 }
804
805 sibling := spm.wm.getSibling(stackNode)
806 grandparent := parent.parent
807
808 spm.wm.promoteSibling(grandparent, parent, sibling)
809 spm.wm.swapContainers(grandparent, parent, sibling)
810
811 generation := spm.wm.nextCleanupGeneration()
812 spm.wm.cleanupPane(node, generation)
813 spm.wm.decommissionParent(parent, generation)
814
815 if sibling != nil {
816 spm.wm.setFocusToLeaf(sibling)
817 } else {
818 spm.wm.updateMainPane()
819 }
820
821 stackNode.parent = nil
822 stackNode.container = nil
823 stackNode.stackWrapper = nil
824 stackNode.stackedPanes = nil
825
826 return nil
827
828 case 1:
829 // Promote the remaining pane back to a regular leaf node.
830 lastPane := stackNode.stackedPanes[0]
831 parent := stackNode.parent
832 lastPaneContainer := lastPane.container
833
834 if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
835 box.Remove(lastPane.titleBar)
836 }
837 if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
838 box.Remove(lastPaneContainer)
839 }
840 if stackNode.container != nil {
841 if webkit.WidgetGetParent(stackNode.container) != nil {
842 webkit.WidgetUnparent(stackNode.container)
843 }
844 }
845
846 if parent == nil {
847 // Stack was root - make last pane the new root
848 spm.wm.root = lastPane
849 lastPane.parent = nil
850 if lastPaneContainer != nil {
851 spm.wm.setRootContainer(lastPaneContainer)
852 webkit.WidgetQueueAllocate(lastPaneContainer)
853 webkit.WidgetShow(lastPaneContainer)
854 }
855 } else {
856 if parent.left == stackNode {
857 parent.left = lastPane
858 } else if parent.right == stackNode {
859 parent.right = lastPane
860 }
861 lastPane.parent = parent
862 if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
863 if parent.left == lastPane {
864 paned.SetStartChild(lastPaneContainer)
865 } else {
866 paned.SetEndChild(lastPaneContainer)
867 }
868 webkit.WidgetQueueAllocate(paned)
869 }
870 if lastPaneContainer != nil {
871 webkit.WidgetSetVisible(lastPaneContainer, true)
872 }
873 }
874
875 lastPane.isStacked = false
876 lastPane.stackedPanes = nil
877 lastPane.titleBar = nil
878 spm.wm.viewToNode[lastPane.pane.webView] = lastPane
879
880 generation := spm.wm.nextCleanupGeneration()
881 spm.wm.cleanupPane(node, generation)
882
883 spm.wm.SetActivePane(lastPane, SourceClose)
884 if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
885 webkit.WidgetShow(lastPaneWidget)
886 webkit.WidgetGrabFocus(lastPaneWidget)
887 }
888
889 stackNode.parent = nil
890 stackNode.container = nil
891 stackNode.stackWrapper = nil
892 stackNode.stackedPanes = nil
893
894 log.Printf("[workspace] converted stack back to regular pane")
895 return nil
896
897 default:
898 // Still multiple panes in stack — adjust active index and visibility.
899 if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
900 stackNode.activeStackIndex--
901 }
902 if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
903 stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
904 }
905
906 spm.UpdateStackVisibility(stackNode)
907 if spm.wm.GetActiveNode() == node {
908 newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
909 spm.wm.SetActivePane(newActivePane, SourceClose)
910 }
911
912 generation := spm.wm.nextCleanupGeneration()
913 spm.wm.cleanupPane(node, generation)
914
915 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
916 len(stackNode.stackedPanes), stackNode.activeStackIndex)
917 return nil
918 }
919}
920
921// handleTitleBarClick handles clicks on title bars to switch the active pane
922func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
923 pane, exists := spm.titleBarToPane[titleBarID]
924 if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
925 return
926 }
927
928 stackNode := pane.parent
929
930 // Find the index of the clicked pane
931 for i, p := range stackNode.stackedPanes {
932 if p == pane {
933 // Only switch if it's not already active
934 if i != stackNode.activeStackIndex {
935 log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
936 stackNode.activeStackIndex, i)
937
938 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
939 currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
940 if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
941 currentTitle := currentActivePane.pane.webView.GetTitle()
942 if currentTitle != "" {
943 spm.updateTitleBarLabel(currentActivePane, currentTitle)
944 }
945 }
946
947 stackNode.activeStackIndex = i
948 spm.UpdateStackVisibility(stackNode)
949 spm.wm.SetActivePane(pane, SourceStackNav)
950 }
951 return
952 }
953 }
954}