1// workspace_pane_ops.go - Pane creation, splitting, closing and tree operations
2package browser
3
4import (
5 "errors"
6 "fmt"
7 "log"
8
9 "github.com/diamondburned/gotk4/pkg/gdk/v4"
10 "github.com/diamondburned/gotk4/pkg/gtk/v4"
11
12 "github.com/bnema/dumber/pkg/webkit"
13)
14
15// closeContext manages the context for a pane close operation
16type closeContext struct {
17 wm *WorkspaceManager
18 target *paneNode
19 remaining int
20 err error
21 generation uint
22}
23
24// beginClose initializes a close context with validation
25func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
26 ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
27 switch {
28 case wm == nil:
29 ctx.err = errors.New("workspace manager nil")
30 case node == nil || !node.isLeaf:
31 ctx.err = errors.New("invalid close target")
32 case node.pane == nil || node.pane.webView == nil:
33 ctx.err = errors.New("close target missing webview")
34 default:
35 ctx.remaining = len(wm.app.panes)
36 }
37 return ctx
38}
39
40// Generation returns the cleanup generation for this context
41func (ctx closeContext) Generation() uint {
42 return ctx.generation
43}
44
45// finish handles cleanup operations after close completion
46func (ctx closeContext) finish() {
47 if ctx.err == nil {
48 ctx.wm.updateMainPane()
49 }
50}
51
52// collectLeaves returns all leaf nodes in the workspace tree
53func (wm *WorkspaceManager) collectLeaves() []*paneNode {
54 return wm.collectLeavesFrom(wm.root)
55}
56
57// collectLeavesFromWithDirection collects leaves from a subtree with direction-aware stack entry
58func (wm *WorkspaceManager) collectLeavesFromWithDirection(node *paneNode, direction string) []*paneNode {
59 var leaves []*paneNode
60 visited := make(map[*paneNode]bool)
61
62 var walk func(*paneNode, int)
63 walk = func(n *paneNode, depth int) {
64 // Prevent infinite recursion and cycles
65 const maxDepth = 50
66 if n == nil || depth > maxDepth {
67 return
68 }
69 if visited[n] {
70 log.Printf("[workspace] collectLeavesFromWithDirection: cycle detected in tree")
71 return
72 }
73 visited[n] = true
74
75 if n.isLeaf {
76 leaves = append(leaves, n)
77 return
78 }
79
80 // Handle stacked panes - select entry pane based on direction
81 if n.isStacked && len(n.stackedPanes) > 0 {
82 var entryIndex int
83 switch direction {
84 case DirectionDown:
85 // Coming from above - enter at first pane
86 entryIndex = 0
87 case DirectionUp:
88 // Coming from below - enter at last pane
89 entryIndex = len(n.stackedPanes) - 1
90 default:
91 // For horizontal directions, use active pane
92 entryIndex = n.activeStackIndex
93 if entryIndex < 0 || entryIndex >= len(n.stackedPanes) {
94 entryIndex = 0
95 }
96 }
97 if entryIndex >= 0 && entryIndex < len(n.stackedPanes) {
98 walk(n.stackedPanes[entryIndex], depth+1)
99 }
100 return
101 }
102
103 // Handle regular split nodes
104 walk(n.left, depth+1)
105 walk(n.right, depth+1)
106 }
107 walk(node, 0)
108 return leaves
109}
110
111// collectLeavesFrom collects all leaf nodes from a given subtree
112func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
113 var leaves []*paneNode
114 visited := make(map[*paneNode]bool)
115
116 var walk func(*paneNode, int)
117 walk = func(n *paneNode, depth int) {
118 // Prevent infinite recursion and cycles
119 const maxDepth = 50
120 if n == nil || depth > maxDepth {
121 return
122 }
123 if visited[n] {
124 log.Printf("[workspace] collectLeavesFrom: cycle detected in tree")
125 return
126 }
127 visited[n] = true
128
129 if n.isLeaf {
130 leaves = append(leaves, n)
131 return
132 }
133
134 // Handle stacked panes - only include the currently ACTIVE pane as a leaf
135 // This prevents multiple CSS classes and ensures correct focus management
136 if n.isStacked && len(n.stackedPanes) > 0 {
137 activeIndex := n.activeStackIndex
138 if activeIndex >= 0 && activeIndex < len(n.stackedPanes) {
139 // Only the active pane in the stack counts as a leaf for focus/navigation
140 activePaneInStack := n.stackedPanes[activeIndex]
141 walk(activePaneInStack, depth+1)
142 }
143 return
144 }
145
146 // Handle regular split nodes
147 walk(n.left, depth+1)
148 walk(n.right, depth+1)
149 }
150 walk(node, 0)
151 return leaves
152}
153
154// createWebView creates a new WebView with the workspace's configuration
155func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
156 if wm == nil || wm.createWebViewFn == nil {
157 return nil, errors.New("workspace manager missing webview factory")
158 }
159 return wm.createWebViewFn()
160}
161
162// createPane creates a new BrowserPane for the given WebView
163func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
164 if wm == nil || wm.createPaneFn == nil {
165 return nil, errors.New("workspace manager missing pane factory")
166 }
167 return wm.createPaneFn(view)
168}
169
170// clonePaneState sets up a new pane with cloned state from another pane
171func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
172 if wm == nil || target == nil {
173 log.Printf("[workspace] clonePaneState: wm or target is nil")
174 return
175 }
176 if target.pane == nil || target.pane.webView == nil {
177 log.Printf("[workspace] clonePaneState: target pane or webView is nil")
178 return
179 }
180
181 // Load about:blank and auto-open omnibox
182 const blankURL = "about:blank"
183
184 log.Printf("[workspace] clonePaneState: loading %s in pane %p", blankURL, target.pane)
185 if err := target.pane.webView.LoadURL(blankURL); err != nil {
186 log.Printf("[workspace] failed to load blank page in new pane: %v", err)
187 } else {
188 log.Printf("[workspace] successfully initiated load of %s", blankURL)
189 }
190}
191
192// safelyDetachControllersBeforeReparent marks nodes for controller reattachment after reparenting.
193// GTK4 automatically removes controllers when widgets are unparented, so we just clear our
194// references and mark for reattachment. Manually removing controllers causes GTK bloom filter
195// corruption (double-removal assertion failure).
196func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
197 if wm == nil || node == nil {
198 return
199 }
200
201 markForDetachment := func(target *paneNode) {
202 if target == nil {
203 return
204 }
205
206 // Just mark for reattachment and clear our references
207 // GTK4 will auto-remove controllers during unparent
208 if target.hoverToken != nil {
209 target.pendingHoverReattach = true
210 target.hoverToken = nil // Clear reference, don't manually remove
211 log.Printf("[workspace] Marked hover controller for reattach on pane %p (GTK will auto-remove)", target)
212 }
213
214 if target.focusControllerToken != 0 {
215 target.pendingFocusReattach = true
216 target.focusControllerToken = 0 // Clear reference, don't manually remove
217 log.Printf("[workspace] Marked focus controller for reattach on pane %p (GTK will auto-remove)", target)
218 }
219 }
220
221 if node.isStacked {
222 for _, child := range node.stackedPanes {
223 markForDetachment(child)
224 }
225 return
226 }
227
228 if node.isLeaf {
229 markForDetachment(node)
230 }
231}
232
233// closeCurrentPane closes the currently focused pane
234func (wm *WorkspaceManager) closeCurrentPane() {
235 if wm == nil || wm.GetActiveNode() == nil {
236 return
237 }
238 if err := wm.ClosePane(wm.GetActiveNode()); err != nil {
239 log.Printf("[workspace] close current pane failed: %v", err)
240 }
241}
242
243// leftmostLeaf finds the leftmost leaf node in a subtree
244func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
245 for node != nil && !node.isLeaf {
246 if node.isStacked {
247 if node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
248 candidate := node.stackedPanes[node.activeStackIndex]
249 if candidate != nil {
250 return candidate
251 }
252 }
253 if len(node.stackedPanes) > 0 {
254 return node.stackedPanes[0]
255 }
256 return nil
257 }
258 if node.left != nil {
259 node = node.left
260 continue
261 }
262 node = node.right
263 }
264 return node
265}
266
267// findReplacementRoot finds a suitable replacement when closing the current root pane
268func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
269 if wm == nil || wm.root == nil {
270 return nil
271 }
272
273 // If root is being closed and there are other panes, find a replacement
274 leaves := wm.collectLeaves()
275 for _, leaf := range leaves {
276 if leaf != excludeNode && leaf != nil && leaf.isLeaf {
277 // Find the topmost ancestor that's not the current root
278 current := leaf
279 for current.parent != nil && current.parent != wm.root {
280 current = current.parent
281 }
282
283 // If this leaf is a direct child of root, or root only has one subtree,
284 // we can promote this subtree to be the new root
285 if current.parent == wm.root {
286 // If the sibling is being excluded, promote this subtree
287 var sibling *paneNode
288 if wm.root.left == current {
289 sibling = wm.root.right
290 } else {
291 sibling = wm.root.left
292 }
293
294 if sibling == excludeNode {
295 // The sibling is being closed, so promote this subtree
296 return current
297 }
298 }
299
300 // Otherwise, return the first suitable leaf
301 return leaf
302 }
303 }
304
305 return nil
306}
307
308// updateMainPane updates the main pane reference based on current state
309func (wm *WorkspaceManager) updateMainPane() {
310 if len(wm.app.panes) == 1 {
311 if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
312 wm.mainPane = leaf
313 }
314 return
315 }
316
317 if wm.mainPane == nil || !wm.mainPane.isLeaf {
318 if wm.GetActiveNode() != nil && wm.GetActiveNode().isLeaf {
319 wm.mainPane = wm.GetActiveNode()
320 }
321 }
322}
323
324// Helper methods to support clean pane removal from workspace tracking
325// These methods implement the interface expected by BrowserPane.CleanupFromWorkspace()
326
327// removeFromMaps removes a WebView from all workspace tracking maps
328func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) {
329 if wm == nil || webView == nil {
330 return
331 }
332
333 delete(wm.viewToNode, webView)
334 delete(wm.lastSplitMsg, webView)
335 delete(wm.lastExitMsg, webView)
336 log.Printf("[workspace] removed webview %p from tracking maps", webView)
337}
338
339// removeFromAppPanes removes a BrowserPane from the app.panes slice
340func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
341 if wm == nil || wm.app == nil || pane == nil {
342 return
343 }
344
345 for i, p := range wm.app.panes {
346 if p == pane {
347 wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
348 log.Printf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes))
349 return
350 }
351 }
352}
353
354// insertPopupPane has been REMOVED - all popup insertion now uses splitNode() with existing pane parameter.
355// This eliminates widget reparenting of existing WebViews, which was causing rendering corruption.
356// See splitNode() for the unified implementation.
357
358// TODO(#8): Refactor this function to reduce cyclomatic complexity (65 -> <30)
359//
360// splitNode splits a target pane into two panes in the specified direction
361// splitNode splits a target pane into two panes in the specified direction.
362// If existingPane is provided, it will be used instead of creating a new WebView.
363// This allows reusing the function for both normal splits (nil) and popup insertion (popup pane).
364func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existingPane *BrowserPane) (*paneNode, error) {
365 if target == nil || !target.isLeaf || target.pane == nil {
366 return nil, errors.New("split target must be a leaf pane")
367 }
368
369 // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
370 // not the individual pane or the stack itself. We create a new split at the stack's parent level.
371 originalTarget := target
372 var stackContainer *paneNode
373 if target.parent != nil && target.parent.isStacked {
374 // Target is in a stack - we'll create the split around the entire stack
375 stackContainer = target.parent
376 log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
377 }
378
379 // Determine what we're actually splitting
380 var splitTarget *paneNode
381 var splitTargetContainer gtk.Widgetter
382
383 if stackContainer != nil {
384 // Case: splitting from inside a stack - we split around the entire stack
385 splitTarget = stackContainer
386 splitTargetContainer = stackContainer.container
387 log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
388 } else {
389 // Case: normal split from a simple pane
390 splitTarget = target
391 splitTargetContainer = target.container
392 log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
393 }
394
395 // Use provided pane or create new one
396 var newPane *BrowserPane
397
398 if existingPane != nil {
399 // Reuse existing pane (for popup insertion)
400 newPane = existingPane
401 log.Printf("[workspace] Using existing pane for split: %p", newPane)
402 } else {
403 // Create new WebView and pane (for normal splits)
404 newView, err := wm.createWebView()
405 if err != nil {
406 return nil, err
407 }
408
409 newPane, err = wm.createPane(newView)
410 if err != nil {
411 return nil, err
412 }
413 log.Printf("[workspace] Created new pane for split: %p", newPane)
414 }
415
416 if handler := newPane.MessageHandler(); handler != nil {
417 handler.SetWorkspaceObserver(wm)
418 }
419
420 newContainer := newPane.webView.RootWidget()
421 if newContainer == nil {
422 return nil, errors.New("new pane missing container")
423 }
424 webkit.WidgetSetHExpand(newContainer, true)
425 webkit.WidgetSetVExpand(newContainer, true)
426
427 // Use the determined split target container
428 if splitTargetContainer == nil {
429 return nil, errors.New("split target missing container")
430 }
431
432 // Only apply GTK operations if this is a simple widget (leaf pane)
433 // For stacked containers, we need to be more careful
434 if splitTarget.isLeaf {
435 if splitTargetContainer != nil {
436 webkit.WidgetSetHExpand(splitTargetContainer, true)
437 webkit.WidgetSetVExpand(splitTargetContainer, true)
438 }
439 } else if splitTarget.isStacked {
440 // Stack containers need the same setup as regular panes for proper splitting
441 if splitTargetContainer != nil {
442 webkit.WidgetSetHExpand(splitTargetContainer, true)
443 webkit.WidgetSetVExpand(splitTargetContainer, true)
444 log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer)
445 }
446 }
447
448 orientation, existingFirst := mapDirection(direction)
449 log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
450 direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil)
451
452 paned := gtk.NewPaned(orientation)
453 if paned == nil {
454 return nil, errors.New("failed to create GtkPaned")
455 }
456 paned.SetHExpand(true)
457 paned.SetVExpand(true)
458
459 // Allow both children to resize; avoids shrinking existing pane to zero during root splits
460 paned.SetResizeStartChild(true)
461 paned.SetResizeEndChild(true)
462
463 newLeaf := &paneNode{
464 pane: newPane,
465 isLeaf: true,
466 }
467 wm.initializePaneWidgets(newLeaf, newContainer)
468
469 split := &paneNode{
470 parent: splitTarget.parent,
471 orientation: orientation,
472 isLeaf: false,
473 }
474 wm.initializePaneWidgets(split, paned)
475
476 parent := split.parent
477
478 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
479 // NOTE: The active class is correctly removed here - focus will transfer to the NEW pane
480 // The focus state machine will apply the active class to the new pane via SetActivePane()
481 if splitTargetContainer != nil {
482 // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
483 if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
484 webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
485 log.Printf("[workspace] removed active class from split target (will transfer to new pane)")
486 }
487 }
488
489 // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
490 wm.safelyDetachControllersBeforeReparent(splitTarget)
491
492 // For non-root splits, unparent from parent paned
493 if parent != nil && parent.container != nil {
494 // Split target has a parent paned - remove it (automatically unparents in GTK4)
495 log.Printf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container)
496 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
497 if parent.left == splitTarget {
498 parentPaned.SetStartChild(nil)
499 } else if parent.right == splitTarget {
500 parentPaned.SetEndChild(nil)
501 }
502 // GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
503 webkit.WidgetQueueAllocate(parent.container)
504 }
505 }
506 // NOTE: For root splits (parent == nil), we DON'T unparent here.
507 // We'll unparent immediately before adding to paned below to minimize orphan time.
508
509 // Set up the tree structure first
510 if existingFirst {
511 split.left = splitTarget
512 split.right = newLeaf
513 } else {
514 split.left = newLeaf
515 split.right = splitTarget
516 }
517
518 splitTarget.parent = split
519 newLeaf.parent = split
520
521 // Update tree root/parent references
522 if parent == nil {
523 wm.root = split
524 } else {
525 if parent.left == splitTarget {
526 parent.left = split
527 } else if parent.right == splitTarget {
528 parent.right = split
529 }
530 }
531
532 // GTK4 handles widget operations automatically - no need to force redraw
533
534 // Handle root split: attach paned to ContentArea (tab system is always present)
535 if parent == nil {
536 // ROOT SPLIT: Manipulate TabManager.ContentArea
537 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
538 log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized")
539 return nil, fmt.Errorf("tab manager not initialized")
540 }
541
542 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
543 if !ok || contentBox == nil {
544 log.Printf("[workspace] ERROR: ContentArea is not a Box")
545 return nil, fmt.Errorf("ContentArea is not a Box")
546 }
547
548 // Remove the old workspace root from ContentArea
549 contentBox.Remove(splitTargetContainer)
550 log.Printf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer)
551
552 // Attach the new paned to ContentArea FIRST
553 contentBox.Append(paned)
554 log.Printf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned)
555
556 // Now add children to the paned - GTK will handle reparenting automatically
557 if existingFirst {
558 paned.SetStartChild(splitTargetContainer)
559 paned.SetEndChild(newContainer)
560 log.Printf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer)
561 } else {
562 paned.SetStartChild(newContainer)
563 paned.SetEndChild(splitTargetContainer)
564 log.Printf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer)
565 }
566 } else {
567 // NON-ROOT SPLIT: Standard approach
568
569 // Add both containers to the new paned
570 if existingFirst {
571 if splitTargetContainer != nil {
572 paned.SetStartChild(splitTargetContainer)
573 paned.SetEndChild(newContainer)
574 log.Printf("[workspace] added splitTarget=%p as start child, new=%p as end child", splitTargetContainer, newContainer)
575 }
576 } else {
577 if splitTargetContainer != nil {
578 paned.SetStartChild(newContainer)
579 paned.SetEndChild(splitTargetContainer)
580 log.Printf("[workspace] added new=%p as start child, splitTarget=%p as end child", newContainer, splitTargetContainer)
581 }
582 }
583
584 // Attach the new paned to its parent
585 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
586 if parent.left == split {
587 parentPaned.SetStartChild(paned)
588 } else if parent.right == split {
589 parentPaned.SetEndChild(paned)
590 }
591 webkit.WidgetQueueAllocate(parent.container)
592 paned.QueueAllocate()
593 log.Printf("[workspace] paned inserted into parent=%p", parent.container)
594 }
595 }
596
597 paned.Show()
598
599 // Synchronize the divider once the paned has a meaningful allocation.
600 wm.syncPanedDivider(paned, orientation, split)
601
602 // GTK4 automatically handles widget rendering after reparenting - no manual updates needed
603
604 wm.viewToNode[newPane.webView] = newLeaf
605 wm.ensureHover(newLeaf)
606
607 // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
608 // The originalTarget is still in the stack and should not interfere with the new split pane
609 if originalTarget == target {
610 // Only ensure hover on target if it's a regular (non-stack) split
611 if !target.pendingHoverReattach {
612 wm.ensureHover(target)
613 }
614 }
615
616 // Only add to app.panes if this is a new pane (not an existing popup pane)
617 if existingPane == nil {
618 wm.app.panes = append(wm.app.panes, newPane)
619 } else {
620 // For existing popup panes, they're already in app.panes from createPane() in handlePopupReadyToShow
621 log.Printf("[workspace] Skipping app.panes append for existing popup pane (already added)")
622 }
623
624 if newPane.zoomController != nil {
625 newPane.zoomController.ApplyInitialZoom()
626 }
627
628 // Update CSS classes for all panes now that we have multiple panes
629
630 reattachTargets := []*paneNode{splitTarget}
631 if splitTarget != nil && splitTarget.isStacked {
632 reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
633 }
634 guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
635 wm.scheduleIdleGuarded(func() bool {
636 if newLeaf == nil || !newLeaf.widgetValid {
637 return false
638 }
639 if newContainer != nil {
640 webkit.WidgetShow(newContainer)
641 }
642
643 // CRITICAL: For root splits, also show the split target container after reparenting
644 // When the split target is reparented from window → paned, GTK doesn't preserve visibility
645 if parent == nil && splitTargetContainer != nil {
646 webkit.WidgetShow(splitTargetContainer)
647 webkit.WidgetQueueAllocate(splitTargetContainer)
648 webkit.WidgetQueueDraw(splitTargetContainer)
649 }
650
651 // Ensure the underlying WebView widget is visible after reparenting
652 if parent == nil && splitTarget != nil && splitTarget.pane != nil && splitTarget.pane.webView != nil {
653 if webViewWidget := splitTarget.pane.webView.AsWidget(); webViewWidget != nil {
654 webkit.WidgetShow(webViewWidget)
655 webkit.WidgetQueueAllocate(webViewWidget)
656 webkit.WidgetQueueDraw(webViewWidget)
657 }
658 }
659
660 // Attach GTK focus controller to new pane
661 if wm.focusStateMachine != nil {
662 wm.focusStateMachine.attachGTKController(newLeaf)
663 }
664
665 for _, candidate := range reattachTargets {
666 if candidate == nil || !candidate.widgetValid {
667 continue
668 }
669 if candidate.pendingHoverReattach {
670 wm.ensureHover(candidate)
671 if candidate.hoverToken != nil {
672 candidate.pendingHoverReattach = false
673 }
674 }
675 if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
676 wm.focusStateMachine.attachGTKController(candidate)
677 if candidate.focusControllerToken != 0 {
678 candidate.pendingFocusReattach = false
679 }
680 }
681 }
682 wm.SetActivePane(newLeaf, SourceSplit)
683 return false
684 }, guardNodes...)
685
686 return newLeaf, nil
687}
688
689// Helper functions for the simplified closePane implementation
690
691// getSibling returns the sibling node for a given node
692func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
693 if node.parent == nil {
694 return nil
695 }
696 if node.parent.left == node {
697 return node.parent.right
698 }
699 return node.parent.left
700}
701
702// nextCleanupGeneration returns the next cleanup generation counter
703func (wm *WorkspaceManager) nextCleanupGeneration() uint {
704 wm.cleanupCounter++
705 return wm.cleanupCounter
706}
707
708// promoteSibling promotes a sibling node to replace its parent in the tree
709func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
710 if grand == nil {
711 wm.root = sibling
712 sibling.parent = nil
713 return
714 }
715 sibling.parent = grand
716 if grand.left == parent {
717 grand.left = sibling
718 } else {
719 grand.right = sibling
720 }
721}
722
723// swapContainers updates GTK widget hierarchy for promoted siblings
724func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) {
725 if grand == nil {
726 // Sibling is being promoted to root
727 // When TreeRebalancer is enabled, skip GTK attachment here as it will be
728 // handled by the rebalancer's promotion transaction to avoid double-attachment
729 if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
730 // NOTE: Can't use attachRoot() here because wm.root has already been updated
731 // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea:
732 // the old paned (parent) is still in ContentArea and needs to be removed,
733 // then the sibling needs to be added.
734
735 // The sibling has already been unparented from the old paned earlier in the close logic,
736 // so it has no GTK parent now.
737
738 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
739 log.Printf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized")
740 return
741 }
742
743 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
744 if !ok || contentBox == nil {
745 log.Printf("[workspace] ERROR: ContentArea is not a Box")
746 return
747 }
748
749 // CRITICAL: Remove the old paned from ContentArea first
750 if parent != nil && parent.container != nil {
751 contentBox.Remove(parent.container)
752 log.Printf("[workspace] Removed old paned %p from ContentArea", parent.container)
753 }
754
755 // Now add sibling to ContentArea as the new root
756 contentBox.Append(sibling.container)
757 webkit.WidgetSetVisible(sibling.container, true)
758 log.Printf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container)
759 }
760 return
761 }
762 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
763 if sibling.container != nil {
764 if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
765 webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
766 }
767 }
768
769 if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
770 // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
771 // Focus was already cleared before unparenting closed pane
772 if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
773 log.Printf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
774 }
775
776 if grand.left == sibling {
777 grandPaned.SetStartChild(sibling.container)
778 log.Printf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container)
779 } else {
780 grandPaned.SetEndChild(sibling.container)
781 log.Printf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container)
782 }
783 webkit.WidgetQueueAllocate(grand.container)
784 }
785}
786
787// cascadePromotion handles the case where a paned node has only one child
788// This happens when closing a pane leaves the grandparent with only one child
789func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
790 if singleChildPaned == nil {
791 return
792 }
793
794 // Find the single child
795 var onlyChild *paneNode
796 if singleChildPaned.left != nil && singleChildPaned.right == nil {
797 onlyChild = singleChildPaned.left
798 } else if singleChildPaned.right != nil && singleChildPaned.left == nil {
799 onlyChild = singleChildPaned.right
800 } else {
801 // Either no children or two children - nothing to cascade
802 return
803 }
804
805 log.Printf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild)
806
807 greatGrandparent := singleChildPaned.parent
808
809 // Update tree structure
810 onlyChild.parent = greatGrandparent
811 if greatGrandparent == nil {
812 // Single child becomes new root
813 wm.root = onlyChild
814 log.Printf("[workspace] Only child %p promoted to root", onlyChild)
815
816 // Attach to window
817 if onlyChild.container != nil {
818 wm.attachRoot(onlyChild)
819 }
820
821 // Cleanup the now-orphaned paned
822 wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
823 } else {
824 // Replace paned in great-grandparent
825 if greatGrandparent.left == singleChildPaned {
826 greatGrandparent.left = onlyChild
827 } else {
828 greatGrandparent.right = onlyChild
829 }
830
831 log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
832
833 // Reparent widget in GTK
834 if greatGrandPaned, ok := greatGrandparent.container.(*gtk.Paned); ok && greatGrandPaned != nil && onlyChild.container != nil {
835 // GTK4 PanedSet*Child automatically handles unparenting
836 if greatGrandparent.left == onlyChild {
837 greatGrandPaned.SetStartChild(onlyChild.container)
838 } else {
839 greatGrandPaned.SetEndChild(onlyChild.container)
840 }
841
842 webkit.WidgetSetHExpand(onlyChild.container, true)
843 webkit.WidgetSetVExpand(onlyChild.container, true)
844 webkit.WidgetQueueAllocate(onlyChild.container)
845 webkit.WidgetQueueAllocate(greatGrandparent.container)
846 wm.scheduleIdleGuarded(func() bool {
847 if onlyChild == nil || !onlyChild.widgetValid {
848 return false
849 }
850 if onlyChild.container != nil {
851 webkit.WidgetShow(onlyChild.container)
852 webkit.WidgetQueueResize(onlyChild.container)
853 webkit.WidgetQueueDraw(onlyChild.container)
854 }
855 return false
856 }, onlyChild)
857 }
858
859 // Cleanup the now-orphaned paned
860 wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
861
862 // Check if great-grandparent now also has only one child (recursive case)
863 if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
864 log.Printf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent)
865 wm.cascadePromotion(greatGrandparent)
866 }
867 }
868}
869
870// attachRoot attaches a node as the new root in TabManager.ContentArea.
871// NOTE: This is typically called AFTER wm.root has already been updated to point to this node.
872// The old root container (if any) will be orphaned in ContentArea and garbage collected.
873func (wm *WorkspaceManager) attachRoot(root *paneNode) {
874 if root == nil || root.container == nil {
875 return
876 }
877
878 // Note: In gotk4, reference counting is handled automatically by Go's GC
879 // No need for manual Ref()/Unref() calls
880
881 // Detach from previous container (paned, stack, etc.) before attaching to ContentArea
882 // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
883 if parent := webkit.WidgetGetParent(root.container); parent != nil {
884 log.Printf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent)
885 webkit.WidgetUnparent(root.container)
886 // Note: In gotk4, the Go object reference remains valid after unparent
887 // Only the GTK parent relationship is cleared
888 }
889
890 if root.container != nil {
891 webkit.WidgetSetHExpand(root.container, true)
892 webkit.WidgetSetVExpand(root.container, true)
893 }
894
895 // Add to ContentArea (the old root will be orphaned and garbage collected)
896 if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
897 log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized")
898 return
899 }
900
901 contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
902 if !ok || contentBox == nil {
903 log.Printf("[workspace] ERROR: ContentArea is not a Box")
904 return
905 }
906
907 contentBox.Append(root.container)
908 webkit.WidgetSetVisible(root.container, true)
909 if root.container != nil {
910 webkit.WidgetQueueAllocate(root.container)
911 webkit.WidgetShow(root.container)
912 webkit.WidgetQueueResize(root.container)
913 webkit.WidgetQueueDraw(root.container)
914 }
915
916 wm.scheduleIdleGuarded(func() bool {
917 if root == nil || !root.widgetValid {
918 return false
919 }
920 if root.container != nil {
921 webkit.WidgetQueueResize(root.container)
922 webkit.WidgetQueueDraw(root.container)
923 }
924 return false
925 }, root)
926
927 // Verify attachment succeeded
928 if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
929 log.Printf("[workspace] WARNING: widget %p has no parent after SetChild", root.container)
930 } else {
931 log.Printf("[workspace] attachRoot successful: widget %p now child of %p", root.container, finalParent)
932 }
933}
934
935// cleanupPane safely cleans up a pane node with generation tracking
936func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
937 if node == nil {
938 return
939 }
940 if !node.widgetValid {
941 return
942 }
943
944 if wm.focusStateMachine != nil {
945 wm.focusStateMachine.InvalidateActivePane(node)
946 }
947
948 wm.cancelIdleHandles(node)
949
950 node.widgetValid = false
951 node.cleanupGeneration = generation
952
953 if node.pane != nil {
954 pane := node.pane
955 pane.Cleanup()
956 pane.CleanupFromWorkspace(wm)
957 if pane.webView != nil {
958 log.Printf("[workspace] releasing WebView reference: %p", pane.webView)
959 pane.webView = nil
960 }
961 node.pane = nil
962 }
963
964 if node.container != nil {
965 // Container widget will be destroyed by GTK when unparented
966 node.container = nil
967 }
968
969 node.pendingIdleHandles = nil
970 node.parent = nil
971 node.left = nil
972 node.right = nil
973}
974
975// decommissionParent cleans up a parent node after promotion
976func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
977 if parent == nil {
978 return
979 }
980
981 // For branch nodes (paneds), destroy the widget
982 // Children have already been unparented during promotion/close, no need to clear them
983 if !parent.isLeaf && parent.container != nil {
984 // In gotk4, we don't need to manually destroy - GTK handles this when unparented
985 // Just mark it for cleanup
986 log.Printf("[workspace] marked paned widget for cleanup: %p", parent.container)
987 }
988
989 wm.cleanupPane(parent, generation)
990}
991
992// promoteNewRoot handles root replacement when closing the root pane
993func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
994 candidate := wm.findReplacementRoot(oldRoot)
995 if candidate == nil {
996 return wm.cleanupAndExit(oldRoot)
997 }
998
999 sibling := wm.getSibling(candidate)
1000 if sibling != nil {
1001 wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
1002 }
1003
1004 candidate.parent = nil
1005 wm.root = candidate
1006
1007 wm.attachRoot(candidate)
1008
1009 wm.cleanupPane(oldRoot, ctx.Generation())
1010 return candidate, nil
1011}
1012
1013// cleanupAndExit handles the final pane cleanup and application exit
1014func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
1015 log.Printf("[workspace] closing final pane; exiting browser")
1016 wm.cleanupPane(node, wm.nextCleanupGeneration())
1017 wm.detachHover(node)
1018
1019 // Gracefully quit the GTK main loop
1020 webkit.QuitMainLoop()
1021 return nil, nil
1022}
1023
1024// setFocusToLeaf sets focus to the leftmost leaf of a node
1025func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
1026 focusTarget := wm.leftmostLeaf(node)
1027 if focusTarget != nil {
1028 wm.SetActivePane(focusTarget, SourceClose)
1029 }
1030}
1031
1032// TODO(#8): Refactor this function to reduce cyclomatic complexity (31 -> <30)
1033//
1034// closePane closes a specific pane and handles tree restructuring (simplified implementation)
1035func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1036 ctx := wm.beginClose(node)
1037 defer ctx.finish()
1038
1039 // STEP 1: Basic validation (quick fail)
1040 if ctx.err != nil {
1041 return nil, ctx.err
1042 }
1043
1044 // STEP 2: Handle stacked panes via compatibility shim
1045 if node.parent != nil && node.parent.isStacked {
1046 return wm.closeStackedPaneCompat(node)
1047 }
1048
1049 // Ensure we detach hover/focus controllers before the widget hierarchy changes.
1050 wm.detachHover(node)
1051 wm.detachFocus(node)
1052
1053 // STEP 3: Handle trivial exit cases
1054 // Don't exit if closing a popup - popups aren't counted in app.panes
1055 if ctx.remaining == 1 && !node.isPopup {
1056 return wm.cleanupAndExit(node)
1057 }
1058 if node == wm.root {
1059 return wm.promoteNewRoot(ctx, node)
1060 }
1061
1062 // STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
1063 parent := node.parent
1064 sibling := wm.getSibling(node)
1065 grandparent := parent.parent
1066
1067 // Clear grandparent's focus child BEFORE unparenting closed pane
1068 // GTK propagates focus events upward, so we must clear grandparent focus first
1069 if grandparent != nil && grandparent.container != nil {
1070 webkit.WidgetSetFocusChild(grandparent.container, nil)
1071 }
1072
1073 // Unparent the closed pane's container from the paned BEFORE promoting sibling
1074 if node.container != nil {
1075 if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1076 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1077 if parent.left == node {
1078 parentPaned.SetStartChild(nil)
1079 } else if parent.right == node {
1080 parentPaned.SetEndChild(nil)
1081 }
1082 log.Printf("[workspace] unparented closed pane container: %p", node.container)
1083 }
1084 }
1085 }
1086
1087 // STEP 5: Unparent sibling from parent paned BEFORE promotion
1088 // The sibling needs to be detached from the parent paned before we can attach it elsewhere
1089 if sibling.container != nil && parent.container != nil {
1090 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1091 if parent.left == sibling {
1092 parentPaned.SetStartChild(nil)
1093 } else if parent.right == sibling {
1094 parentPaned.SetEndChild(nil)
1095 }
1096 log.Printf("[workspace] unparented sibling container %p from parent paned %p", sibling.container, parent.container)
1097 }
1098 }
1099
1100 // STEP 6: Promote sibling in-place (tree structure)
1101 wm.promoteSibling(grandparent, parent, sibling)
1102
1103 // STEP 7: GTK widget reparenting
1104 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1105 // For root promotion: we need to remove the old paned from ContentArea
1106 wm.swapContainers(grandparent, parent, sibling)
1107
1108 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1109 if sibling != nil && sibling.container != nil {
1110 wm.scheduleIdleGuarded(func() bool {
1111 if sibling == nil || !sibling.widgetValid {
1112 return false
1113 }
1114 if sibling.container != nil {
1115 webkit.WidgetShow(sibling.container)
1116 webkit.WidgetQueueResize(sibling.container)
1117 webkit.WidgetQueueDraw(sibling.container)
1118 }
1119 return false
1120 }, sibling)
1121 }
1122
1123 // STEP 8: Check if grandparent now has only one child (cascade promotion needed)
1124 if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1125 // Grandparent has exactly one child - it should be promoted too
1126 log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
1127 wm.cascadePromotion(grandparent)
1128 }
1129
1130 // STEP 9: Cleanup & focus
1131 wm.cleanupPane(node, ctx.Generation())
1132 wm.decommissionParent(parent, ctx.Generation())
1133 if sibling != nil {
1134 wm.scheduleIdleGuarded(func() bool {
1135 if sibling == nil || !sibling.widgetValid {
1136 return false
1137 }
1138 wm.setFocusToLeaf(sibling)
1139 return false
1140 }, sibling)
1141 }
1142
1143 return sibling, nil
1144}
1145
1146// closeStackedPaneCompat provides compatibility layer for closing stacked panes
1147func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
1148 stack := node.parent
1149
1150 // Find index
1151 index := -1
1152 for i, pane := range stack.stackedPanes {
1153 if pane == node {
1154 index = i
1155 break
1156 }
1157 }
1158
1159 if index == -1 {
1160 return nil, errors.New("pane not in stack")
1161 }
1162
1163 // Remove from array
1164 stack.stackedPanes = append(
1165 stack.stackedPanes[:index],
1166 stack.stackedPanes[index+1:]...,
1167 )
1168
1169 // If only one pane left, unstack it
1170 if len(stack.stackedPanes) == 1 {
1171 remaining := stack.stackedPanes[0]
1172
1173 // Replace stack with remaining pane in tree
1174 remaining.parent = stack.parent
1175 if stack.parent != nil {
1176 if stack.parent.left == stack {
1177 stack.parent.left = remaining
1178 } else {
1179 stack.parent.right = remaining
1180 }
1181
1182 // Update GTK widgets
1183 if parentPaned, ok := stack.parent.container.(*gtk.Paned); ok && parentPaned != nil && remaining.container != nil {
1184 if stack.parent.left == remaining {
1185 parentPaned.SetStartChild(remaining.container)
1186 } else {
1187 parentPaned.SetEndChild(remaining.container)
1188 }
1189 }
1190 } else {
1191 // Stack was root - attach remaining pane as new root
1192 wm.root = remaining
1193 if remaining.container != nil {
1194 wm.setRootContainer(remaining.container)
1195 }
1196 }
1197
1198 // Cleanup stack container
1199 wm.cleanupPane(stack, wm.nextCleanupGeneration())
1200
1201 return remaining, nil
1202 }
1203
1204 // Update active index if needed
1205 if stack.activeStackIndex >= len(stack.stackedPanes) {
1206 stack.activeStackIndex = len(stack.stackedPanes) - 1
1207 }
1208
1209 // Remove widget from GTK box
1210 if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1211 stackBox.Remove(node.container)
1212 }
1213
1214 // Show new active pane
1215 if stack.activeStackIndex >= 0 {
1216 active := stack.stackedPanes[stack.activeStackIndex]
1217 if active.container != nil {
1218 webkit.WidgetShow(active.container)
1219 }
1220 }
1221
1222 // Cleanup closed pane
1223 wm.cleanupPane(node, wm.nextCleanupGeneration())
1224
1225 return stack, nil
1226}
1227
1228// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1229// the divider to an even split. GTK often reports near-zero allocations for a few frames
1230// after insertion, so we retry on map/tick/idle until the size stabilizes.
1231func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1232 if wm == nil || paned == nil {
1233 return
1234 }
1235
1236 const (
1237 minDimension = 32
1238 maxFrames = 120
1239 )
1240
1241 setPosition := func() bool {
1242 if guard != nil && !guard.widgetValid {
1243 return true
1244 }
1245
1246 alloc := paned.Allocation()
1247 dimension := alloc.Width()
1248 if orientation == gtk.OrientationVertical {
1249 dimension = alloc.Height()
1250 }
1251
1252 if dimension < minDimension {
1253 return false
1254 }
1255
1256 pos := dimension / 2
1257 if pos <= 0 {
1258 return false
1259 }
1260
1261 paned.SetPosition(pos)
1262 log.Printf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height())
1263 return true
1264 }
1265
1266 // Attempt immediately with the current allocation.
1267 if setPosition() {
1268 return
1269 }
1270
1271 var frames uint
1272
1273 paned.ConnectMap(func() {
1274 setPosition()
1275 })
1276
1277 paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1278 if guard != nil && !guard.widgetValid {
1279 return false
1280 }
1281
1282 frames++
1283 if setPosition() {
1284 return false
1285 }
1286
1287 if frames >= maxFrames {
1288 alloc := paned.Allocation()
1289 log.Printf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height())
1290 return false
1291 }
1292
1293 return true
1294 })
1295
1296 wm.scheduleIdleGuarded(func() bool {
1297 setPosition()
1298 return false
1299 }, guard)
1300}