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