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