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