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