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// splitNode splits a target pane into two panes in the specified direction
303// splitNode splits a target pane into two panes in the specified direction.
304// If existingPane is provided, it will be used instead of creating a new WebView.
305// This allows reusing the function for both normal splits (nil) and popup insertion (popup pane).
306func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existingPane *BrowserPane) (*paneNode, error) {
307 if target == nil || !target.isLeaf || target.pane == nil {
308 return nil, errors.New("split target must be a leaf pane")
309 }
310
311 // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
312 // not the individual pane or the stack itself. We create a new split at the stack's parent level.
313 originalTarget := target
314 var stackContainer *paneNode
315 if target.parent != nil && target.parent.isStacked {
316 // Target is in a stack - we'll create the split around the entire stack
317 stackContainer = target.parent
318 log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
319 }
320
321 // Determine what we're actually splitting
322 var splitTarget *paneNode
323 var splitTargetContainer gtk.Widgetter
324
325 if stackContainer != nil {
326 // Case: splitting from inside a stack - we split around the entire stack
327 splitTarget = stackContainer
328 splitTargetContainer = stackContainer.container
329 log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
330 } else {
331 // Case: normal split from a simple pane
332 splitTarget = target
333 splitTargetContainer = target.container
334 log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
335 }
336
337 // Use provided pane or create new one
338 var newPane *BrowserPane
339
340 if existingPane != nil {
341 // Reuse existing pane (for popup insertion)
342 newPane = existingPane
343 log.Printf("[workspace] Using existing pane for split: %p", newPane)
344 } else {
345 // Create new WebView and pane (for normal splits)
346 newView, err := wm.createWebView()
347 if err != nil {
348 return nil, err
349 }
350
351 newPane, err = wm.createPane(newView)
352 if err != nil {
353 return nil, err
354 }
355 log.Printf("[workspace] Created new pane for split: %p", newPane)
356 }
357
358 if handler := newPane.MessageHandler(); handler != nil {
359 handler.SetWorkspaceObserver(wm)
360 }
361
362 newContainer := newPane.webView.RootWidget()
363 if newContainer == nil {
364 return nil, errors.New("new pane missing container")
365 }
366 webkit.WidgetSetHExpand(newContainer, true)
367 webkit.WidgetSetVExpand(newContainer, true)
368
369 // Use the determined split target container
370 if splitTargetContainer == nil {
371 return nil, errors.New("split target missing container")
372 }
373
374 // Only apply GTK operations if this is a simple widget (leaf pane)
375 // For stacked containers, we need to be more careful
376 if splitTarget.isLeaf {
377 if splitTargetContainer != nil {
378 webkit.WidgetSetHExpand(splitTargetContainer, true)
379 webkit.WidgetSetVExpand(splitTargetContainer, true)
380 }
381 } else if splitTarget.isStacked {
382 // Stack containers need the same setup as regular panes for proper splitting
383 if splitTargetContainer != nil {
384 webkit.WidgetSetHExpand(splitTargetContainer, true)
385 webkit.WidgetSetVExpand(splitTargetContainer, true)
386 log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer)
387 }
388 }
389
390 orientation, existingFirst := mapDirection(direction)
391 log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
392 direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil)
393
394 paned := gtk.NewPaned(orientation)
395 if paned == nil {
396 return nil, errors.New("failed to create GtkPaned")
397 }
398 paned.SetHExpand(true)
399 paned.SetVExpand(true)
400
401 // Allow both children to resize; avoids shrinking existing pane to zero during root splits
402 paned.SetResizeStartChild(true)
403 paned.SetResizeEndChild(true)
404
405 newLeaf := &paneNode{
406 pane: newPane,
407 isLeaf: true,
408 }
409 wm.initializePaneWidgets(newLeaf, newContainer)
410
411 split := &paneNode{
412 parent: splitTarget.parent,
413 orientation: orientation,
414 isLeaf: false,
415 }
416 wm.initializePaneWidgets(split, paned)
417
418 parent := split.parent
419
420 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
421 // NOTE: The active class is correctly removed here - focus will transfer to the NEW pane
422 // The focus state machine will apply the active class to the new pane via SetActivePane()
423 if splitTargetContainer != nil {
424 // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
425 if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
426 webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
427 log.Printf("[workspace] removed active class from split target (will transfer to new pane)")
428 }
429 }
430
431 // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
432 wm.safelyDetachControllersBeforeReparent(splitTarget)
433
434 // For non-root splits, unparent from parent paned
435 if parent != nil && parent.container != nil {
436 // Split target has a parent paned - remove it (automatically unparents in GTK4)
437 log.Printf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container)
438 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
439 if parent.left == splitTarget {
440 parentPaned.SetStartChild(nil)
441 } else if parent.right == splitTarget {
442 parentPaned.SetEndChild(nil)
443 }
444 // GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
445 webkit.WidgetQueueAllocate(parent.container)
446 }
447 }
448 // NOTE: For root splits (parent == nil), we DON'T unparent here.
449 // We'll unparent immediately before adding to paned below to minimize orphan time.
450
451 // Set up the tree structure first
452 if existingFirst {
453 split.left = splitTarget
454 split.right = newLeaf
455 } else {
456 split.left = newLeaf
457 split.right = splitTarget
458 }
459
460 splitTarget.parent = split
461 newLeaf.parent = split
462
463 // Update tree root/parent references
464 if parent == nil {
465 wm.root = split
466 } else {
467 if parent.left == splitTarget {
468 parent.left = split
469 } else if parent.right == splitTarget {
470 parent.right = split
471 }
472 }
473
474 // GTK4 handles widget operations automatically - no need to force redraw
475
476 // Handle root split: attach paned to window FIRST, then let GTK handle reparenting
477 if parent == nil {
478 // ROOT SPLIT: Attach empty paned to window, then add children
479 // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild
480 if wm.window != nil {
481 // Clear any existing window child (the splitTargetContainer)
482 wm.window.SetChild(nil)
483
484 // Attach the new paned to window FIRST
485 wm.window.SetChild(paned)
486 log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
487 }
488
489 // Now add children to the paned - GTK will handle reparenting automatically
490 if existingFirst {
491 paned.SetStartChild(splitTargetContainer)
492 paned.SetEndChild(newContainer)
493 log.Printf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer)
494 } else {
495 paned.SetStartChild(newContainer)
496 paned.SetEndChild(splitTargetContainer)
497 log.Printf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer)
498 }
499 } else {
500 // NON-ROOT SPLIT: Standard approach
501
502 // Add both containers to the new paned
503 if existingFirst {
504 if splitTargetContainer != nil {
505 paned.SetStartChild(splitTargetContainer)
506 paned.SetEndChild(newContainer)
507 log.Printf("[workspace] added splitTarget=%p as start child, new=%p as end child", splitTargetContainer, newContainer)
508 }
509 } else {
510 if splitTargetContainer != nil {
511 paned.SetStartChild(newContainer)
512 paned.SetEndChild(splitTargetContainer)
513 log.Printf("[workspace] added new=%p as start child, splitTarget=%p as end child", newContainer, splitTargetContainer)
514 }
515 }
516
517 // Attach the new paned to its parent
518 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
519 if parent.left == split {
520 parentPaned.SetStartChild(paned)
521 } else if parent.right == split {
522 parentPaned.SetEndChild(paned)
523 }
524 webkit.WidgetQueueAllocate(parent.container)
525 paned.QueueAllocate()
526 log.Printf("[workspace] paned inserted into parent=%p", parent.container)
527 }
528 }
529
530 paned.Show()
531
532 // Synchronize the divider once the paned has a meaningful allocation.
533 wm.syncPanedDivider(paned, orientation, split)
534
535 // GTK4 automatically handles widget rendering after reparenting - no manual updates needed
536
537 wm.viewToNode[newPane.webView] = newLeaf
538 wm.ensureHover(newLeaf)
539
540 // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
541 // The originalTarget is still in the stack and should not interfere with the new split pane
542 if originalTarget == target {
543 // Only ensure hover on target if it's a regular (non-stack) split
544 if !target.pendingHoverReattach {
545 wm.ensureHover(target)
546 }
547 }
548
549 // Only add to app.panes if this is a new pane (not an existing popup pane)
550 if existingPane == nil {
551 wm.app.panes = append(wm.app.panes, newPane)
552 } else {
553 // For existing popup panes, they're already in app.panes from createPane() in handlePopupReadyToShow
554 log.Printf("[workspace] Skipping app.panes append for existing popup pane (already added)")
555 }
556
557 if newPane.zoomController != nil {
558 newPane.zoomController.ApplyInitialZoom()
559 }
560
561 // Update CSS classes for all panes now that we have multiple panes
562
563 reattachTargets := []*paneNode{splitTarget}
564 if splitTarget != nil && splitTarget.isStacked {
565 reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
566 }
567 guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
568 wm.scheduleIdleGuarded(func() bool {
569 if newLeaf == nil || !newLeaf.widgetValid {
570 return false
571 }
572 if newContainer != nil {
573 webkit.WidgetShow(newContainer)
574 }
575
576 // CRITICAL: For root splits, also show the split target container after reparenting
577 // When the split target is reparented from window → paned, GTK doesn't preserve visibility
578 if parent == nil && splitTargetContainer != nil {
579 webkit.WidgetShow(splitTargetContainer)
580 webkit.WidgetQueueAllocate(splitTargetContainer)
581 webkit.WidgetQueueDraw(splitTargetContainer)
582 }
583
584 // Ensure the underlying WebView widget is visible after reparenting
585 if parent == nil && splitTarget != nil && splitTarget.pane != nil && splitTarget.pane.webView != nil {
586 if webViewWidget := splitTarget.pane.webView.AsWidget(); webViewWidget != nil {
587 webkit.WidgetShow(webViewWidget)
588 webkit.WidgetQueueAllocate(webViewWidget)
589 webkit.WidgetQueueDraw(webViewWidget)
590 }
591 }
592
593 // Attach GTK focus controller to new pane
594 if wm.focusStateMachine != nil {
595 wm.focusStateMachine.attachGTKController(newLeaf)
596 }
597
598 for _, candidate := range reattachTargets {
599 if candidate == nil || !candidate.widgetValid {
600 continue
601 }
602 if candidate.pendingHoverReattach {
603 wm.ensureHover(candidate)
604 if candidate.hoverToken != nil {
605 candidate.pendingHoverReattach = false
606 }
607 }
608 if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
609 wm.focusStateMachine.attachGTKController(candidate)
610 if candidate.focusControllerToken != 0 {
611 candidate.pendingFocusReattach = false
612 }
613 }
614 }
615 wm.SetActivePane(newLeaf, SourceSplit)
616 return false
617 }, guardNodes...)
618
619 return newLeaf, nil
620}
621
622// Helper functions for the simplified closePane implementation
623
624// getSibling returns the sibling node for a given node
625func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
626 if node.parent == nil {
627 return nil
628 }
629 if node.parent.left == node {
630 return node.parent.right
631 }
632 return node.parent.left
633}
634
635// nextCleanupGeneration returns the next cleanup generation counter
636func (wm *WorkspaceManager) nextCleanupGeneration() uint {
637 wm.cleanupCounter++
638 return wm.cleanupCounter
639}
640
641// promoteSibling promotes a sibling node to replace its parent in the tree
642func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
643 if grand == nil {
644 wm.root = sibling
645 sibling.parent = nil
646 return
647 }
648 sibling.parent = grand
649 if grand.left == parent {
650 grand.left = sibling
651 } else {
652 grand.right = sibling
653 }
654}
655
656// swapContainers updates GTK widget hierarchy for promoted siblings
657func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
658 if grand == nil {
659 // When TreeRebalancer is enabled, skip GTK attachment here as it will be
660 // handled by the rebalancer's promotion transaction to avoid double-attachment
661 if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
662 wm.attachRoot(sibling)
663 }
664 return
665 }
666 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
667 if sibling.container != nil {
668 if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
669 webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
670 }
671 }
672
673 if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
674 // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
675 // Focus was already cleared before unparenting closed pane
676 if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
677 log.Printf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
678 }
679
680 if grand.left == sibling {
681 grandPaned.SetStartChild(sibling.container)
682 log.Printf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container)
683 } else {
684 grandPaned.SetEndChild(sibling.container)
685 log.Printf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container)
686 }
687 webkit.WidgetQueueAllocate(grand.container)
688 }
689}
690
691// cascadePromotion handles the case where a paned node has only one child
692// This happens when closing a pane leaves the grandparent with only one child
693func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
694 if singleChildPaned == nil {
695 return
696 }
697
698 // Find the single child
699 var onlyChild *paneNode
700 if singleChildPaned.left != nil && singleChildPaned.right == nil {
701 onlyChild = singleChildPaned.left
702 } else if singleChildPaned.right != nil && singleChildPaned.left == nil {
703 onlyChild = singleChildPaned.right
704 } else {
705 // Either no children or two children - nothing to cascade
706 return
707 }
708
709 log.Printf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild)
710
711 greatGrandparent := singleChildPaned.parent
712
713 // Update tree structure
714 onlyChild.parent = greatGrandparent
715 if greatGrandparent == nil {
716 // Single child becomes new root
717 wm.root = onlyChild
718 log.Printf("[workspace] Only child %p promoted to root", onlyChild)
719
720 // Attach to window
721 if onlyChild.container != nil {
722 wm.attachRoot(onlyChild)
723 }
724
725 // Cleanup the now-orphaned paned
726 wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
727 } else {
728 // Replace paned in great-grandparent
729 if greatGrandparent.left == singleChildPaned {
730 greatGrandparent.left = onlyChild
731 } else {
732 greatGrandparent.right = onlyChild
733 }
734
735 log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
736
737 // Reparent widget in GTK
738 if greatGrandPaned, ok := greatGrandparent.container.(*gtk.Paned); ok && greatGrandPaned != nil && onlyChild.container != nil {
739 // GTK4 PanedSet*Child automatically handles unparenting
740 if greatGrandparent.left == onlyChild {
741 greatGrandPaned.SetStartChild(onlyChild.container)
742 } else {
743 greatGrandPaned.SetEndChild(onlyChild.container)
744 }
745
746 webkit.WidgetSetHExpand(onlyChild.container, true)
747 webkit.WidgetSetVExpand(onlyChild.container, true)
748 webkit.WidgetQueueAllocate(onlyChild.container)
749 webkit.WidgetQueueAllocate(greatGrandparent.container)
750 wm.scheduleIdleGuarded(func() bool {
751 if onlyChild == nil || !onlyChild.widgetValid {
752 return false
753 }
754 if onlyChild.container != nil {
755 webkit.WidgetShow(onlyChild.container)
756 webkit.WidgetQueueResize(onlyChild.container)
757 webkit.WidgetQueueDraw(onlyChild.container)
758 }
759 return false
760 }, onlyChild)
761 }
762
763 // Cleanup the now-orphaned paned
764 wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
765
766 // Check if great-grandparent now also has only one child (recursive case)
767 if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
768 log.Printf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent)
769 wm.cascadePromotion(greatGrandparent)
770 }
771 }
772}
773
774// attachRoot attaches a node as the new window root
775func (wm *WorkspaceManager) attachRoot(root *paneNode) {
776 if root == nil || root.container == nil || wm.window == nil {
777 return
778 }
779
780 // Note: In gotk4, reference counting is handled automatically by Go's GC
781 // No need for manual Ref()/Unref() calls
782
783 // Detach from previous container (paned, stack, etc.) before replacing the window child.
784 // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent
785 if parent := webkit.WidgetGetParent(root.container); parent != nil {
786 log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent)
787 webkit.WidgetUnparent(root.container)
788 // Note: In gotk4, the Go object reference remains valid after unparent
789 // Only the GTK parent relationship is cleared
790 }
791
792 // Clear any existing window child to avoid GTK warnings when swapping roots.
793 wm.window.SetChild(nil)
794
795 if root.container != nil {
796 webkit.WidgetSetHExpand(root.container, true)
797 webkit.WidgetSetVExpand(root.container, true)
798 }
799
800 // GTK4 will automatically unparent root.container from its previous parent when we attach it here
801 wm.window.SetChild(root.container)
802 if root.container != nil {
803 webkit.WidgetQueueAllocate(root.container)
804 webkit.WidgetShow(root.container)
805 webkit.WidgetQueueResize(root.container)
806 webkit.WidgetQueueDraw(root.container)
807 }
808
809 wm.scheduleIdleGuarded(func() bool {
810 if root == nil || !root.widgetValid {
811 return false
812 }
813 if root.container != nil {
814 webkit.WidgetQueueResize(root.container)
815 webkit.WidgetQueueDraw(root.container)
816 }
817 return false
818 }, root)
819
820 // Verify attachment succeeded
821 if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
822 log.Printf("[workspace] WARNING: widget %p has no parent after SetChild", root.container)
823 } else {
824 log.Printf("[workspace] attachRoot successful: widget %p now child of %p", root.container, finalParent)
825 }
826}
827
828// cleanupPane safely cleans up a pane node with generation tracking
829func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
830 if node == nil {
831 return
832 }
833 if !node.widgetValid {
834 return
835 }
836
837 if wm.focusStateMachine != nil {
838 wm.focusStateMachine.InvalidateActivePane(node)
839 }
840
841 wm.cancelIdleHandles(node)
842
843 node.widgetValid = false
844 node.cleanupGeneration = generation
845
846 if node.pane != nil {
847 pane := node.pane
848 pane.Cleanup()
849 pane.CleanupFromWorkspace(wm)
850 if pane.webView != nil {
851 log.Printf("[workspace] releasing WebView reference: %p", pane.webView)
852 pane.webView = nil
853 }
854 node.pane = nil
855 }
856
857 if node.container != nil {
858 // Container widget will be destroyed by GTK when unparented
859 node.container = nil
860 }
861
862 node.pendingIdleHandles = nil
863 node.parent = nil
864 node.left = nil
865 node.right = nil
866}
867
868// decommissionParent cleans up a parent node after promotion
869func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
870 if parent == nil {
871 return
872 }
873
874 // For branch nodes (paneds), destroy the widget
875 // Children have already been unparented during promotion/close, no need to clear them
876 if !parent.isLeaf && parent.container != nil {
877 // In gotk4, we don't need to manually destroy - GTK handles this when unparented
878 // Just mark it for cleanup
879 log.Printf("[workspace] marked paned widget for cleanup: %p", parent.container)
880 }
881
882 wm.cleanupPane(parent, generation)
883}
884
885// promoteNewRoot handles root replacement when closing the root pane
886func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
887 candidate := wm.findReplacementRoot(oldRoot)
888 if candidate == nil {
889 return wm.cleanupAndExit(oldRoot)
890 }
891
892 sibling := wm.getSibling(candidate)
893 if sibling != nil {
894 wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
895 }
896
897 candidate.parent = nil
898 wm.root = candidate
899
900 wm.attachRoot(candidate)
901
902 wm.cleanupPane(oldRoot, ctx.Generation())
903 return candidate, nil
904}
905
906// cleanupAndExit handles the final pane cleanup and application exit
907func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
908 log.Printf("[workspace] closing final pane; exiting browser")
909 wm.cleanupPane(node, wm.nextCleanupGeneration())
910 wm.detachHover(node)
911
912 // Gracefully quit the GTK main loop
913 webkit.QuitMainLoop()
914 return nil, nil
915}
916
917// setFocusToLeaf sets focus to the leftmost leaf of a node
918func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
919 focusTarget := wm.leftmostLeaf(node)
920 if focusTarget != nil {
921 wm.SetActivePane(focusTarget, SourceClose)
922 }
923}
924
925// closePane closes a specific pane and handles tree restructuring (simplified implementation)
926func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
927 ctx := wm.beginClose(node)
928 defer ctx.finish()
929
930 // STEP 1: Basic validation (quick fail)
931 if ctx.err != nil {
932 return nil, ctx.err
933 }
934
935 // STEP 2: Handle stacked panes via compatibility shim
936 if node.parent != nil && node.parent.isStacked {
937 return wm.closeStackedPaneCompat(node)
938 }
939
940 // Ensure we detach hover/focus controllers before the widget hierarchy changes.
941 wm.detachHover(node)
942 wm.detachFocus(node)
943
944 // STEP 3: Handle trivial exit cases
945 // Don't exit if closing a popup - popups aren't counted in app.panes
946 if ctx.remaining == 1 && !node.isPopup {
947 return wm.cleanupAndExit(node)
948 }
949 if node == wm.root {
950 return wm.promoteNewRoot(ctx, node)
951 }
952
953 // STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
954 parent := node.parent
955 sibling := wm.getSibling(node)
956 grandparent := parent.parent
957
958 // Clear grandparent's focus child BEFORE unparenting closed pane
959 // GTK propagates focus events upward, so we must clear grandparent focus first
960 if grandparent != nil && grandparent.container != nil {
961 webkit.WidgetSetFocusChild(grandparent.container, nil)
962 }
963
964 // Unparent the closed pane's container from the paned BEFORE promoting sibling
965 if node.container != nil {
966 if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
967 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
968 if parent.left == node {
969 parentPaned.SetStartChild(nil)
970 } else if parent.right == node {
971 parentPaned.SetEndChild(nil)
972 }
973 log.Printf("[workspace] unparented closed pane container: %p", node.container)
974 }
975 }
976 }
977
978 // STEP 5: Unparent sibling from parent paned BEFORE promotion
979 // The sibling needs to be detached from the parent paned before we can attach it elsewhere
980 if sibling.container != nil && parent.container != nil {
981 if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
982 if parent.left == sibling {
983 parentPaned.SetStartChild(nil)
984 } else if parent.right == sibling {
985 parentPaned.SetEndChild(nil)
986 }
987 log.Printf("[workspace] unparented sibling container %p from parent paned %p", sibling.container, parent.container)
988 }
989 }
990
991 // STEP 6: Promote sibling in-place (tree structure)
992 wm.promoteSibling(grandparent, parent, sibling)
993
994 // STEP 7: GTK widget reparenting
995 // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
996 // For window: we already manually unparented above, so now we can safely attach
997 wm.swapContainers(grandparent, sibling)
998
999 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1000 if sibling != nil && sibling.container != nil {
1001 wm.scheduleIdleGuarded(func() bool {
1002 if sibling == nil || !sibling.widgetValid {
1003 return false
1004 }
1005 if sibling.container != nil {
1006 webkit.WidgetShow(sibling.container)
1007 webkit.WidgetQueueResize(sibling.container)
1008 webkit.WidgetQueueDraw(sibling.container)
1009 }
1010 return false
1011 }, sibling)
1012 }
1013
1014 // STEP 8: Check if grandparent now has only one child (cascade promotion needed)
1015 if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1016 // Grandparent has exactly one child - it should be promoted too
1017 log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
1018 wm.cascadePromotion(grandparent)
1019 }
1020
1021 // STEP 9: Cleanup & focus
1022 wm.cleanupPane(node, ctx.Generation())
1023 wm.decommissionParent(parent, ctx.Generation())
1024 if sibling != nil {
1025 wm.scheduleIdleGuarded(func() bool {
1026 if sibling == nil || !sibling.widgetValid {
1027 return false
1028 }
1029 wm.setFocusToLeaf(sibling)
1030 return false
1031 }, sibling)
1032 }
1033
1034 return sibling, nil
1035}
1036
1037// closeStackedPaneCompat provides compatibility layer for closing stacked panes
1038func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
1039 stack := node.parent
1040
1041 // Find index
1042 index := -1
1043 for i, pane := range stack.stackedPanes {
1044 if pane == node {
1045 index = i
1046 break
1047 }
1048 }
1049
1050 if index == -1 {
1051 return nil, errors.New("pane not in stack")
1052 }
1053
1054 // Remove from array
1055 stack.stackedPanes = append(
1056 stack.stackedPanes[:index],
1057 stack.stackedPanes[index+1:]...,
1058 )
1059
1060 // If only one pane left, unstack it
1061 if len(stack.stackedPanes) == 1 {
1062 remaining := stack.stackedPanes[0]
1063
1064 // Replace stack with remaining pane in tree
1065 remaining.parent = stack.parent
1066 if stack.parent != nil {
1067 if stack.parent.left == stack {
1068 stack.parent.left = remaining
1069 } else {
1070 stack.parent.right = remaining
1071 }
1072
1073 // Update GTK widgets
1074 if parentPaned, ok := stack.parent.container.(*gtk.Paned); ok && parentPaned != nil && remaining.container != nil {
1075 if stack.parent.left == remaining {
1076 parentPaned.SetStartChild(remaining.container)
1077 } else {
1078 parentPaned.SetEndChild(remaining.container)
1079 }
1080 }
1081 } else {
1082 // Stack was root
1083 wm.root = remaining
1084 if remaining.container != nil {
1085 wm.window.SetChild(remaining.container)
1086 }
1087 }
1088
1089 // Cleanup stack container
1090 wm.cleanupPane(stack, wm.nextCleanupGeneration())
1091
1092 return remaining, nil
1093 }
1094
1095 // Update active index if needed
1096 if stack.activeStackIndex >= len(stack.stackedPanes) {
1097 stack.activeStackIndex = len(stack.stackedPanes) - 1
1098 }
1099
1100 // Remove widget from GTK box
1101 if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1102 stackBox.Remove(node.container)
1103 }
1104
1105 // Show new active pane
1106 if stack.activeStackIndex >= 0 {
1107 active := stack.stackedPanes[stack.activeStackIndex]
1108 if active.container != nil {
1109 webkit.WidgetShow(active.container)
1110 }
1111 }
1112
1113 // Cleanup closed pane
1114 wm.cleanupPane(node, wm.nextCleanupGeneration())
1115
1116 return stack, nil
1117}
1118
1119// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1120// the divider to an even split. GTK often reports near-zero allocations for a few frames
1121// after insertion, so we retry on map/tick/idle until the size stabilizes.
1122func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1123 if wm == nil || paned == nil {
1124 return
1125 }
1126
1127 const (
1128 minDimension = 32
1129 maxFrames = 120
1130 )
1131
1132 setPosition := func() bool {
1133 if guard != nil && !guard.widgetValid {
1134 return true
1135 }
1136
1137 alloc := paned.Allocation()
1138 dimension := alloc.Width()
1139 if orientation == gtk.OrientationVertical {
1140 dimension = alloc.Height()
1141 }
1142
1143 if dimension < minDimension {
1144 return false
1145 }
1146
1147 pos := dimension / 2
1148 if pos <= 0 {
1149 return false
1150 }
1151
1152 paned.SetPosition(pos)
1153 log.Printf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height())
1154 return true
1155 }
1156
1157 // Attempt immediately with the current allocation.
1158 if setPosition() {
1159 return
1160 }
1161
1162 var frames uint
1163
1164 paned.ConnectMap(func() {
1165 setPosition()
1166 })
1167
1168 paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1169 if guard != nil && !guard.widgetValid {
1170 return false
1171 }
1172
1173 frames++
1174 if setPosition() {
1175 return false
1176 }
1177
1178 if frames >= maxFrames {
1179 alloc := paned.Allocation()
1180 log.Printf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height())
1181 return false
1182 }
1183
1184 return true
1185 })
1186
1187 wm.scheduleIdleGuarded(func() bool {
1188 setPosition()
1189 return false
1190 }, guard)
1191}