feat(ui): implement incremental stack split with visible pane navigation
- commit
- 03c91d21f120ef06cbf1dfd353ab824399175b70
- author
- brice · · 2025-12-15 22:37 UTC
- committer
- brice · · 2025-12-15 22:18 UTC
- parent
- 05af959
- signature
- unsigned
4 files · +150 -11
M internal/application/usecase/manage_panes.go +8 -4
| @@ -72,11 +72,15 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 72 | 72 | return nil, fmt.Errorf("target pane is required") | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | // If target is stacked, split around the entire stack | |
| 75 | + | // If target is inside a stack and splitting left/right, split around the entire stack | |
| 76 | + | // (i.e., create the new pane as a sibling to the stack, not inside it) | |
| 76 | 77 | targetNode := input.TargetPane | |
| 77 | - | if targetNode.IsStacked { | |
| 78 | - | // Find the stack container | |
| 79 | - | log.Debug().Msg("target is stacked, splitting around stack") | |
| 78 | + | if input.Direction == SplitLeft || input.Direction == SplitRight { | |
| 79 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 80 | + | // Promote to the stack container so split happens outside the stack | |
| 81 | + | targetNode = targetNode.Parent | |
| 82 | + | log.Debug().Msg("target is inside a stack, splitting around stack container") | |
| 83 | + | } | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 82 | 86 | // Create new pane | |
| @@ -72,11 +72,15 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 72 | return nil, fmt.Errorf("target pane is required") | 72 | return nil, fmt.Errorf("target pane is required") | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | - | // If target is stacked, split around the entire stack | 75 | + | // If target is inside a stack and splitting left/right, split around the entire stack |
| 76 | + | // (i.e., create the new pane as a sibling to the stack, not inside it) | |||
| 76 | targetNode := input.TargetPane | 77 | targetNode := input.TargetPane | ||
| 77 | - | if targetNode.IsStacked { | 78 | + | if input.Direction == SplitLeft || input.Direction == SplitRight { |
| 78 | - | // Find the stack container | 79 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { |
| 79 | - | log.Debug().Msg("target is stacked, splitting around stack") | 80 | + | // Promote to the stack container so split happens outside the stack |
| 81 | + | targetNode = targetNode.Parent | |||
| 82 | + | log.Debug().Msg("target is inside a stack, splitting around stack container") | |||
| 83 | + | } | |||
| 80 | } | 84 | } | ||
| 81 | 85 | ||||
| 82 | // Create new pane | 86 | // Create new pane | ||
M internal/ui/app.go +126 -5
| @@ -587,6 +587,25 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect | |||
| 587 | 587 | return nil | |
| 588 | 588 | } | |
| 589 | 589 | ||
| 590 | + | wsView := a.activeWorkspaceView() | |
| 591 | + | ||
| 592 | + | // Check if we're splitting from inside a stack (horizontal split around stack) | |
| 593 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | |
| 594 | + | activePane.Parent != nil && activePane.Parent.IsStacked | |
| 595 | + | ||
| 596 | + | // Get the existing StackedView BEFORE domain changes (for incremental operation) | |
| 597 | + | var existingStackWidget layout.Widget | |
| 598 | + | if isStackSplit && wsView != nil { | |
| 599 | + | tr := wsView.TreeRenderer() | |
| 600 | + | if tr != nil { | |
| 601 | + | // Get any pane's StackedView from the stack - they all map to the same one | |
| 602 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |
| 603 | + | if stackedView != nil { | |
| 604 | + | existingStackWidget = stackedView.Widget() | |
| 605 | + | } | |
| 606 | + | } | |
| 607 | + | } | |
| 608 | + | ||
| 590 | 609 | output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{ | |
| 591 | 610 | Workspace: ws, | |
| 592 | 611 | TargetPane: activePane, | |
| @@ -597,16 +616,31 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect | |||
| 597 | 616 | return err | |
| 598 | 617 | } | |
| 599 | 618 | ||
| 619 | + | // Remember old active pane before changing | |
| 620 | + | oldActivePaneID := activePane.Pane.ID | |
| 621 | + | ||
| 600 | 622 | // Set the new pane as active | |
| 601 | 623 | ws.ActivePaneID = output.NewPaneNode.Pane.ID | |
| 602 | 624 | ||
| 603 | - | // Rebuild the workspace view | |
| 604 | - | wsView := a.activeWorkspaceView() | |
| 625 | + | // Update the workspace view | |
| 605 | 626 | if wsView != nil { | |
| 606 | - | if err := wsView.Rebuild(); err != nil { | |
| 607 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 627 | + | if isStackSplit && existingStackWidget != nil { | |
| 628 | + | // Incremental split: reuse existing stack widget | |
| 629 | + | if err := a.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingStackWidget, oldActivePaneID); err != nil { | |
| 630 | + | log.Warn().Err(err).Msg("incremental split failed, falling back to rebuild") | |
| 631 | + | // Fallback to full rebuild | |
| 632 | + | if err := wsView.Rebuild(); err != nil { | |
| 633 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 634 | + | } | |
| 635 | + | a.attachWorkspaceWebViews(ctx, ws, wsView) | |
| 636 | + | } | |
| 637 | + | } else { | |
| 638 | + | // Regular split: full rebuild | |
| 639 | + | if err := wsView.Rebuild(); err != nil { | |
| 640 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 641 | + | } | |
| 642 | + | a.attachWorkspaceWebViews(ctx, ws, wsView) | |
| 608 | 643 | } | |
| 609 | - | a.attachWorkspaceWebViews(ctx, ws, wsView) | |
| 610 | 644 | wsView.FocusPane(ws.ActivePaneID) | |
| 611 | 645 | } | |
| 612 | 646 | ||
| @@ -615,6 +649,93 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect | |||
| 615 | 649 | return nil | |
| 616 | 650 | } | |
| 617 | 651 | ||
| 652 | + | // doIncrementalStackSplit performs an incremental split around an existing stacked pane. | |
| 653 | + | // This preserves existing WebViews by reusing the existing StackedView widget and creating | |
| 654 | + | // a new SplitView around it, rather than rebuilding the entire widget tree. | |
| 655 | + | func (a *App) doIncrementalStackSplit( | |
| 656 | + | ctx context.Context, | |
| 657 | + | wsView *component.WorkspaceView, | |
| 658 | + | ws *entity.Workspace, | |
| 659 | + | output *usecase.SplitPaneOutput, | |
| 660 | + | direction usecase.SplitDirection, | |
| 661 | + | existingStackWidget layout.Widget, | |
| 662 | + | oldActivePaneID entity.PaneID, | |
| 663 | + | ) error { | |
| 664 | + | log := logging.FromContext(ctx) | |
| 665 | + | factory := wsView.Factory() | |
| 666 | + | ||
| 667 | + | log.Debug(). | |
| 668 | + | Str("direction", string(direction)). | |
| 669 | + | Str("new_pane_id", string(output.NewPaneNode.Pane.ID)). | |
| 670 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |
| 671 | + | Msg("performing incremental stack split") | |
| 672 | + | ||
| 673 | + | // 1. Deactivate the old active pane (in the stack) | |
| 674 | + | if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil { | |
| 675 | + | oldPaneView.SetActive(false) | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | // 2. Unparent the existing stack widget from the workspace container | |
| 679 | + | wsView.Container().Remove(existingStackWidget) | |
| 680 | + | ||
| 681 | + | // 2. Create new PaneView for the new pane (without WebView - will attach later) | |
| 682 | + | newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil) | |
| 683 | + | ||
| 684 | + | // 3. Wrap the new PaneView in a StackedView | |
| 685 | + | newStackedView := layout.NewStackedView(factory) | |
| 686 | + | newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget()) | |
| 687 | + | ||
| 688 | + | // 4. Create a SplitView with the appropriate orientation and child placement | |
| 689 | + | var splitView *layout.SplitView | |
| 690 | + | if direction == usecase.SplitLeft { | |
| 691 | + | // New pane goes on the left, existing stack on the right | |
| 692 | + | splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal, | |
| 693 | + | newStackedView.Widget(), existingStackWidget, output.SplitRatio) | |
| 694 | + | } else { | |
| 695 | + | // New pane goes on the right, existing stack on the left (SplitRight) | |
| 696 | + | splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal, | |
| 697 | + | existingStackWidget, newStackedView.Widget(), output.SplitRatio) | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | // 5. Replace the root widget in the workspace view | |
| 701 | + | wsView.SetRootWidgetDirect(splitView.Widget()) | |
| 702 | + | ||
| 703 | + | // 6. Register the new pane in tracking maps and activate it | |
| 704 | + | wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView) | |
| 705 | + | newPaneView.SetActive(true) | |
| 706 | + | ||
| 707 | + | tr := wsView.TreeRenderer() | |
| 708 | + | if tr != nil { | |
| 709 | + | // Register the new pane's StackedView mapping | |
| 710 | + | tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView) | |
| 711 | + | // Register the split node widget | |
| 712 | + | tr.RegisterWidget(output.ParentNode.ID, splitView.Widget()) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | // 7. Attach WebView only for the new pane | |
| 716 | + | wv, err := a.ensureWebViewForPane(ctx, output.NewPaneNode.Pane.ID) | |
| 717 | + | if err != nil { | |
| 718 | + | log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane") | |
| 719 | + | return err | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | // Load the default URL for new pane | |
| 723 | + | newPaneEntity := output.NewPaneNode.Pane | |
| 724 | + | if newPaneEntity.URI != "" { | |
| 725 | + | if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil { | |
| 726 | + | log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane") | |
| 727 | + | } | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | widget := a.wrapWebViewWidget(ctx, wv) | |
| 731 | + | if widget != nil { | |
| 732 | + | newPaneView.SetWebViewWidget(widget) | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | log.Debug().Msg("incremental stack split completed successfully") | |
| 736 | + | return nil | |
| 737 | + | } | |
| 738 | + | ||
| 618 | 739 | // handleClosePane closes the active pane. | |
| 619 | 740 | func (a *App) handleClosePane(ctx context.Context) error { | |
| 620 | 741 | log := logging.FromContext(ctx) | |
| @@ -587,6 +587,25 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect | |||||
| 587 | return nil | 587 | return nil | ||
| 588 | } | 588 | } | ||
| 589 | 589 | ||||
| 590 | + | wsView := a.activeWorkspaceView() | |||
| 591 | + | ||||
| 592 | + | // Check if we're splitting from inside a stack (horizontal split around stack) | |||
| 593 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | |||
| 594 | + | activePane.Parent != nil && activePane.Parent.IsStacked | |||
| 595 | + | ||||
| 596 | + | // Get the existing StackedView BEFORE domain changes (for incremental operation) | |||
| 597 | + | var existingStackWidget layout.Widget | |||
| 598 | + | if isStackSplit && wsView != nil { | |||
| 599 | + | tr := wsView.TreeRenderer() | |||
| 600 | + | if tr != nil { | |||
| 601 | + | // Get any pane's StackedView from the stack - they all map to the same one | |||
| 602 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |||
| 603 | + | if stackedView != nil { | |||
| 604 | + | existingStackWidget = stackedView.Widget() | |||
| 605 | + | } | |||
| 606 | + | } | |||
| 607 | + | } | |||
| 608 | + | ||||
| 590 | output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{ | 609 | output, err := a.panesUC.Split(ctx, usecase.SplitPaneInput{ | ||
| 591 | Workspace: ws, | 610 | Workspace: ws, | ||
| 592 | TargetPane: activePane, | 611 | TargetPane: activePane, | ||
| @@ -597,16 +616,31 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect | |||||
| 597 | return err | 616 | return err | ||
| 598 | } | 617 | } | ||
| 599 | 618 | ||||
| 619 | + | // Remember old active pane before changing | |||
| 620 | + | oldActivePaneID := activePane.Pane.ID | |||
| 621 | + | ||||
| 600 | // Set the new pane as active | 622 | // Set the new pane as active | ||
| 601 | ws.ActivePaneID = output.NewPaneNode.Pane.ID | 623 | ws.ActivePaneID = output.NewPaneNode.Pane.ID | ||
| 602 | 624 | ||||
| 603 | - | // Rebuild the workspace view | 625 | + | // Update the workspace view |
| 604 | - | wsView := a.activeWorkspaceView() | |||
| 605 | if wsView != nil { | 626 | if wsView != nil { | ||
| 606 | - | if err := wsView.Rebuild(); err != nil { | 627 | + | if isStackSplit && existingStackWidget != nil { |
| 607 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | 628 | + | // Incremental split: reuse existing stack widget |
| 629 | + | if err := a.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingStackWidget, oldActivePaneID); err != nil { | |||
| 630 | + | log.Warn().Err(err).Msg("incremental split failed, falling back to rebuild") | |||
| 631 | + | // Fallback to full rebuild | |||
| 632 | + | if err := wsView.Rebuild(); err != nil { | |||
| 633 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 634 | + | } | |||
| 635 | + | a.attachWorkspaceWebViews(ctx, ws, wsView) | |||
| 636 | + | } | |||
| 637 | + | } else { | |||
| 638 | + | // Regular split: full rebuild | |||
| 639 | + | if err := wsView.Rebuild(); err != nil { | |||
| 640 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 641 | + | } | |||
| 642 | + | a.attachWorkspaceWebViews(ctx, ws, wsView) | |||
| 608 | } | 643 | } | ||
| 609 | - | a.attachWorkspaceWebViews(ctx, ws, wsView) | |||
| 610 | wsView.FocusPane(ws.ActivePaneID) | 644 | wsView.FocusPane(ws.ActivePaneID) | ||
| 611 | } | 645 | } | ||
| 612 | 646 | ||||
| @@ -615,6 +649,93 @@ func (a *App) handlePaneSplit(ctx context.Context, direction usecase.SplitDirect | |||||
| 615 | return nil | 649 | return nil | ||
| 616 | } | 650 | } | ||
| 617 | 651 | ||||
| 652 | + | // doIncrementalStackSplit performs an incremental split around an existing stacked pane. | |||
| 653 | + | // This preserves existing WebViews by reusing the existing StackedView widget and creating | |||
| 654 | + | // a new SplitView around it, rather than rebuilding the entire widget tree. | |||
| 655 | + | func (a *App) doIncrementalStackSplit( | |||
| 656 | + | ctx context.Context, | |||
| 657 | + | wsView *component.WorkspaceView, | |||
| 658 | + | ws *entity.Workspace, | |||
| 659 | + | output *usecase.SplitPaneOutput, | |||
| 660 | + | direction usecase.SplitDirection, | |||
| 661 | + | existingStackWidget layout.Widget, | |||
| 662 | + | oldActivePaneID entity.PaneID, | |||
| 663 | + | ) error { | |||
| 664 | + | log := logging.FromContext(ctx) | |||
| 665 | + | factory := wsView.Factory() | |||
| 666 | + | ||||
| 667 | + | log.Debug(). | |||
| 668 | + | Str("direction", string(direction)). | |||
| 669 | + | Str("new_pane_id", string(output.NewPaneNode.Pane.ID)). | |||
| 670 | + | Str("old_active_pane_id", string(oldActivePaneID)). | |||
| 671 | + | Msg("performing incremental stack split") | |||
| 672 | + | ||||
| 673 | + | // 1. Deactivate the old active pane (in the stack) | |||
| 674 | + | if oldPaneView := wsView.GetPaneView(oldActivePaneID); oldPaneView != nil { | |||
| 675 | + | oldPaneView.SetActive(false) | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | // 2. Unparent the existing stack widget from the workspace container | |||
| 679 | + | wsView.Container().Remove(existingStackWidget) | |||
| 680 | + | ||||
| 681 | + | // 2. Create new PaneView for the new pane (without WebView - will attach later) | |||
| 682 | + | newPaneView := component.NewPaneView(factory, output.NewPaneNode.Pane.ID, nil) | |||
| 683 | + | ||||
| 684 | + | // 3. Wrap the new PaneView in a StackedView | |||
| 685 | + | newStackedView := layout.NewStackedView(factory) | |||
| 686 | + | newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget()) | |||
| 687 | + | ||||
| 688 | + | // 4. Create a SplitView with the appropriate orientation and child placement | |||
| 689 | + | var splitView *layout.SplitView | |||
| 690 | + | if direction == usecase.SplitLeft { | |||
| 691 | + | // New pane goes on the left, existing stack on the right | |||
| 692 | + | splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal, | |||
| 693 | + | newStackedView.Widget(), existingStackWidget, output.SplitRatio) | |||
| 694 | + | } else { | |||
| 695 | + | // New pane goes on the right, existing stack on the left (SplitRight) | |||
| 696 | + | splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal, | |||
| 697 | + | existingStackWidget, newStackedView.Widget(), output.SplitRatio) | |||
| 698 | + | } | |||
| 699 | + | ||||
| 700 | + | // 5. Replace the root widget in the workspace view | |||
| 701 | + | wsView.SetRootWidgetDirect(splitView.Widget()) | |||
| 702 | + | ||||
| 703 | + | // 6. Register the new pane in tracking maps and activate it | |||
| 704 | + | wsView.RegisterPaneView(output.NewPaneNode.Pane.ID, newPaneView) | |||
| 705 | + | newPaneView.SetActive(true) | |||
| 706 | + | ||||
| 707 | + | tr := wsView.TreeRenderer() | |||
| 708 | + | if tr != nil { | |||
| 709 | + | // Register the new pane's StackedView mapping | |||
| 710 | + | tr.RegisterPaneInStack(string(output.NewPaneNode.Pane.ID), newStackedView) | |||
| 711 | + | // Register the split node widget | |||
| 712 | + | tr.RegisterWidget(output.ParentNode.ID, splitView.Widget()) | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | // 7. Attach WebView only for the new pane | |||
| 716 | + | wv, err := a.ensureWebViewForPane(ctx, output.NewPaneNode.Pane.ID) | |||
| 717 | + | if err != nil { | |||
| 718 | + | log.Warn().Err(err).Str("pane_id", string(output.NewPaneNode.Pane.ID)).Msg("failed to ensure webview for new pane") | |||
| 719 | + | return err | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | // Load the default URL for new pane | |||
| 723 | + | newPaneEntity := output.NewPaneNode.Pane | |||
| 724 | + | if newPaneEntity.URI != "" { | |||
| 725 | + | if err := wv.LoadURI(ctx, newPaneEntity.URI); err != nil { | |||
| 726 | + | log.Warn().Err(err).Str("uri", newPaneEntity.URI).Msg("failed to load URI for new pane") | |||
| 727 | + | } | |||
| 728 | + | } | |||
| 729 | + | ||||
| 730 | + | widget := a.wrapWebViewWidget(ctx, wv) | |||
| 731 | + | if widget != nil { | |||
| 732 | + | newPaneView.SetWebViewWidget(widget) | |||
| 733 | + | } | |||
| 734 | + | ||||
| 735 | + | log.Debug().Msg("incremental stack split completed successfully") | |||
| 736 | + | return nil | |||
| 737 | + | } | |||
| 738 | + | ||||
| 618 | // handleClosePane closes the active pane. | 739 | // handleClosePane closes the active pane. | ||
| 619 | func (a *App) handleClosePane(ctx context.Context) error { | 740 | func (a *App) handleClosePane(ctx context.Context) error { | ||
| 620 | log := logging.FromContext(ctx) | 741 | log := logging.FromContext(ctx) | ||
M internal/ui/focus/manager.go +10 -2
| @@ -37,18 +37,26 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||
| 37 | 37 | // CollectPaneRects gathers geometry from all visible panes. | |
| 38 | 38 | // For panes in stacks, uses the stack container's geometry since individual | |
| 39 | 39 | // collapsed panes have no allocated size. | |
| 40 | + | // Only visible panes are included - for stacked panes, only the active one is visible. | |
| 40 | 41 | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | |
| 41 | 42 | var rects []entity.PaneRect | |
| 42 | 43 | ||
| 43 | 44 | container := provider.ContainerWidget() | |
| 44 | 45 | ||
| 45 | 46 | for _, paneID := range provider.GetPaneIDs() { | |
| 46 | - | // First check if pane is in a stack - use stack container geometry | |
| 47 | + | paneWidget := provider.GetPaneWidget(paneID) | |
| 48 | + | ||
| 49 | + | // Skip panes that aren't visible (includes inactive stacked panes) | |
| 50 | + | if paneWidget == nil || !paneWidget.IsVisible() { | |
| 51 | + | continue | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // For stacked panes, use stack container geometry (pane itself has no size) | |
| 47 | 55 | var widget layout.Widget | |
| 48 | 56 | if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil { | |
| 49 | 57 | widget = stackWidget | |
| 50 | 58 | } else { | |
| 51 | - | widget = provider.GetPaneWidget(paneID) | |
| 59 | + | widget = paneWidget | |
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | 62 | if widget == nil || !widget.IsVisible() { | |
| @@ -37,18 +37,26 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||||
| 37 | // CollectPaneRects gathers geometry from all visible panes. | 37 | // CollectPaneRects gathers geometry from all visible panes. | ||
| 38 | // For panes in stacks, uses the stack container's geometry since individual | 38 | // For panes in stacks, uses the stack container's geometry since individual | ||
| 39 | // collapsed panes have no allocated size. | 39 | // collapsed panes have no allocated size. | ||
| 40 | + | // Only visible panes are included - for stacked panes, only the active one is visible. | |||
| 40 | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | 41 | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | ||
| 41 | var rects []entity.PaneRect | 42 | var rects []entity.PaneRect | ||
| 42 | 43 | ||||
| 43 | container := provider.ContainerWidget() | 44 | container := provider.ContainerWidget() | ||
| 44 | 45 | ||||
| 45 | for _, paneID := range provider.GetPaneIDs() { | 46 | for _, paneID := range provider.GetPaneIDs() { | ||
| 46 | - | // First check if pane is in a stack - use stack container geometry | 47 | + | paneWidget := provider.GetPaneWidget(paneID) |
| 48 | + | ||||
| 49 | + | // Skip panes that aren't visible (includes inactive stacked panes) | |||
| 50 | + | if paneWidget == nil || !paneWidget.IsVisible() { | |||
| 51 | + | continue | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // For stacked panes, use stack container geometry (pane itself has no size) | |||
| 47 | var widget layout.Widget | 55 | var widget layout.Widget | ||
| 48 | if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil { | 56 | if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil { | ||
| 49 | widget = stackWidget | 57 | widget = stackWidget | ||
| 50 | } else { | 58 | } else { | ||
| 51 | - | widget = provider.GetPaneWidget(paneID) | 59 | + | widget = paneWidget |
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 54 | if widget == nil || !widget.IsVisible() { | 62 | if widget == nil || !widget.IsVisible() { | ||
M internal/ui/layout/tree.go +6 -0
| @@ -197,6 +197,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | |||
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | 199 | stackedView.AddPane(tr.ctx, title, favicon, childWidget) | |
| 200 | + | ||
| 201 | + | // Override paneToStack to point to THIS stacked view, not the child's wrapper. | |
| 202 | + | // This ensures geometric navigation and stack sync work correctly. | |
| 203 | + | if child.Pane != nil { | |
| 204 | + | tr.paneToStack[string(child.Pane.ID)] = stackedView | |
| 205 | + | } | |
| 200 | 206 | } | |
| 201 | 207 | ||
| 202 | 208 | // Set active index | |
| @@ -197,6 +197,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | |||||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | stackedView.AddPane(tr.ctx, title, favicon, childWidget) | 199 | stackedView.AddPane(tr.ctx, title, favicon, childWidget) | ||
| 200 | + | ||||
| 201 | + | // Override paneToStack to point to THIS stacked view, not the child's wrapper. | |||
| 202 | + | // This ensures geometric navigation and stack sync work correctly. | |||
| 203 | + | if child.Pane != nil { | |||
| 204 | + | tr.paneToStack[string(child.Pane.ID)] = stackedView | |||
| 205 | + | } | |||
| 200 | } | 206 | } | ||
| 201 | 207 | ||||
| 202 | // Set active index | 208 | // Set active index | ||