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