1package browser
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "sync/atomic"
8 "time"
9
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 // Create title bar for the new pane
102 newTitleBar := spm.createTitleBar(newLeaf)
103 spm.wm.setTitleBar(newLeaf, newTitleBar)
104
105 // Keep new container hidden initially - will be shown after transition
106 webkit.WidgetSetVisible(newContainer, false)
107 if newTitleBar != nil {
108 webkit.WidgetSetVisible(newTitleBar, false)
109 }
110
111 return newLeaf, nil
112}
113
114// addPaneToExistingStack adds a new pane to an existing stack
115func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) (*paneNode, int, error) {
116 stackNode := target.parent
117
118 // Find the current position of the target pane in the stack
119 currentIndex := -1
120 for i, pane := range stackNode.stackedPanes {
121 if pane == target {
122 currentIndex = i
123 break
124 }
125 }
126
127 if currentIndex == -1 {
128 return nil, 0, errors.New("target pane not found in stack")
129 }
130
131 // Insert the new pane right after the current pane
132 insertIndex := currentIndex + 1
133 log.Printf("[workspace] adding to existing stack: currentIndex=%d insertIndex=%d stackSize=%d",
134 currentIndex, insertIndex, len(stackNode.stackedPanes))
135
136 // Set parent relationship
137 newLeaf.parent = stackNode
138
139 // Insert the new pane at the correct position in the slice
140 stackNode.stackedPanes = append(stackNode.stackedPanes, nil) // Expand slice
141 copy(stackNode.stackedPanes[insertIndex+1:], stackNode.stackedPanes[insertIndex:]) // Shift elements
142 stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
143
144 // Unparent the new container before adding it to the stack (only if it has a parent)
145 if newLeaf.container != nil {
146 if webkit.WidgetGetParent(newLeaf.container) != nil {
147 webkit.WidgetUnparent(newLeaf.container)
148 }
149 }
150
151 // Insert widgets at correct position for Zellij-style layout
152 // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
153
154 // Find the widget to insert after (based on insertIndex)
155 var insertAfterWidget gtk.Widgetter
156 if insertIndex > 0 {
157 // Insert after the previous pane's container
158 prevPane := stackNode.stackedPanes[insertIndex-1]
159 if prevPane.container != nil {
160 insertAfterWidget = prevPane.container
161 }
162 }
163
164 if insertAfterWidget != nil {
165 // Insert titleBar after the previous pane's container
166 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
167 box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
168 }
169 // Insert container after the newly inserted titleBar
170 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
171 box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
172 }
173 log.Printf("[workspace] inserted widgets at position %d (after widget %p)", insertIndex, insertAfterWidget)
174 } else {
175 // Insert at the beginning (insertIndex = 0)
176 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
177 box.Prepend(newLeaf.container)
178 }
179 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
180 box.Prepend(newLeaf.titleBar)
181 }
182 log.Printf("[workspace] prepended widgets at position 0")
183 }
184
185 return stackNode, insertIndex, nil
186}
187
188// convertToStackedContainer converts a simple pane to a stacked container
189func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNode) (*paneNode, int, error) {
190 log.Printf("[workspace] converting pane to stacked: %p", target)
191
192 // Create the wrapper container - this is what will be used by splitNode
193 stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
194 if stackWrapperContainer == nil {
195 return nil, 0, errors.New("failed to create stack wrapper container")
196 }
197 stackWrapperContainer.SetHExpand(true)
198 stackWrapperContainer.SetVExpand(true)
199 // Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
200
201 // Add CSS class to the stack container for proper styling
202 stackWrapperContainer.AddCSSClass(stackContainerClass)
203
204 // Create the internal box for the actual stacked widgets (titles + webviews)
205 stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
206 if stackInternalBox == nil {
207 return nil, 0, errors.New("failed to create stack internal box")
208 }
209 stackInternalBox.SetHExpand(true)
210 stackInternalBox.SetVExpand(true)
211
212 // The internal box goes inside the wrapper
213 stackWrapperContainer.Append(stackInternalBox)
214
215 // Get the existing container and parent info
216 existingContainer := target.container
217 parent := target.parent
218
219 // Create title bar for the existing pane
220 titleBar := spm.createTitleBar(target)
221
222 // Keep existing container visible during transition to prevent rendering glitch
223 if titleBar != nil {
224 webkit.WidgetSetVisible(titleBar, false)
225 }
226
227 // Detach existing container from its current parent first
228 spm.detachFromParent(target, parent)
229
230 // Build the complete stack structure with hidden widgets
231 if titleBar != nil {
232 stackInternalBox.Append(titleBar)
233 }
234 if existingContainer != nil {
235 stackInternalBox.Append(existingContainer)
236 }
237
238 // Immediately reattach the stack wrapper to minimize visibility gap
239 spm.reattachToParent(stackWrapperContainer, target, parent)
240
241 // Keep target as a regular leaf node (it's just inside a stack now)
242 // Individual panes within a stack should NOT be marked as isStacked
243 // Only the stack container itself has isStacked=true
244 target.isStacked = false
245 target.isLeaf = true
246 // container stays the same (existingContainer)
247 spm.wm.setTitleBar(target, titleBar)
248
249 // Create the stack container node - container points to wrapper, stackWrapper points to internal box
250 stackNode := &paneNode{
251 isStacked: true,
252 isLeaf: false,
253 stackedPanes: []*paneNode{target},
254 activeStackIndex: 0, // KEEP CURRENT PANE ACTIVE during transition (index 0)
255 parent: parent,
256 }
257 spm.wm.setContainer(stackNode, stackWrapperContainer, "stack-wrapper") // Wrapper for GTK operations (splits, etc.)
258 spm.wm.setStackWrapper(stackNode, stackInternalBox) // Internal box for stack operations
259
260 // Update target's parent to be the stack node
261 target.parent = stackNode
262
263 // Update parent references (GTK operations already done above)
264 if parent == nil {
265 spm.wm.root = stackNode
266 } else {
267 if parent.left == target {
268 parent.left = stackNode
269 } else if parent.right == target {
270 parent.right = stackNode
271 }
272 }
273
274 // Set up the new pane
275 newLeaf.parent = stackNode
276
277 // Insert the new pane at index 1 (after the original pane)
278 insertIndex := 1
279 stackNode.stackedPanes = append(stackNode.stackedPanes, newLeaf)
280
281 // Unparent the new container before adding it to the stack
282 // This is critical: even freshly created WebViews might have internal parent refs
283 if newLeaf.container != nil {
284 parentWidget := webkit.WidgetGetParent(newLeaf.container)
285 if parentWidget != nil {
286 log.Printf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget)
287 webkit.WidgetUnparent(newLeaf.container)
288 // Verify unparent succeeded
289 if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
290 log.Printf("[workspace] WARNING: container %p still has parent %p after unparent", newLeaf.container, finalParent)
291 }
292 }
293 }
294
295 // Add the new widgets to the internal stack box
296 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
297 box.Append(newLeaf.titleBar)
298 }
299 if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
300 box.Append(newLeaf.container)
301 }
302
303 return stackNode, insertIndex, nil
304}
305
306// detachFromParent removes a pane from its current parent
307func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
308 if parent == nil {
309 // Target is the root - remove it from window
310 if spm.wm.window != nil {
311 spm.wm.window.SetChild(nil)
312 }
313 // Unparent if it has a GTK parent
314 if target.container != nil {
315 if webkit.WidgetGetParent(target.container) != nil {
316 webkit.WidgetUnparent(target.container)
317 }
318 }
319 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
320 // Target has a parent paned - remove it (automatically unparents in GTK4)
321 if parent.left == target {
322 paned.SetStartChild(nil)
323 } else if parent.right == target {
324 paned.SetEndChild(nil)
325 }
326 }
327}
328
329// reattachToParent attaches a container to the parent
330func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
331 if parent == nil {
332 if spm.wm.window != nil {
333 spm.wm.window.SetChild(container)
334 }
335 } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
336 if parent.left == target {
337 paned.SetStartChild(container)
338 } else if parent.right == target {
339 paned.SetEndChild(container)
340 }
341 }
342}
343
344// finalizeStackCreation completes the stack creation process
345func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
346 // Update workspace state first
347 spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
348 spm.wm.ensureHover(newLeaf)
349 spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
350 if newLeaf.pane.zoomController != nil {
351 newLeaf.pane.zoomController.ApplyInitialZoom()
352 }
353
354 // Mark stack operation timestamp to prevent focus conflicts
355 spm.wm.lastStackOperation = time.Now()
356
357 // This ensures Zellij-style layout where the previously active pane shows its current title
358 currentActiveIndex := stackNode.activeStackIndex
359 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
360 currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
361 if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
362 currentTitle := currentlyActivePane.pane.webView.GetTitle()
363 if currentTitle != "" {
364 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
365 log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
366 }
367 }
368 }
369
370 // Immediately switch to the new pane (don't use deprecated IdleAdd)
371 stackNode.activeStackIndex = insertIndex
372 spm.UpdateStackVisibility(stackNode)
373
374 // Set focus on the new pane synchronously
375 spm.wm.SetActivePane(newLeaf, SourceSplit)
376
377 log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
378 stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
379 return newLeaf, nil
380}
381
382// UpdateStackVisibility updates the visibility of panes in a stack
383func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
384 log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
385
386 if stackNode == nil {
387 log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
388 return
389 }
390
391 if !stackNode.isStacked {
392 log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
393 return
394 }
395
396 if len(stackNode.stackedPanes) == 0 {
397 log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
398 return
399 }
400
401 activeIndex := stackNode.activeStackIndex
402 if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
403 log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
404 activeIndex = 0
405 stackNode.activeStackIndex = activeIndex
406 }
407
408 log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
409
410 // CRITICAL: Process ALL panes in a single pass to prevent flickering
411 for i, pane := range stackNode.stackedPanes {
412 if i == activeIndex {
413 // Active pane: show container, NEVER show title bar
414 if pane.container != nil {
415 webkit.WidgetSetVisible(pane.container, true)
416 }
417 if pane.titleBar != nil {
418 webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
419 }
420 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
421 } else {
422 // Inactive panes: hide container, show title bar
423 if pane.container != nil {
424 webkit.WidgetSetVisible(pane.container, false)
425 }
426 if pane.titleBar != nil {
427 webkit.WidgetSetVisible(pane.titleBar, true)
428
429 // Refresh title bar to pick up any newly downloaded favicons
430 if pane.pane != nil && pane.pane.webView != nil {
431 title := pane.pane.webView.GetTitle()
432 if title == "" {
433 title = "New Tab"
434 }
435 spm.updateTitleBarLabel(pane, title)
436 }
437 }
438 }
439 }
440}
441
442// NavigateStack handles navigation within a stacked pane container
443func (spm *StackedPaneManager) NavigateStack(direction string) bool {
444 if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
445 return false
446 }
447
448 // Find the stack container this pane belongs to
449 var stackNode *paneNode
450 current := spm.wm.GetActiveNode()
451
452 // Check if current pane is directly in a stack
453 if current.parent != nil && current.parent.isStacked {
454 stackNode = current.parent
455 } else {
456 // Current pane might be the stack container itself if it was the first pane converted to stack
457 if current.isStacked {
458 stackNode = current
459 }
460 }
461
462 if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
463 return false // Not in a stack or stack has only one pane
464 }
465
466 // Use the stackNode's activeStackIndex instead of searching for current pane
467 // This is more reliable than trying to match panes
468 currentIndex := stackNode.activeStackIndex
469 if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
470 log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
471 currentIndex = 0
472 stackNode.activeStackIndex = 0
473 }
474
475 // Calculate new index based on direction
476 var newIndex int
477 switch direction {
478 case "up":
479 newIndex = currentIndex - 1
480 if newIndex < 0 {
481 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
482 }
483 case "down":
484 newIndex = currentIndex + 1
485 if newIndex >= len(stackNode.stackedPanes) {
486 newIndex = 0 // Wrap to first
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 = "New Tab" // Fallback only when title is actually empty
528 }
529 pageURL = pane.pane.webView.GetCurrentURL()
530 } else {
531 titleText = "New Tab"
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 // TODO: Implement click handler with gtk.GestureClick
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 // TODO: Implement with gtk.GestureClick
614 // webkit.WidgetAttachClickHandler(newTitleBar, func() {
615 // spm.handleTitleBarClick(titleBarID)
616 // })
617
618 // Replace the old title bar in the stack
619 if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
620 // Find the correct insertion position based on the pane's index in the stack
621 paneIndex := -1
622 for i, stackedPane := range node.parent.stackedPanes {
623 if stackedPane == node {
624 paneIndex = i
625 break
626 }
627 }
628
629 // Remove old title bar
630 if node.titleBar != nil {
631 webkit.WidgetUnparent(node.titleBar)
632 }
633
634 // Update the node's title bar reference
635 spm.wm.setTitleBar(node, newTitleBar)
636
637 // Insert the new title bar at the correct position
638 if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
639 if paneIndex == 0 {
640 // First pane - insert at the beginning
641 box.Prepend(newTitleBar)
642 } else {
643 // Insert after the previous pane's container
644 prevPane := node.parent.stackedPanes[paneIndex-1]
645 if prevPane.container != nil {
646 box.InsertChildAfter(newTitleBar, prevPane.container)
647 }
648 }
649 }
650
651 log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
652 }
653}
654
655// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
656func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
657 titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
658 if titleBox == nil {
659 return nil
660 }
661
662 titleBox.AddCSSClass("stacked-pane-title")
663 titleBox.SetHExpand(true)
664 titleBox.SetVExpand(false)
665
666 placeholderLabel := gtk.NewLabel(faviconPlaceholder)
667 if placeholderLabel != nil {
668 placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
669 titleBox.Append(placeholderLabel)
670 webkit.WidgetSetVisible(placeholderLabel, true)
671 }
672
673 if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
674 spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
675 if err != nil {
676 log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err)
677 return
678 }
679
680 if texture != nil && titleBox != nil {
681 log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL)
682
683 // Create favicon image
684 faviconImg := gtk.NewImageFromPaintable(texture)
685 faviconImg.SetPixelSize(16)
686 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
687
688 // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
689 // This handles the case where multiple async callbacks fire for the same title bar
690 if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
691 titleBox.Remove(placeholderLabel)
692 }
693
694 // Prepend favicon image (multiple calls are safe - GTK handles it)
695 titleBox.Prepend(faviconImg)
696 webkit.WidgetSetVisible(faviconImg, true)
697 }
698 })
699 }
700
701 // Create title label with the specified title
702 titleLabel := gtk.NewLabel(title)
703 if titleLabel == nil {
704 return titleBox
705 }
706
707 titleLabel.AddCSSClass("stacked-pane-title-text")
708 titleLabel.SetEllipsize(2)
709
710 titleBox.Append(titleLabel)
711 webkit.WidgetSetVisible(titleBox, true)
712 webkit.WidgetSetVisible(titleLabel, true)
713
714 return titleBox
715}
716
717// CloseStackedPane handles closing a pane that is part of a stack.
718// The logic mirrors the regular close path: we rebuild the tree, update focus,
719// and let WorkspaceManager perform the actual cleanup/destroy work so that all
720// bookkeeping stays consistent.
721func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
722 if node == nil || node.parent == nil || !node.parent.isStacked {
723 return errors.New("node is not part of a stacked pane")
724 }
725
726 stackNode := node.parent
727
728 // Locate the pane inside the stack slice.
729 nodeIndex := -1
730 for i, stackedPane := range stackNode.stackedPanes {
731 if stackedPane == node {
732 nodeIndex = i
733 break
734 }
735 }
736 if nodeIndex == -1 {
737 return errors.New("node not found in stack")
738 }
739
740 log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
741
742 // Detach hover/focus controllers before tearing down widgets to avoid GTK
743 // callbacks referencing freed memory during destruction.
744 if spm.wm != nil {
745 spm.wm.detachHover(node)
746 spm.wm.detachFocus(node)
747 }
748
749 // Remove the pane widgets from the stack container before mutating the tree.
750 stackBox := stackNode.stackWrapper
751 if stackBox == nil {
752 stackBox = stackNode.container
753 }
754 if box, ok := stackBox.(*gtk.Box); ok && box != nil {
755 if node.titleBar != nil {
756 box.Remove(node.titleBar)
757 }
758 if node.container != nil {
759 box.Remove(node.container)
760 }
761 }
762
763 // Remove the pane from the stack slice.
764 stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
765
766 switch len(stackNode.stackedPanes) {
767 case 0:
768 // Stack container is now empty: remove it from the layout entirely.
769 parent := stackNode.parent
770 if parent == nil {
771 // Stack was the root of the workspace — closing this pane exits the app.
772 if stackNode.container != nil {
773 if webkit.WidgetGetParent(stackNode.container) != nil {
774 webkit.WidgetUnparent(stackNode.container)
775 }
776 }
777 if spm.wm.window != nil {
778 spm.wm.window.SetChild(nil)
779 }
780 _, err := spm.wm.cleanupAndExit(node)
781 return err
782 }
783
784 // Detach the empty stack container from its GtkPaned parent.
785 if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
786 if parent.left == stackNode {
787 paned.SetStartChild(nil)
788 } else if parent.right == stackNode {
789 paned.SetEndChild(nil)
790 }
791 webkit.WidgetQueueAllocate(paned)
792 }
793 if stackNode.container != nil {
794 if webkit.WidgetGetParent(stackNode.container) != nil {
795 webkit.WidgetUnparent(stackNode.container)
796 }
797 }
798
799 sibling := spm.wm.getSibling(stackNode)
800 grandparent := parent.parent
801
802 spm.wm.promoteSibling(grandparent, parent, sibling)
803 spm.wm.swapContainers(grandparent, sibling)
804
805 generation := spm.wm.nextCleanupGeneration()
806 spm.wm.cleanupPane(node, generation)
807 spm.wm.decommissionParent(parent, generation)
808
809 if sibling != nil {
810 spm.wm.setFocusToLeaf(sibling)
811 } else {
812 spm.wm.updateMainPane()
813 }
814
815 stackNode.parent = nil
816 stackNode.container = nil
817 stackNode.stackWrapper = nil
818 stackNode.stackedPanes = nil
819
820 return nil
821
822 case 1:
823 // Promote the remaining pane back to a regular leaf node.
824 lastPane := stackNode.stackedPanes[0]
825 parent := stackNode.parent
826 lastPaneContainer := lastPane.container
827
828 if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
829 box.Remove(lastPane.titleBar)
830 }
831 if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
832 box.Remove(lastPaneContainer)
833 }
834 if stackNode.container != nil {
835 if webkit.WidgetGetParent(stackNode.container) != nil {
836 webkit.WidgetUnparent(stackNode.container)
837 }
838 }
839
840 if parent == nil {
841 spm.wm.root = lastPane
842 lastPane.parent = nil
843 if spm.wm.window != nil && lastPaneContainer != nil {
844 spm.wm.window.SetChild(lastPaneContainer)
845 webkit.WidgetQueueAllocate(lastPaneContainer)
846 webkit.WidgetShow(lastPaneContainer)
847 }
848 } else {
849 if parent.left == stackNode {
850 parent.left = lastPane
851 } else if parent.right == stackNode {
852 parent.right = lastPane
853 }
854 lastPane.parent = parent
855 if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
856 if parent.left == lastPane {
857 paned.SetStartChild(lastPaneContainer)
858 } else {
859 paned.SetEndChild(lastPaneContainer)
860 }
861 webkit.WidgetQueueAllocate(paned)
862 }
863 if lastPaneContainer != nil {
864 webkit.WidgetSetVisible(lastPaneContainer, true)
865 }
866 }
867
868 lastPane.isStacked = false
869 lastPane.stackedPanes = nil
870 lastPane.titleBar = nil
871 spm.wm.viewToNode[lastPane.pane.webView] = lastPane
872
873 generation := spm.wm.nextCleanupGeneration()
874 spm.wm.cleanupPane(node, generation)
875
876 spm.wm.SetActivePane(lastPane, SourceClose)
877 if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
878 webkit.WidgetShow(lastPaneWidget)
879 webkit.WidgetGrabFocus(lastPaneWidget)
880 }
881
882 stackNode.parent = nil
883 stackNode.container = nil
884 stackNode.stackWrapper = nil
885 stackNode.stackedPanes = nil
886
887 log.Printf("[workspace] converted stack back to regular pane")
888 return nil
889
890 default:
891 // Still multiple panes in stack — adjust active index and visibility.
892 if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
893 stackNode.activeStackIndex--
894 }
895 if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
896 stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
897 }
898
899 spm.UpdateStackVisibility(stackNode)
900 if spm.wm.GetActiveNode() == node {
901 newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
902 spm.wm.SetActivePane(newActivePane, SourceClose)
903 }
904
905 generation := spm.wm.nextCleanupGeneration()
906 spm.wm.cleanupPane(node, generation)
907
908 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
909 len(stackNode.stackedPanes), stackNode.activeStackIndex)
910 return nil
911 }
912}
913
914// handleTitleBarClick handles clicks on title bars to switch the active pane
915func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
916 pane, exists := spm.titleBarToPane[titleBarID]
917 if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
918 return
919 }
920
921 stackNode := pane.parent
922
923 // Find the index of the clicked pane
924 for i, p := range stackNode.stackedPanes {
925 if p == pane {
926 // Only switch if it's not already active
927 if i != stackNode.activeStackIndex {
928 log.Printf("[workspace] title bar clicked: switching from pane %d to pane %d",
929 stackNode.activeStackIndex, i)
930
931 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
932 currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
933 if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
934 currentTitle := currentActivePane.pane.webView.GetTitle()
935 if currentTitle != "" {
936 spm.updateTitleBarLabel(currentActivePane, currentTitle)
937 }
938 }
939
940 stackNode.activeStackIndex = i
941 spm.UpdateStackVisibility(stackNode)
942 spm.wm.SetActivePane(pane, SourceStackNav)
943 }
944 return
945 }
946 }
947}