fix: vertical splits from stacked panes now split around entire stack
- commit
- f2d203b7e3c0b3d384e8284885c6821f57cfdb7d
- author
- brice · · 2026-01-01 10:34 UTC
- parent
- c2f6690
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +393 -113
M internal/application/usecase/manage_panes.go +6 -7
| @@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 112 | 112 | return nil, fmt.Errorf("target pane is required") | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | - | // If target is inside a stack and splitting left/right, split around the entire stack | |
| 115 | + | // If target is inside a stack, split around the entire stack | |
| 116 | 116 | // (i.e., create the new pane as a sibling to the stack, not inside it) | |
| 117 | + | // This applies to all directions - we don't want to nest split containers inside stacks. | |
| 117 | 118 | targetNode := input.TargetPane | |
| 118 | - | if input.Direction == SplitLeft || input.Direction == SplitRight { | |
| 119 | - | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 120 | - | // Promote to the stack container so split happens outside the stack | |
| 121 | - | targetNode = targetNode.Parent | |
| 122 | - | log.Debug().Msg("target is inside a stack, splitting around stack container") | |
| 123 | - | } | |
| 119 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 120 | + | // Promote to the stack container so split happens outside the stack | |
| 121 | + | targetNode = targetNode.Parent | |
| 122 | + | log.Debug().Msg("target is inside a stack, splitting around stack container") | |
| 124 | 123 | } | |
| 125 | 124 | ||
| 126 | 125 | // Create new pane | |
| @@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 112 | return nil, fmt.Errorf("target pane is required") | 112 | return nil, fmt.Errorf("target pane is required") | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | - | // If target is inside a stack and splitting left/right, split around the entire stack | 115 | + | // If target is inside a stack, split around the entire stack |
| 116 | // (i.e., create the new pane as a sibling to the stack, not inside it) | 116 | // (i.e., create the new pane as a sibling to the stack, not inside it) | ||
| 117 | + | // This applies to all directions - we don't want to nest split containers inside stacks. | |||
| 117 | targetNode := input.TargetPane | 118 | targetNode := input.TargetPane | ||
| 118 | - | if input.Direction == SplitLeft || input.Direction == SplitRight { | 119 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { |
| 119 | - | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | 120 | + | // Promote to the stack container so split happens outside the stack |
| 120 | - | // Promote to the stack container so split happens outside the stack | 121 | + | targetNode = targetNode.Parent |
| 121 | - | targetNode = targetNode.Parent | 122 | + | log.Debug().Msg("target is inside a stack, splitting around stack container") |
| 122 | - | log.Debug().Msg("target is inside a stack, splitting around stack container") | |||
| 123 | - | } | |||
| 124 | } | 123 | } | ||
| 125 | 124 | ||||
| 126 | // Create new pane | 125 | // Create new pane | ||
M internal/ui/component/pane_view.go +12 -0
| @@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||
| 244 | 244 | } | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | // CancelPendingHover cancels any pending hover focus timer. | |
| 248 | + | // Called when keyboard navigation occurs to prevent hover from overriding keyboard focus. | |
| 249 | + | func (pv *PaneView) CancelPendingHover() { | |
| 250 | + | pv.mu.RLock() | |
| 251 | + | handler := pv.hoverHandler | |
| 252 | + | pv.mu.RUnlock() | |
| 253 | + | ||
| 254 | + | if handler != nil { | |
| 255 | + | handler.Cancel() | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 247 | 259 | // Widget returns the underlying overlay widget for embedding in containers. | |
| 248 | 260 | func (pv *PaneView) Widget() layout.Widget { | |
| 249 | 261 | return pv.overlay | |
| @@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||||
| 244 | } | 244 | } | ||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | + | // CancelPendingHover cancels any pending hover focus timer. | |||
| 248 | + | // Called when keyboard navigation occurs to prevent hover from overriding keyboard focus. | |||
| 249 | + | func (pv *PaneView) CancelPendingHover() { | |||
| 250 | + | pv.mu.RLock() | |||
| 251 | + | handler := pv.hoverHandler | |||
| 252 | + | pv.mu.RUnlock() | |||
| 253 | + | ||||
| 254 | + | if handler != nil { | |||
| 255 | + | handler.Cancel() | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 247 | // Widget returns the underlying overlay widget for embedding in containers. | 259 | // Widget returns the underlying overlay widget for embedding in containers. | ||
| 248 | func (pv *PaneView) Widget() layout.Widget { | 260 | func (pv *PaneView) Widget() layout.Widget { | ||
| 249 | return pv.overlay | 261 | return pv.overlay | ||
M internal/ui/component/workspace_view.go +42 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sync" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| @@ -50,6 +51,10 @@ type WorkspaceView struct { | |||
| 50 | 51 | onPaneFocused func(paneID entity.PaneID) | |
| 51 | 52 | onSplitRatioDragged func(nodeID string, ratio float64) | |
| 52 | 53 | ||
| 54 | + | // Hover suppression for keyboard navigation (Issue #89) | |
| 55 | + | // Prevents hover focus from overriding keyboard-initiated focus changes | |
| 56 | + | hoverSuppressedUntil time.Time | |
| 57 | + | ||
| 53 | 58 | mu sync.RWMutex | |
| 54 | 59 | } | |
| 55 | 60 | ||
| @@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||
| 86 | 91 | ||
| 87 | 92 | // Set up hover callback for focus-follows-mouse | |
| 88 | 93 | pv.SetOnHover(func(paneID entity.PaneID) { | |
| 94 | + | // Skip if hover is suppressed (keyboard navigation just happened) | |
| 95 | + | if a.wv.IsHoverSuppressed() { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 89 | 99 | // Skip if this pane is already active | |
| 90 | 100 | if a.wv.GetActivePaneID() == paneID { | |
| 91 | 101 | return | |
| @@ -755,3 +765,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView { | |||
| 755 | 765 | defer wv.mu.RUnlock() | |
| 756 | 766 | return wv.paneViews[wv.getActivePaneIDInternal()] | |
| 757 | 767 | } | |
| 768 | + | ||
| 769 | + | // KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation. | |
| 770 | + | const KeyboardFocusSuppressDuration = 300 * time.Millisecond | |
| 771 | + | ||
| 772 | + | // SuppressHover temporarily blocks hover focus changes after keyboard navigation. | |
| 773 | + | // This prevents the race condition where a pending hover timer overrides | |
| 774 | + | // an explicit keyboard focus change (Issue #89). | |
| 775 | + | func (wv *WorkspaceView) SuppressHover(duration time.Duration) { | |
| 776 | + | wv.mu.Lock() | |
| 777 | + | wv.hoverSuppressedUntil = time.Now().Add(duration) | |
| 778 | + | wv.mu.Unlock() | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | // IsHoverSuppressed returns true if hover focus is temporarily suppressed. | |
| 782 | + | func (wv *WorkspaceView) IsHoverSuppressed() bool { | |
| 783 | + | wv.mu.RLock() | |
| 784 | + | defer wv.mu.RUnlock() | |
| 785 | + | return time.Now().Before(wv.hoverSuppressedUntil) | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | // CancelAllPendingHovers cancels any pending hover timers on all pane views. | |
| 789 | + | // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus. | |
| 790 | + | func (wv *WorkspaceView) CancelAllPendingHovers() { | |
| 791 | + | wv.mu.RLock() | |
| 792 | + | defer wv.mu.RUnlock() | |
| 793 | + | ||
| 794 | + | for _, pv := range wv.paneViews { | |
| 795 | + | if pv != nil { | |
| 796 | + | pv.CancelPendingHover() | |
| 797 | + | } | |
| 798 | + | } | |
| 799 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -50,6 +51,10 @@ type WorkspaceView struct { | |||||
| 50 | onPaneFocused func(paneID entity.PaneID) | 51 | onPaneFocused func(paneID entity.PaneID) | ||
| 51 | onSplitRatioDragged func(nodeID string, ratio float64) | 52 | onSplitRatioDragged func(nodeID string, ratio float64) | ||
| 52 | 53 | ||||
| 54 | + | // Hover suppression for keyboard navigation (Issue #89) | |||
| 55 | + | // Prevents hover focus from overriding keyboard-initiated focus changes | |||
| 56 | + | hoverSuppressedUntil time.Time | |||
| 57 | + | ||||
| 53 | mu sync.RWMutex | 58 | mu sync.RWMutex | ||
| 54 | } | 59 | } | ||
| 55 | 60 | ||||
| @@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||||
| 86 | 91 | ||||
| 87 | // Set up hover callback for focus-follows-mouse | 92 | // Set up hover callback for focus-follows-mouse | ||
| 88 | pv.SetOnHover(func(paneID entity.PaneID) { | 93 | pv.SetOnHover(func(paneID entity.PaneID) { | ||
| 94 | + | // Skip if hover is suppressed (keyboard navigation just happened) | |||
| 95 | + | if a.wv.IsHoverSuppressed() { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | ||||
| 89 | // Skip if this pane is already active | 99 | // Skip if this pane is already active | ||
| 90 | if a.wv.GetActivePaneID() == paneID { | 100 | if a.wv.GetActivePaneID() == paneID { | ||
| 91 | return | 101 | return | ||
| @@ -755,3 +765,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView { | |||||
| 755 | defer wv.mu.RUnlock() | 765 | defer wv.mu.RUnlock() | ||
| 756 | return wv.paneViews[wv.getActivePaneIDInternal()] | 766 | return wv.paneViews[wv.getActivePaneIDInternal()] | ||
| 757 | } | 767 | } | ||
| 768 | + | ||||
| 769 | + | // KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation. | |||
| 770 | + | const KeyboardFocusSuppressDuration = 300 * time.Millisecond | |||
| 771 | + | ||||
| 772 | + | // SuppressHover temporarily blocks hover focus changes after keyboard navigation. | |||
| 773 | + | // This prevents the race condition where a pending hover timer overrides | |||
| 774 | + | // an explicit keyboard focus change (Issue #89). | |||
| 775 | + | func (wv *WorkspaceView) SuppressHover(duration time.Duration) { | |||
| 776 | + | wv.mu.Lock() | |||
| 777 | + | wv.hoverSuppressedUntil = time.Now().Add(duration) | |||
| 778 | + | wv.mu.Unlock() | |||
| 779 | + | } | |||
| 780 | + | ||||
| 781 | + | // IsHoverSuppressed returns true if hover focus is temporarily suppressed. | |||
| 782 | + | func (wv *WorkspaceView) IsHoverSuppressed() bool { | |||
| 783 | + | wv.mu.RLock() | |||
| 784 | + | defer wv.mu.RUnlock() | |||
| 785 | + | return time.Now().Before(wv.hoverSuppressedUntil) | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | // CancelAllPendingHovers cancels any pending hover timers on all pane views. | |||
| 789 | + | // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus. | |||
| 790 | + | func (wv *WorkspaceView) CancelAllPendingHovers() { | |||
| 791 | + | wv.mu.RLock() | |||
| 792 | + | defer wv.mu.RUnlock() | |||
| 793 | + | ||||
| 794 | + | for _, pv := range wv.paneViews { | |||
| 795 | + | if pv != nil { | |||
| 796 | + | pv.CancelPendingHover() | |||
| 797 | + | } | |||
| 798 | + | } | |||
| 799 | + | } | |||
M internal/ui/component/workspace_view_test.go +14 -0
| @@ -129,6 +129,7 @@ func setupStackedLeafMocks( | |||
| 129 | 129 | mockFavicon := mocks.NewMockImageWidget(t) | |
| 130 | 130 | mockLabel := mocks.NewMockLabelWidget(t) | |
| 131 | 131 | mockButton := mocks.NewMockButtonWidget(t) | |
| 132 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |
| 132 | 133 | ||
| 133 | 134 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once() | |
| 134 | 135 | mockStackBox.EXPECT().SetHexpand(true).Once() | |
| @@ -150,13 +151,26 @@ func setupStackedLeafMocks( | |||
| 150 | 151 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | |
| 151 | 152 | mockTitleBar.EXPECT().Append(mockLabel).Once() | |
| 152 | 153 | ||
| 154 | + | // Close button (uses SetIconName directly instead of child image) | |
| 155 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |
| 156 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |
| 157 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |
| 158 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |
| 159 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |
| 160 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |
| 161 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |
| 162 | + | ||
| 163 | + | // Title bar button | |
| 153 | 164 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | |
| 154 | 165 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | |
| 155 | 166 | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | |
| 156 | 167 | mockButton.EXPECT().SetFocusOnClick(false).Once() | |
| 157 | 168 | mockButton.EXPECT().SetVexpand(false).Once() | |
| 158 | 169 | mockButton.EXPECT().SetHexpand(true).Once() | |
| 170 | + | ||
| 171 | + | // Click handlers | |
| 159 | 172 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | |
| 173 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |
| 160 | 174 | ||
| 161 | 175 | mockStackBox.EXPECT().Append(mockButton).Once() | |
| 162 | 176 | mockStackBox.EXPECT().Append(container).Once() | |
| @@ -129,6 +129,7 @@ func setupStackedLeafMocks( | |||||
| 129 | mockFavicon := mocks.NewMockImageWidget(t) | 129 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| 130 | mockLabel := mocks.NewMockLabelWidget(t) | 130 | mockLabel := mocks.NewMockLabelWidget(t) | ||
| 131 | mockButton := mocks.NewMockButtonWidget(t) | 131 | mockButton := mocks.NewMockButtonWidget(t) | ||
| 132 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |||
| 132 | 133 | ||||
| 133 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once() | 134 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once() | ||
| 134 | mockStackBox.EXPECT().SetHexpand(true).Once() | 135 | mockStackBox.EXPECT().SetHexpand(true).Once() | ||
| @@ -150,13 +151,26 @@ func setupStackedLeafMocks( | |||||
| 150 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | 151 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | ||
| 151 | mockTitleBar.EXPECT().Append(mockLabel).Once() | 152 | mockTitleBar.EXPECT().Append(mockLabel).Once() | ||
| 152 | 153 | ||||
| 154 | + | // Close button (uses SetIconName directly instead of child image) | |||
| 155 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |||
| 156 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |||
| 157 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |||
| 158 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |||
| 159 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |||
| 160 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |||
| 161 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |||
| 162 | + | ||||
| 163 | + | // Title bar button | |||
| 153 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | 164 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | ||
| 154 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | 165 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | ||
| 155 | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | 166 | mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once() | ||
| 156 | mockButton.EXPECT().SetFocusOnClick(false).Once() | 167 | mockButton.EXPECT().SetFocusOnClick(false).Once() | ||
| 157 | mockButton.EXPECT().SetVexpand(false).Once() | 168 | mockButton.EXPECT().SetVexpand(false).Once() | ||
| 158 | mockButton.EXPECT().SetHexpand(true).Once() | 169 | mockButton.EXPECT().SetHexpand(true).Once() | ||
| 170 | + | ||||
| 171 | + | // Click handlers | |||
| 159 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | 172 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | ||
| 173 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |||
| 160 | 174 | ||||
| 161 | mockStackBox.EXPECT().Append(mockButton).Once() | 175 | mockStackBox.EXPECT().Append(mockButton).Once() | ||
| 162 | mockStackBox.EXPECT().Append(container).Once() | 176 | mockStackBox.EXPECT().Append(container).Once() | ||
M internal/ui/coordinator/workspace.go +45 -16
| @@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||
| 179 | 179 | return nil, false | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | - | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | |
| 183 | - | activePane.Parent != nil && activePane.Parent.IsStacked | |
| 182 | + | // Check if active pane is inside a stack | |
| 183 | + | isInStack := activePane.Parent != nil && activePane.Parent.IsStacked | |
| 184 | + | // isStackSplit is only true for horizontal splits - these use a special code path | |
| 185 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack | |
| 184 | 186 | ||
| 185 | 187 | var existingWidget layout.Widget | |
| 186 | 188 | if wsView != nil { | |
| 187 | - | existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit) | |
| 189 | + | existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack) | |
| 188 | 190 | } | |
| 189 | 191 | ||
| 190 | 192 | return &splitContext{ | |
| @@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||
| 199 | 201 | func (c *WorkspaceCoordinator) resolveSplitWidget( | |
| 200 | 202 | wsView *component.WorkspaceView, | |
| 201 | 203 | activePane *entity.PaneNode, | |
| 202 | - | isStackSplit bool, | |
| 204 | + | isInStack bool, | |
| 203 | 205 | ) layout.Widget { | |
| 204 | 206 | if wsView == nil { | |
| 205 | 207 | return nil | |
| 206 | 208 | } | |
| 207 | 209 | ||
| 208 | - | if !isStackSplit { | |
| 209 | - | return wsView.GetRootWidget() | |
| 210 | - | } | |
| 210 | + | // When splitting from inside a stack (any direction), we need the stack widget | |
| 211 | + | // because the domain model promotes the split to happen around the stack container | |
| 212 | + | if isInStack { | |
| 213 | + | tr := wsView.TreeRenderer() | |
| 214 | + | if tr == nil { | |
| 215 | + | return nil | |
| 216 | + | } | |
| 211 | 217 | ||
| 212 | - | tr := wsView.TreeRenderer() | |
| 213 | - | if tr == nil { | |
| 214 | - | return nil | |
| 215 | - | } | |
| 218 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |
| 219 | + | if stackedView == nil { | |
| 220 | + | return nil | |
| 221 | + | } | |
| 216 | 222 | ||
| 217 | - | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |
| 218 | - | if stackedView == nil { | |
| 219 | - | return nil | |
| 223 | + | return stackedView.Widget() | |
| 220 | 224 | } | |
| 221 | 225 | ||
| 222 | - | return stackedView.Widget() | |
| 226 | + | return wsView.GetRootWidget() | |
| 223 | 227 | } | |
| 224 | 228 | ||
| 225 | 229 | func (c *WorkspaceCoordinator) applySplitToView( | |
| @@ -1161,6 +1165,10 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase. | |||
| 1161 | 1165 | return nil | |
| 1162 | 1166 | } | |
| 1163 | 1167 | ||
| 1168 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |
| 1169 | + | wsView.CancelAllPendingHovers() | |
| 1170 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |
| 1171 | + | ||
| 1164 | 1172 | // Update the workspace view's active pane | |
| 1165 | 1173 | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | |
| 1166 | 1174 | log.Warn().Err(err).Msg("failed to update active pane in view") | |
| @@ -1277,7 +1285,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1277 | 1285 | stackCtx.wsView.ShowOmnibox(ctx, "") | |
| 1278 | 1286 | } | |
| 1279 | 1287 | ||
| 1280 | - | // Set up title bar click callback | |
| 1288 | + | // Set up title bar click and close callbacks | |
| 1281 | 1289 | tr := stackCtx.wsView.TreeRenderer() | |
| 1282 | 1290 | if tr != nil { | |
| 1283 | 1291 | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) | |
| @@ -1286,6 +1294,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1286 | 1294 | stackedView.SetOnActivate(func(index int) { | |
| 1287 | 1295 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | |
| 1288 | 1296 | }) | |
| 1297 | + | stackedView.SetOnClosePane(func(paneID string) { | |
| 1298 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |
| 1299 | + | }) | |
| 1289 | 1300 | } | |
| 1290 | 1301 | } | |
| 1291 | 1302 | ||
| @@ -1531,6 +1542,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||
| 1531 | 1542 | // Update workspace view (also updates domain model) | |
| 1532 | 1543 | _, wsView := c.getActiveWS() | |
| 1533 | 1544 | if wsView != nil { | |
| 1545 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |
| 1546 | + | wsView.CancelAllPendingHovers() | |
| 1547 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |
| 1548 | + | ||
| 1534 | 1549 | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | |
| 1535 | 1550 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 1536 | 1551 | } | |
| @@ -1543,6 +1558,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||
| 1543 | 1558 | Msg("switched active pane via title bar click") | |
| 1544 | 1559 | } | |
| 1545 | 1560 | ||
| 1561 | + | // onStackedPaneClose handles close button clicks on stacked pane title bars. | |
| 1562 | + | func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) { | |
| 1563 | + | log := logging.FromContext(ctx) | |
| 1564 | + | ||
| 1565 | + | log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button") | |
| 1566 | + | ||
| 1567 | + | if err := c.ClosePaneByID(ctx, paneID); err != nil { | |
| 1568 | + | log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane") | |
| 1569 | + | } | |
| 1570 | + | } | |
| 1571 | + | ||
| 1546 | 1572 | // --- Popup Insertion --- | |
| 1547 | 1573 | ||
| 1548 | 1574 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | |
| @@ -1836,6 +1862,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||
| 1836 | 1862 | stackedView.SetOnActivate(func(index int) { | |
| 1837 | 1863 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | |
| 1838 | 1864 | }) | |
| 1865 | + | stackedView.SetOnClosePane(func(paneID string) { | |
| 1866 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |
| 1867 | + | }) | |
| 1839 | 1868 | } | |
| 1840 | 1869 | } | |
| 1841 | 1870 | return nil | |
| @@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||||
| 179 | return nil, false | 179 | return nil, false | ||
| 180 | } | 180 | } | ||
| 181 | 181 | ||||
| 182 | - | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | 182 | + | // Check if active pane is inside a stack |
| 183 | - | activePane.Parent != nil && activePane.Parent.IsStacked | 183 | + | isInStack := activePane.Parent != nil && activePane.Parent.IsStacked |
| 184 | + | // isStackSplit is only true for horizontal splits - these use a special code path | |||
| 185 | + | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack | |||
| 184 | 186 | ||||
| 185 | var existingWidget layout.Widget | 187 | var existingWidget layout.Widget | ||
| 186 | if wsView != nil { | 188 | if wsView != nil { | ||
| 187 | - | existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit) | 189 | + | existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack) |
| 188 | } | 190 | } | ||
| 189 | 191 | ||||
| 190 | return &splitContext{ | 192 | return &splitContext{ | ||
| @@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||||
| 199 | func (c *WorkspaceCoordinator) resolveSplitWidget( | 201 | func (c *WorkspaceCoordinator) resolveSplitWidget( | ||
| 200 | wsView *component.WorkspaceView, | 202 | wsView *component.WorkspaceView, | ||
| 201 | activePane *entity.PaneNode, | 203 | activePane *entity.PaneNode, | ||
| 202 | - | isStackSplit bool, | 204 | + | isInStack bool, |
| 203 | ) layout.Widget { | 205 | ) layout.Widget { | ||
| 204 | if wsView == nil { | 206 | if wsView == nil { | ||
| 205 | return nil | 207 | return nil | ||
| 206 | } | 208 | } | ||
| 207 | 209 | ||||
| 208 | - | if !isStackSplit { | 210 | + | // When splitting from inside a stack (any direction), we need the stack widget |
| 209 | - | return wsView.GetRootWidget() | 211 | + | // because the domain model promotes the split to happen around the stack container |
| 210 | - | } | 212 | + | if isInStack { |
| 213 | + | tr := wsView.TreeRenderer() | |||
| 214 | + | if tr == nil { | |||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 211 | 217 | ||||
| 212 | - | tr := wsView.TreeRenderer() | 218 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) |
| 213 | - | if tr == nil { | 219 | + | if stackedView == nil { |
| 214 | - | return nil | 220 | + | return nil |
| 215 | - | } | 221 | + | } |
| 216 | 222 | ||||
| 217 | - | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | 223 | + | return stackedView.Widget() |
| 218 | - | if stackedView == nil { | |||
| 219 | - | return nil | |||
| 220 | } | 224 | } | ||
| 221 | 225 | ||||
| 222 | - | return stackedView.Widget() | 226 | + | return wsView.GetRootWidget() |
| 223 | } | 227 | } | ||
| 224 | 228 | ||||
| 225 | func (c *WorkspaceCoordinator) applySplitToView( | 229 | func (c *WorkspaceCoordinator) applySplitToView( | ||
| @@ -1161,6 +1165,10 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase. | |||||
| 1161 | return nil | 1165 | return nil | ||
| 1162 | } | 1166 | } | ||
| 1163 | 1167 | ||||
| 1168 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |||
| 1169 | + | wsView.CancelAllPendingHovers() | |||
| 1170 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |||
| 1171 | + | ||||
| 1164 | // Update the workspace view's active pane | 1172 | // Update the workspace view's active pane | ||
| 1165 | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | 1173 | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | ||
| 1166 | log.Warn().Err(err).Msg("failed to update active pane in view") | 1174 | log.Warn().Err(err).Msg("failed to update active pane in view") | ||
| @@ -1277,7 +1285,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1277 | stackCtx.wsView.ShowOmnibox(ctx, "") | 1285 | stackCtx.wsView.ShowOmnibox(ctx, "") | ||
| 1278 | } | 1286 | } | ||
| 1279 | 1287 | ||||
| 1280 | - | // Set up title bar click callback | 1288 | + | // Set up title bar click and close callbacks |
| 1281 | tr := stackCtx.wsView.TreeRenderer() | 1289 | tr := stackCtx.wsView.TreeRenderer() | ||
| 1282 | if tr != nil { | 1290 | if tr != nil { | ||
| 1283 | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) | 1291 | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) | ||
| @@ -1286,6 +1294,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1286 | stackedView.SetOnActivate(func(index int) { | 1294 | stackedView.SetOnActivate(func(index int) { | ||
| 1287 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | 1295 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | ||
| 1288 | }) | 1296 | }) | ||
| 1297 | + | stackedView.SetOnClosePane(func(paneID string) { | |||
| 1298 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |||
| 1299 | + | }) | |||
| 1289 | } | 1300 | } | ||
| 1290 | } | 1301 | } | ||
| 1291 | 1302 | ||||
| @@ -1531,6 +1542,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||||
| 1531 | // Update workspace view (also updates domain model) | 1542 | // Update workspace view (also updates domain model) | ||
| 1532 | _, wsView := c.getActiveWS() | 1543 | _, wsView := c.getActiveWS() | ||
| 1533 | if wsView != nil { | 1544 | if wsView != nil { | ||
| 1545 | + | // Cancel pending hover timers and suppress future hover focus (Issue #89) | |||
| 1546 | + | wsView.CancelAllPendingHovers() | |||
| 1547 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |||
| 1548 | + | ||||
| 1534 | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | 1549 | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | ||
| 1535 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | 1550 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | ||
| 1536 | } | 1551 | } | ||
| @@ -1543,6 +1558,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||||
| 1543 | Msg("switched active pane via title bar click") | 1558 | Msg("switched active pane via title bar click") | ||
| 1544 | } | 1559 | } | ||
| 1545 | 1560 | ||||
| 1561 | + | // onStackedPaneClose handles close button clicks on stacked pane title bars. | |||
| 1562 | + | func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) { | |||
| 1563 | + | log := logging.FromContext(ctx) | |||
| 1564 | + | ||||
| 1565 | + | log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button") | |||
| 1566 | + | ||||
| 1567 | + | if err := c.ClosePaneByID(ctx, paneID); err != nil { | |||
| 1568 | + | log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane") | |||
| 1569 | + | } | |||
| 1570 | + | } | |||
| 1571 | + | ||||
| 1546 | // --- Popup Insertion --- | 1572 | // --- Popup Insertion --- | ||
| 1547 | 1573 | ||||
| 1548 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | 1574 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | ||
| @@ -1836,6 +1862,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||||
| 1836 | stackedView.SetOnActivate(func(index int) { | 1862 | stackedView.SetOnActivate(func(index int) { | ||
| 1837 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | 1863 | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | ||
| 1838 | }) | 1864 | }) | ||
| 1865 | + | stackedView.SetOnClosePane(func(paneID string) { | |||
| 1866 | + | c.onStackedPaneClose(ctx, entity.PaneID(paneID)) | |||
| 1867 | + | }) | |||
| 1839 | } | 1868 | } | ||
| 1840 | } | 1869 | } | ||
| 1841 | return nil | 1870 | return nil | ||
M internal/ui/focus/manager.go +41 -5
| @@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||
| 38 | 38 | // For panes in stacks, uses the stack container's geometry since individual | |
| 39 | 39 | // collapsed panes have no allocated size. | |
| 40 | 40 | // Only visible panes are included - for stacked panes, only the active one is visible. | |
| 41 | - | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | |
| 41 | + | func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect { | |
| 42 | + | log := logging.FromContext(ctx) | |
| 42 | 43 | var rects []entity.PaneRect | |
| 43 | 44 | ||
| 44 | 45 | container := provider.ContainerWidget() | |
| 45 | 46 | ||
| 46 | - | for _, paneID := range provider.GetPaneIDs() { | |
| 47 | + | allPaneIDs := provider.GetPaneIDs() | |
| 48 | + | log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting") | |
| 49 | + | ||
| 50 | + | for _, paneID := range allPaneIDs { | |
| 47 | 51 | paneWidget := provider.GetPaneWidget(paneID) | |
| 48 | 52 | ||
| 49 | 53 | // Skip panes that aren't visible (includes inactive stacked panes) | |
| 50 | 54 | if paneWidget == nil || !paneWidget.IsVisible() { | |
| 55 | + | log.Debug(). | |
| 56 | + | Str("pane_id", string(paneID)). | |
| 57 | + | Bool("widget_nil", paneWidget == nil). | |
| 58 | + | Msg("CollectPaneRects: skipping invisible pane") | |
| 51 | 59 | continue | |
| 52 | 60 | } | |
| 53 | 61 | ||
| @@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||
| 77 | 85 | continue | |
| 78 | 86 | } | |
| 79 | 87 | ||
| 88 | + | log.Debug(). | |
| 89 | + | Str("pane_id", string(paneID)). | |
| 90 | + | Int("x", int(x)). | |
| 91 | + | Int("y", int(y)). | |
| 92 | + | Int("w", w). | |
| 93 | + | Int("h", h). | |
| 94 | + | Msg("CollectPaneRects: added visible pane") | |
| 95 | + | ||
| 80 | 96 | rects = append(rects, entity.PaneRect{ | |
| 81 | 97 | PaneID: paneID, | |
| 82 | 98 | X: int(x), | |
| @@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||
| 86 | 102 | }) | |
| 87 | 103 | } | |
| 88 | 104 | ||
| 105 | + | log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done") | |
| 106 | + | ||
| 89 | 107 | return rects | |
| 90 | 108 | } | |
| 91 | 109 | ||
| @@ -128,7 +146,7 @@ func (m *Manager) NavigateGeometric( | |||
| 128 | 146 | } | |
| 129 | 147 | ||
| 130 | 148 | // Collect geometry from visible panes | |
| 131 | - | rects := m.CollectPaneRects(provider) | |
| 149 | + | rects := m.CollectPaneRects(ctx, provider) | |
| 132 | 150 | if len(rects) == 0 { | |
| 133 | 151 | return nil, nil | |
| 134 | 152 | } | |
| @@ -149,8 +167,26 @@ func (m *Manager) NavigateGeometric( | |||
| 149 | 167 | // Update workspace | |
| 150 | 168 | ws.ActivePaneID = output.TargetPaneID | |
| 151 | 169 | ||
| 152 | - | // Return target node | |
| 153 | - | return ws.FindPane(output.TargetPaneID), nil | |
| 170 | + | // Find target node and update stack index if target is in a stack | |
| 171 | + | targetNode := ws.FindPane(output.TargetPaneID) | |
| 172 | + | if targetNode != nil && targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 173 | + | // Update the stack's ActiveStackIndex to match the target pane | |
| 174 | + | oldIndex := targetNode.Parent.ActiveStackIndex | |
| 175 | + | for i, child := range targetNode.Parent.Children { | |
| 176 | + | if child == targetNode { | |
| 177 | + | targetNode.Parent.ActiveStackIndex = i | |
| 178 | + | log.Debug(). | |
| 179 | + | Str("target_pane_id", string(output.TargetPaneID)). | |
| 180 | + | Int("old_stack_index", oldIndex). | |
| 181 | + | Int("new_stack_index", i). | |
| 182 | + | Int("num_children", len(targetNode.Parent.Children)). | |
| 183 | + | Msg("updated stack index for geometric nav into stack") | |
| 184 | + | break | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | return targetNode, nil | |
| 154 | 190 | } | |
| 155 | 191 | ||
| 156 | 192 | // navigateWithinStack tries to navigate within a stack. | |
| @@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||||
| 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 | // Only visible panes are included - for stacked panes, only the active one is visible. | ||
| 41 | - | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | 41 | + | func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect { |
| 42 | + | log := logging.FromContext(ctx) | |||
| 42 | var rects []entity.PaneRect | 43 | var rects []entity.PaneRect | ||
| 43 | 44 | ||||
| 44 | container := provider.ContainerWidget() | 45 | container := provider.ContainerWidget() | ||
| 45 | 46 | ||||
| 46 | - | for _, paneID := range provider.GetPaneIDs() { | 47 | + | allPaneIDs := provider.GetPaneIDs() |
| 48 | + | log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting") | |||
| 49 | + | ||||
| 50 | + | for _, paneID := range allPaneIDs { | |||
| 47 | paneWidget := provider.GetPaneWidget(paneID) | 51 | paneWidget := provider.GetPaneWidget(paneID) | ||
| 48 | 52 | ||||
| 49 | // Skip panes that aren't visible (includes inactive stacked panes) | 53 | // Skip panes that aren't visible (includes inactive stacked panes) | ||
| 50 | if paneWidget == nil || !paneWidget.IsVisible() { | 54 | if paneWidget == nil || !paneWidget.IsVisible() { | ||
| 55 | + | log.Debug(). | |||
| 56 | + | Str("pane_id", string(paneID)). | |||
| 57 | + | Bool("widget_nil", paneWidget == nil). | |||
| 58 | + | Msg("CollectPaneRects: skipping invisible pane") | |||
| 51 | continue | 59 | continue | ||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| @@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||||
| 77 | continue | 85 | continue | ||
| 78 | } | 86 | } | ||
| 79 | 87 | ||||
| 88 | + | log.Debug(). | |||
| 89 | + | Str("pane_id", string(paneID)). | |||
| 90 | + | Int("x", int(x)). | |||
| 91 | + | Int("y", int(y)). | |||
| 92 | + | Int("w", w). | |||
| 93 | + | Int("h", h). | |||
| 94 | + | Msg("CollectPaneRects: added visible pane") | |||
| 95 | + | ||||
| 80 | rects = append(rects, entity.PaneRect{ | 96 | rects = append(rects, entity.PaneRect{ | ||
| 81 | PaneID: paneID, | 97 | PaneID: paneID, | ||
| 82 | X: int(x), | 98 | X: int(x), | ||
| @@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR | |||||
| 86 | }) | 102 | }) | ||
| 87 | } | 103 | } | ||
| 88 | 104 | ||||
| 105 | + | log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done") | |||
| 106 | + | ||||
| 89 | return rects | 107 | return rects | ||
| 90 | } | 108 | } | ||
| 91 | 109 | ||||
| @@ -128,7 +146,7 @@ func (m *Manager) NavigateGeometric( | |||||
| 128 | } | 146 | } | ||
| 129 | 147 | ||||
| 130 | // Collect geometry from visible panes | 148 | // Collect geometry from visible panes | ||
| 131 | - | rects := m.CollectPaneRects(provider) | 149 | + | rects := m.CollectPaneRects(ctx, provider) |
| 132 | if len(rects) == 0 { | 150 | if len(rects) == 0 { | ||
| 133 | return nil, nil | 151 | return nil, nil | ||
| 134 | } | 152 | } | ||
| @@ -149,8 +167,26 @@ func (m *Manager) NavigateGeometric( | |||||
| 149 | // Update workspace | 167 | // Update workspace | ||
| 150 | ws.ActivePaneID = output.TargetPaneID | 168 | ws.ActivePaneID = output.TargetPaneID | ||
| 151 | 169 | ||||
| 152 | - | // Return target node | 170 | + | // Find target node and update stack index if target is in a stack |
| 153 | - | return ws.FindPane(output.TargetPaneID), nil | 171 | + | targetNode := ws.FindPane(output.TargetPaneID) |
| 172 | + | if targetNode != nil && targetNode.Parent != nil && targetNode.Parent.IsStacked { | |||
| 173 | + | // Update the stack's ActiveStackIndex to match the target pane | |||
| 174 | + | oldIndex := targetNode.Parent.ActiveStackIndex | |||
| 175 | + | for i, child := range targetNode.Parent.Children { | |||
| 176 | + | if child == targetNode { | |||
| 177 | + | targetNode.Parent.ActiveStackIndex = i | |||
| 178 | + | log.Debug(). | |||
| 179 | + | Str("target_pane_id", string(output.TargetPaneID)). | |||
| 180 | + | Int("old_stack_index", oldIndex). | |||
| 181 | + | Int("new_stack_index", i). | |||
| 182 | + | Int("num_children", len(targetNode.Parent.Children)). | |||
| 183 | + | Msg("updated stack index for geometric nav into stack") | |||
| 184 | + | break | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | return targetNode, nil | |||
| 154 | } | 190 | } | ||
| 155 | 191 | ||||
| 156 | // navigateWithinStack tries to navigate within a stack. | 192 | // navigateWithinStack tries to navigate within a stack. | ||
M internal/ui/input/hover.go +6 -0
| @@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() { | |||
| 115 | 115 | ||
| 116 | 116 | // handleLeave cancels any pending focus switch. | |
| 117 | 117 | func (h *HoverHandler) handleLeave() { | |
| 118 | + | h.Cancel() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Cancel cancels any pending focus switch. | |
| 122 | + | // This can be called externally to cancel hover when keyboard navigation occurs. | |
| 123 | + | func (h *HoverHandler) Cancel() { | |
| 118 | 124 | h.timerMu.Lock() | |
| 119 | 125 | defer h.timerMu.Unlock() | |
| 120 | 126 | ||
| @@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() { | |||||
| 115 | 115 | ||||
| 116 | // handleLeave cancels any pending focus switch. | 116 | // handleLeave cancels any pending focus switch. | ||
| 117 | func (h *HoverHandler) handleLeave() { | 117 | func (h *HoverHandler) handleLeave() { | ||
| 118 | + | h.Cancel() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Cancel cancels any pending focus switch. | |||
| 122 | + | // This can be called externally to cancel hover when keyboard navigation occurs. | |||
| 123 | + | func (h *HoverHandler) Cancel() { | |||
| 118 | h.timerMu.Lock() | 124 | h.timerMu.Lock() | ||
| 119 | defer h.timerMu.Unlock() | 125 | defer h.timerMu.Unlock() | ||
| 120 | 126 | ||||
M internal/ui/layout/gtk_widgets.go +3 -2
| @@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget { | |||
| 519 | 519 | return >kWidget{inner: parent} | |
| 520 | 520 | } | |
| 521 | 521 | ||
| 522 | - | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } | |
| 523 | - | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } | |
| 522 | + | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } | |
| 523 | + | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } | |
| 524 | + | func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) } | |
| 524 | 525 | ||
| 525 | 526 | func (btn *gtkButton) SetChild(child Widget) { | |
| 526 | 527 | if child == nil { | |
| @@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget { | |||||
| 519 | return >kWidget{inner: parent} | 519 | return >kWidget{inner: parent} | ||
| 520 | } | 520 | } | ||
| 521 | 521 | ||||
| 522 | - | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } | 522 | + | func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) } |
| 523 | - | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } | 523 | + | func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() } |
| 524 | + | func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) } | |||
| 524 | 525 | ||||
| 525 | func (btn *gtkButton) SetChild(child Widget) { | 526 | func (btn *gtkButton) SetChild(child Widget) { | ||
| 526 | if child == nil { | 527 | if child == nil { | ||
M internal/ui/layout/mocks/mocks.go +40 -0
| @@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) | |||
| 8951 | 8951 | return _c | |
| 8952 | 8952 | } | |
| 8953 | 8953 | ||
| 8954 | + | // SetIconName provides a mock function for the type MockButtonWidget | |
| 8955 | + | func (_mock *MockButtonWidget) SetIconName(iconName string) { | |
| 8956 | + | _mock.Called(iconName) | |
| 8957 | + | return | |
| 8958 | + | } | |
| 8959 | + | ||
| 8960 | + | // MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName' | |
| 8961 | + | type MockButtonWidget_SetIconName_Call struct { | |
| 8962 | + | *mock.Call | |
| 8963 | + | } | |
| 8964 | + | ||
| 8965 | + | // SetIconName is a helper method to define mock.On call | |
| 8966 | + | // - iconName string | |
| 8967 | + | func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call { | |
| 8968 | + | return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)} | |
| 8969 | + | } | |
| 8970 | + | ||
| 8971 | + | func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |
| 8972 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 8973 | + | var arg0 string | |
| 8974 | + | if args[0] != nil { | |
| 8975 | + | arg0 = args[0].(string) | |
| 8976 | + | } | |
| 8977 | + | run( | |
| 8978 | + | arg0, | |
| 8979 | + | ) | |
| 8980 | + | }) | |
| 8981 | + | return _c | |
| 8982 | + | } | |
| 8983 | + | ||
| 8984 | + | func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call { | |
| 8985 | + | _c.Call.Return() | |
| 8986 | + | return _c | |
| 8987 | + | } | |
| 8988 | + | ||
| 8989 | + | func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |
| 8990 | + | _c.Run(run) | |
| 8991 | + | return _c | |
| 8992 | + | } | |
| 8993 | + | ||
| 8954 | 8994 | // SetLabel provides a mock function for the type MockButtonWidget | |
| 8955 | 8995 | func (_mock *MockButtonWidget) SetLabel(label string) { | |
| 8956 | 8996 | _mock.Called(label) | |
| @@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) | |||||
| 8951 | return _c | 8951 | return _c | ||
| 8952 | } | 8952 | } | ||
| 8953 | 8953 | ||||
| 8954 | + | // SetIconName provides a mock function for the type MockButtonWidget | |||
| 8955 | + | func (_mock *MockButtonWidget) SetIconName(iconName string) { | |||
| 8956 | + | _mock.Called(iconName) | |||
| 8957 | + | return | |||
| 8958 | + | } | |||
| 8959 | + | ||||
| 8960 | + | // MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName' | |||
| 8961 | + | type MockButtonWidget_SetIconName_Call struct { | |||
| 8962 | + | *mock.Call | |||
| 8963 | + | } | |||
| 8964 | + | ||||
| 8965 | + | // SetIconName is a helper method to define mock.On call | |||
| 8966 | + | // - iconName string | |||
| 8967 | + | func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call { | |||
| 8968 | + | return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)} | |||
| 8969 | + | } | |||
| 8970 | + | ||||
| 8971 | + | func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |||
| 8972 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 8973 | + | var arg0 string | |||
| 8974 | + | if args[0] != nil { | |||
| 8975 | + | arg0 = args[0].(string) | |||
| 8976 | + | } | |||
| 8977 | + | run( | |||
| 8978 | + | arg0, | |||
| 8979 | + | ) | |||
| 8980 | + | }) | |||
| 8981 | + | return _c | |||
| 8982 | + | } | |||
| 8983 | + | ||||
| 8984 | + | func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call { | |||
| 8985 | + | _c.Call.Return() | |||
| 8986 | + | return _c | |||
| 8987 | + | } | |||
| 8988 | + | ||||
| 8989 | + | func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call { | |||
| 8990 | + | _c.Run(run) | |||
| 8991 | + | return _c | |||
| 8992 | + | } | |||
| 8993 | + | ||||
| 8954 | // SetLabel provides a mock function for the type MockButtonWidget | 8994 | // SetLabel provides a mock function for the type MockButtonWidget | ||
| 8955 | func (_mock *MockButtonWidget) SetLabel(label string) { | 8995 | func (_mock *MockButtonWidget) SetLabel(label string) { | ||
| 8956 | _mock.Called(label) | 8996 | _mock.Called(label) | ||
M internal/ui/layout/stacked.go +120 -83
| @@ -17,7 +17,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds") | |||
| 17 | 17 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | |
| 18 | 18 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | |
| 19 | 19 | ||
| 20 | - | const stackedTitleMaxWidthChars = 30 | |
| 20 | + | const ( | |
| 21 | + | stackedTitleMaxWidthChars = 30 | |
| 22 | + | stackedPaneCloseIcon = "window-close-symbolic" | |
| 23 | + | ) | |
| 21 | 24 | ||
| 22 | 25 | // stackedPane represents a single pane within a stacked container. | |
| 23 | 26 | type stackedPane struct { | |
| @@ -38,7 +41,8 @@ type StackedView struct { | |||
| 38 | 41 | panes []*stackedPane | |
| 39 | 42 | activeIndex int | |
| 40 | 43 | ||
| 41 | - | onActivate func(index int) // called when a pane is activated via title bar click | |
| 44 | + | onActivate func(index int) // called when a pane is activated via title bar click | |
| 45 | + | onClosePane func(paneID string) // called when a pane's close button is clicked | |
| 42 | 46 | ||
| 43 | 47 | mu sync.RWMutex | |
| 44 | 48 | } | |
| @@ -57,20 +61,18 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||
| 57 | 61 | } | |
| 58 | 62 | } | |
| 59 | 63 | ||
| 60 | - | // AddPane adds a new pane to the stack. | |
| 61 | - | // The new pane becomes active (visible). | |
| 62 | - | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | |
| 63 | - | log := logging.FromContext(ctx) | |
| 64 | - | sv.mu.Lock() | |
| 65 | - | defer sv.mu.Unlock() | |
| 66 | - | ||
| 67 | - | log.Debug(). | |
| 68 | - | Str("title", title). | |
| 69 | - | Bool("container_nil", container == nil). | |
| 70 | - | Int("current_count", len(sv.panes)). | |
| 71 | - | Msg("StackedView.AddPane called") | |
| 64 | + | // titleBarComponents holds the widgets created for a pane's title bar. | |
| 65 | + | type titleBarComponents struct { | |
| 66 | + | titleBar BoxWidget | |
| 67 | + | titleButton ButtonWidget | |
| 68 | + | closeBtn ButtonWidget | |
| 69 | + | favicon ImageWidget | |
| 70 | + | label LabelWidget | |
| 71 | + | } | |
| 72 | 72 | ||
| 73 | - | // Create title bar - must not expand vertically | |
| 73 | + | // createTitleBar creates the title bar widgets for a pane. | |
| 74 | + | func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents { | |
| 75 | + | // Create title bar container - must not expand vertically | |
| 74 | 76 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | |
| 75 | 77 | titleBar.AddCssClass("stacked-pane-titlebar") | |
| 76 | 78 | titleBar.SetVexpand(false) | |
| @@ -93,6 +95,15 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||
| 93 | 95 | label.SetXalign(0.0) | |
| 94 | 96 | titleBar.Append(label) | |
| 95 | 97 | ||
| 98 | + | // Create close button using GTK's native icon button support | |
| 99 | + | closeBtn := sv.factory.NewButton() | |
| 100 | + | closeBtn.SetIconName(stackedPaneCloseIcon) | |
| 101 | + | closeBtn.AddCssClass("stacked-pane-close-button") | |
| 102 | + | closeBtn.SetFocusOnClick(false) | |
| 103 | + | closeBtn.SetVexpand(false) | |
| 104 | + | closeBtn.SetHexpand(false) | |
| 105 | + | titleBar.Append(closeBtn) | |
| 106 | + | ||
| 96 | 107 | // Make title bar clickable - ensure it doesn't expand vertically | |
| 97 | 108 | titleButton := sv.factory.NewButton() | |
| 98 | 109 | titleButton.SetChild(titleBar) | |
| @@ -101,35 +112,52 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||
| 101 | 112 | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | |
| 102 | 113 | titleButton.SetHexpand(true) // But fill horizontal space | |
| 103 | 114 | ||
| 115 | + | return titleBarComponents{ | |
| 116 | + | titleBar: titleBar, | |
| 117 | + | titleButton: titleButton, | |
| 118 | + | closeBtn: closeBtn, | |
| 119 | + | favicon: favicon, | |
| 120 | + | label: label, | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // AddPane adds a new pane to the stack. | |
| 125 | + | // The new pane becomes active (visible). | |
| 126 | + | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | |
| 127 | + | log := logging.FromContext(ctx) | |
| 128 | + | sv.mu.Lock() | |
| 129 | + | defer sv.mu.Unlock() | |
| 130 | + | ||
| 131 | + | log.Debug(). | |
| 132 | + | Str("title", title). | |
| 133 | + | Bool("container_nil", container == nil). | |
| 134 | + | Int("current_count", len(sv.panes)). | |
| 135 | + | Msg("StackedView.AddPane called") | |
| 136 | + | ||
| 137 | + | // Create title bar components | |
| 138 | + | tb := sv.createTitleBar(title, faviconIconName) | |
| 139 | + | ||
| 104 | 140 | pane := &stackedPane{ | |
| 105 | 141 | paneID: paneID, | |
| 106 | - | titleBar: titleBar, | |
| 142 | + | titleBar: tb.titleBar, | |
| 107 | 143 | container: container, | |
| 108 | 144 | title: title, | |
| 109 | - | favicon: favicon, | |
| 110 | - | label: label, | |
| 145 | + | favicon: tb.favicon, | |
| 146 | + | label: tb.label, | |
| 111 | 147 | isActive: false, | |
| 112 | 148 | } | |
| 113 | 149 | ||
| 114 | 150 | index := len(sv.panes) | |
| 115 | 151 | sv.panes = append(sv.panes, pane) | |
| 116 | 152 | ||
| 117 | - | // Connect click handler | |
| 118 | - | titleButton.ConnectClicked(func() { | |
| 119 | - | sv.mu.RLock() | |
| 120 | - | callback := sv.onActivate | |
| 121 | - | sv.mu.RUnlock() | |
| 122 | - | ||
| 123 | - | if callback != nil { | |
| 124 | - | callback(index) | |
| 125 | - | } | |
| 126 | - | }) | |
| 153 | + | // Connect click handlers using paneID (not index, to handle removals) | |
| 154 | + | sv.connectTitleBarHandlers(tb, paneID) | |
| 127 | 155 | ||
| 128 | 156 | // Add to container | |
| 129 | 157 | log.Debug(). | |
| 130 | 158 | Int("index", index). | |
| 131 | 159 | Msg("StackedView: appending titleButton and container to box") | |
| 132 | - | sv.box.Append(titleButton) | |
| 160 | + | sv.box.Append(tb.titleButton) | |
| 133 | 161 | if container != nil { | |
| 134 | 162 | sv.box.Append(container) | |
| 135 | 163 | } | |
| @@ -145,6 +173,33 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||
| 145 | 173 | return index | |
| 146 | 174 | } | |
| 147 | 175 | ||
| 176 | + | // connectTitleBarHandlers connects the click handlers for a title bar. | |
| 177 | + | // Uses paneID lookup instead of captured index to handle pane removals correctly. | |
| 178 | + | func (sv *StackedView) connectTitleBarHandlers(tb titleBarComponents, paneID string) { | |
| 179 | + | // Connect title bar click handler for activation | |
| 180 | + | tb.titleButton.ConnectClicked(func() { | |
| 181 | + | sv.mu.RLock() | |
| 182 | + | callback := sv.onActivate | |
| 183 | + | currentIndex := sv.findPaneIndexInternal(paneID) | |
| 184 | + | sv.mu.RUnlock() | |
| 185 | + | ||
| 186 | + | if callback != nil && currentIndex >= 0 { | |
| 187 | + | callback(currentIndex) | |
| 188 | + | } | |
| 189 | + | }) | |
| 190 | + | ||
| 191 | + | // Connect close button click handler | |
| 192 | + | tb.closeBtn.ConnectClicked(func() { | |
| 193 | + | sv.mu.RLock() | |
| 194 | + | onClose := sv.onClosePane | |
| 195 | + | sv.mu.RUnlock() | |
| 196 | + | ||
| 197 | + | if onClose != nil { | |
| 198 | + | onClose(paneID) | |
| 199 | + | } | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | ||
| 148 | 203 | // InsertPaneAfter inserts a new pane after the specified index position. | |
| 149 | 204 | // Use afterIndex=-1 to insert at the beginning. | |
| 150 | 205 | // The new pane becomes active (visible). | |
| @@ -172,44 +227,16 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 172 | 227 | Int("current_count", len(sv.panes)). | |
| 173 | 228 | Msg("StackedView.InsertPaneAfter called") | |
| 174 | 229 | ||
| 175 | - | // Create title bar - must not expand vertically | |
| 176 | - | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | |
| 177 | - | titleBar.AddCssClass("stacked-pane-titlebar") | |
| 178 | - | titleBar.SetVexpand(false) | |
| 179 | - | ||
| 180 | - | // Create favicon image | |
| 181 | - | favicon := sv.factory.NewImage() | |
| 182 | - | if faviconIconName != "" { | |
| 183 | - | favicon.SetFromIconName(faviconIconName) | |
| 184 | - | } else { | |
| 185 | - | favicon.SetFromIconName("web-browser-symbolic") | |
| 186 | - | } | |
| 187 | - | favicon.SetPixelSize(16) | |
| 188 | - | titleBar.Append(favicon) | |
| 189 | - | ||
| 190 | - | // Create title label | |
| 191 | - | label := sv.factory.NewLabel(title) | |
| 192 | - | label.SetEllipsize(EllipsizeEnd) | |
| 193 | - | label.SetMaxWidthChars(stackedTitleMaxWidthChars) | |
| 194 | - | label.SetHexpand(true) | |
| 195 | - | label.SetXalign(0.0) | |
| 196 | - | titleBar.Append(label) | |
| 197 | - | ||
| 198 | - | // Make title bar clickable - ensure it doesn't expand vertically | |
| 199 | - | titleButton := sv.factory.NewButton() | |
| 200 | - | titleButton.SetChild(titleBar) | |
| 201 | - | titleButton.AddCssClass("stacked-pane-title-button") | |
| 202 | - | titleButton.SetFocusOnClick(false) | |
| 203 | - | titleButton.SetVexpand(false) | |
| 204 | - | titleButton.SetHexpand(true) | |
| 230 | + | // Create title bar components | |
| 231 | + | tb := sv.createTitleBar(title, faviconIconName) | |
| 205 | 232 | ||
| 206 | 233 | pane := &stackedPane{ | |
| 207 | 234 | paneID: paneID, | |
| 208 | - | titleBar: titleBar, | |
| 235 | + | titleBar: tb.titleBar, | |
| 209 | 236 | container: container, | |
| 210 | 237 | title: title, | |
| 211 | - | favicon: favicon, | |
| 212 | - | label: label, | |
| 238 | + | favicon: tb.favicon, | |
| 239 | + | label: tb.label, | |
| 213 | 240 | isActive: false, | |
| 214 | 241 | } | |
| 215 | 242 | ||
| @@ -218,19 +245,25 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 218 | 245 | copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:]) | |
| 219 | 246 | sv.panes[insertIndex] = pane | |
| 220 | 247 | ||
| 221 | - | // Connect click handler - use captured index | |
| 222 | - | capturedIndex := insertIndex | |
| 223 | - | titleButton.ConnectClicked(func() { | |
| 224 | - | sv.mu.RLock() | |
| 225 | - | callback := sv.onActivate | |
| 226 | - | sv.mu.RUnlock() | |
| 227 | - | ||
| 228 | - | if callback != nil { | |
| 229 | - | callback(capturedIndex) | |
| 230 | - | } | |
| 231 | - | }) | |
| 248 | + | // Connect click handlers using paneID (not index, to handle removals) | |
| 249 | + | sv.connectTitleBarHandlers(tb, paneID) | |
| 232 | 250 | ||
| 233 | 251 | // Insert widgets at correct position in GTK box | |
| 252 | + | sv.insertTitleBarWidgets(tb.titleButton, container, insertIndex) | |
| 253 | + | ||
| 254 | + | // Set this pane as active | |
| 255 | + | sv.setActiveInternal(ctx, insertIndex) | |
| 256 | + | ||
| 257 | + | log.Debug(). | |
| 258 | + | Int("insert_index", insertIndex). | |
| 259 | + | Int("new_count", len(sv.panes)). | |
| 260 | + | Msg("StackedView.InsertPaneAfter completed") | |
| 261 | + | ||
| 262 | + | return insertIndex | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // insertTitleBarWidgets inserts the title bar button and container at the correct position. | |
| 266 | + | func (sv *StackedView) insertTitleBarWidgets(titleButton ButtonWidget, container Widget, insertIndex int) { | |
| 234 | 267 | if insertIndex > 0 && sv.panes[insertIndex-1] != nil { | |
| 235 | 268 | // Insert after the previous pane's container | |
| 236 | 269 | prevPane := sv.panes[insertIndex-1] | |
| @@ -262,16 +295,6 @@ func (sv *StackedView) InsertPaneAfter( | |||
| 262 | 295 | } | |
| 263 | 296 | sv.box.Prepend(titleButton) | |
| 264 | 297 | } | |
| 265 | - | ||
| 266 | - | // Set this pane as active | |
| 267 | - | sv.setActiveInternal(ctx, insertIndex) | |
| 268 | - | ||
| 269 | - | log.Debug(). | |
| 270 | - | Int("insert_index", insertIndex). | |
| 271 | - | Int("new_count", len(sv.panes)). | |
| 272 | - | Msg("StackedView.InsertPaneAfter completed") | |
| 273 | - | ||
| 274 | - | return insertIndex | |
| 275 | 298 | } | |
| 276 | 299 | ||
| 277 | 300 | // RemovePane removes a pane from the stack by index. | |
| @@ -429,6 +452,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int { | |||
| 429 | 452 | sv.mu.RLock() | |
| 430 | 453 | defer sv.mu.RUnlock() | |
| 431 | 454 | ||
| 455 | + | return sv.findPaneIndexInternal(paneID) | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // findPaneIndexInternal returns the index of a pane by ID. | |
| 459 | + | // Caller must hold at least a read lock on sv.mu. | |
| 460 | + | func (sv *StackedView) findPaneIndexInternal(paneID string) int { | |
| 432 | 461 | for i, pane := range sv.panes { | |
| 433 | 462 | if pane.paneID == paneID { | |
| 434 | 463 | return i | |
| @@ -445,6 +474,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) { | |||
| 445 | 474 | sv.onActivate = fn | |
| 446 | 475 | } | |
| 447 | 476 | ||
| 477 | + | // SetOnClosePane sets the callback for when a pane's close button is clicked. | |
| 478 | + | func (sv *StackedView) SetOnClosePane(fn func(paneID string)) { | |
| 479 | + | sv.mu.Lock() | |
| 480 | + | defer sv.mu.Unlock() | |
| 481 | + | ||
| 482 | + | sv.onClosePane = fn | |
| 483 | + | } | |
| 484 | + | ||
| 448 | 485 | // UpdateTitle updates the title of a pane at the given index. | |
| 449 | 486 | func (sv *StackedView) UpdateTitle(index int, title string) error { | |
| 450 | 487 | sv.mu.Lock() | |
| @@ -17,7 +17,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds") | |||||
| 17 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | 17 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | ||
| 18 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | 18 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | ||
| 19 | 19 | ||||
| 20 | - | const stackedTitleMaxWidthChars = 30 | 20 | + | const ( |
| 21 | + | stackedTitleMaxWidthChars = 30 | |||
| 22 | + | stackedPaneCloseIcon = "window-close-symbolic" | |||
| 23 | + | ) | |||
| 21 | 24 | ||||
| 22 | // stackedPane represents a single pane within a stacked container. | 25 | // stackedPane represents a single pane within a stacked container. | ||
| 23 | type stackedPane struct { | 26 | type stackedPane struct { | ||
| @@ -38,7 +41,8 @@ type StackedView struct { | |||||
| 38 | panes []*stackedPane | 41 | panes []*stackedPane | ||
| 39 | activeIndex int | 42 | activeIndex int | ||
| 40 | 43 | ||||
| 41 | - | onActivate func(index int) // called when a pane is activated via title bar click | 44 | + | onActivate func(index int) // called when a pane is activated via title bar click |
| 45 | + | onClosePane func(paneID string) // called when a pane's close button is clicked | |||
| 42 | 46 | ||||
| 43 | mu sync.RWMutex | 47 | mu sync.RWMutex | ||
| 44 | } | 48 | } | ||
| @@ -57,20 +61,18 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||||
| 57 | } | 61 | } | ||
| 58 | } | 62 | } | ||
| 59 | 63 | ||||
| 60 | - | // AddPane adds a new pane to the stack. | 64 | + | // titleBarComponents holds the widgets created for a pane's title bar. |
| 61 | - | // The new pane becomes active (visible). | 65 | + | type titleBarComponents struct { |
| 62 | - | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | 66 | + | titleBar BoxWidget |
| 63 | - | log := logging.FromContext(ctx) | 67 | + | titleButton ButtonWidget |
| 64 | - | sv.mu.Lock() | 68 | + | closeBtn ButtonWidget |
| 65 | - | defer sv.mu.Unlock() | 69 | + | favicon ImageWidget |
| 66 | - | 70 | + | label LabelWidget | |
| 67 | - | log.Debug(). | 71 | + | } |
| 68 | - | Str("title", title). | |||
| 69 | - | Bool("container_nil", container == nil). | |||
| 70 | - | Int("current_count", len(sv.panes)). | |||
| 71 | - | Msg("StackedView.AddPane called") | |||
| 72 | 72 | ||||
| 73 | - | // Create title bar - must not expand vertically | 73 | + | // createTitleBar creates the title bar widgets for a pane. |
| 74 | + | func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents { | |||
| 75 | + | // Create title bar container - must not expand vertically | |||
| 74 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | 76 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | ||
| 75 | titleBar.AddCssClass("stacked-pane-titlebar") | 77 | titleBar.AddCssClass("stacked-pane-titlebar") | ||
| 76 | titleBar.SetVexpand(false) | 78 | titleBar.SetVexpand(false) | ||
| @@ -93,6 +95,15 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||||
| 93 | label.SetXalign(0.0) | 95 | label.SetXalign(0.0) | ||
| 94 | titleBar.Append(label) | 96 | titleBar.Append(label) | ||
| 95 | 97 | ||||
| 98 | + | // Create close button using GTK's native icon button support | |||
| 99 | + | closeBtn := sv.factory.NewButton() | |||
| 100 | + | closeBtn.SetIconName(stackedPaneCloseIcon) | |||
| 101 | + | closeBtn.AddCssClass("stacked-pane-close-button") | |||
| 102 | + | closeBtn.SetFocusOnClick(false) | |||
| 103 | + | closeBtn.SetVexpand(false) | |||
| 104 | + | closeBtn.SetHexpand(false) | |||
| 105 | + | titleBar.Append(closeBtn) | |||
| 106 | + | ||||
| 96 | // Make title bar clickable - ensure it doesn't expand vertically | 107 | // Make title bar clickable - ensure it doesn't expand vertically | ||
| 97 | titleButton := sv.factory.NewButton() | 108 | titleButton := sv.factory.NewButton() | ||
| 98 | titleButton.SetChild(titleBar) | 109 | titleButton.SetChild(titleBar) | ||
| @@ -101,35 +112,52 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||||
| 101 | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | 112 | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | ||
| 102 | titleButton.SetHexpand(true) // But fill horizontal space | 113 | titleButton.SetHexpand(true) // But fill horizontal space | ||
| 103 | 114 | ||||
| 115 | + | return titleBarComponents{ | |||
| 116 | + | titleBar: titleBar, | |||
| 117 | + | titleButton: titleButton, | |||
| 118 | + | closeBtn: closeBtn, | |||
| 119 | + | favicon: favicon, | |||
| 120 | + | label: label, | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // AddPane adds a new pane to the stack. | |||
| 125 | + | // The new pane becomes active (visible). | |||
| 126 | + | func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int { | |||
| 127 | + | log := logging.FromContext(ctx) | |||
| 128 | + | sv.mu.Lock() | |||
| 129 | + | defer sv.mu.Unlock() | |||
| 130 | + | ||||
| 131 | + | log.Debug(). | |||
| 132 | + | Str("title", title). | |||
| 133 | + | Bool("container_nil", container == nil). | |||
| 134 | + | Int("current_count", len(sv.panes)). | |||
| 135 | + | Msg("StackedView.AddPane called") | |||
| 136 | + | ||||
| 137 | + | // Create title bar components | |||
| 138 | + | tb := sv.createTitleBar(title, faviconIconName) | |||
| 139 | + | ||||
| 104 | pane := &stackedPane{ | 140 | pane := &stackedPane{ | ||
| 105 | paneID: paneID, | 141 | paneID: paneID, | ||
| 106 | - | titleBar: titleBar, | 142 | + | titleBar: tb.titleBar, |
| 107 | container: container, | 143 | container: container, | ||
| 108 | title: title, | 144 | title: title, | ||
| 109 | - | favicon: favicon, | 145 | + | favicon: tb.favicon, |
| 110 | - | label: label, | 146 | + | label: tb.label, |
| 111 | isActive: false, | 147 | isActive: false, | ||
| 112 | } | 148 | } | ||
| 113 | 149 | ||||
| 114 | index := len(sv.panes) | 150 | index := len(sv.panes) | ||
| 115 | sv.panes = append(sv.panes, pane) | 151 | sv.panes = append(sv.panes, pane) | ||
| 116 | 152 | ||||
| 117 | - | // Connect click handler | 153 | + | // Connect click handlers using paneID (not index, to handle removals) |
| 118 | - | titleButton.ConnectClicked(func() { | 154 | + | sv.connectTitleBarHandlers(tb, paneID) |
| 119 | - | sv.mu.RLock() | |||
| 120 | - | callback := sv.onActivate | |||
| 121 | - | sv.mu.RUnlock() | |||
| 122 | - | ||||
| 123 | - | if callback != nil { | |||
| 124 | - | callback(index) | |||
| 125 | - | } | |||
| 126 | - | }) | |||
| 127 | 155 | ||||
| 128 | // Add to container | 156 | // Add to container | ||
| 129 | log.Debug(). | 157 | log.Debug(). | ||
| 130 | Int("index", index). | 158 | Int("index", index). | ||
| 131 | Msg("StackedView: appending titleButton and container to box") | 159 | Msg("StackedView: appending titleButton and container to box") | ||
| 132 | - | sv.box.Append(titleButton) | 160 | + | sv.box.Append(tb.titleButton) |
| 133 | if container != nil { | 161 | if container != nil { | ||
| 134 | sv.box.Append(container) | 162 | sv.box.Append(container) | ||
| 135 | } | 163 | } | ||
| @@ -145,6 +173,33 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||||
| 145 | return index | 173 | return index | ||
| 146 | } | 174 | } | ||
| 147 | 175 | ||||
| 176 | + | // connectTitleBarHandlers connects the click handlers for a title bar. | |||
| 177 | + | // Uses paneID lookup instead of captured index to handle pane removals correctly. | |||
| 178 | + | func (sv *StackedView) connectTitleBarHandlers(tb titleBarComponents, paneID string) { | |||
| 179 | + | // Connect title bar click handler for activation | |||
| 180 | + | tb.titleButton.ConnectClicked(func() { | |||
| 181 | + | sv.mu.RLock() | |||
| 182 | + | callback := sv.onActivate | |||
| 183 | + | currentIndex := sv.findPaneIndexInternal(paneID) | |||
| 184 | + | sv.mu.RUnlock() | |||
| 185 | + | ||||
| 186 | + | if callback != nil && currentIndex >= 0 { | |||
| 187 | + | callback(currentIndex) | |||
| 188 | + | } | |||
| 189 | + | }) | |||
| 190 | + | ||||
| 191 | + | // Connect close button click handler | |||
| 192 | + | tb.closeBtn.ConnectClicked(func() { | |||
| 193 | + | sv.mu.RLock() | |||
| 194 | + | onClose := sv.onClosePane | |||
| 195 | + | sv.mu.RUnlock() | |||
| 196 | + | ||||
| 197 | + | if onClose != nil { | |||
| 198 | + | onClose(paneID) | |||
| 199 | + | } | |||
| 200 | + | }) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 148 | // InsertPaneAfter inserts a new pane after the specified index position. | 203 | // InsertPaneAfter inserts a new pane after the specified index position. | ||
| 149 | // Use afterIndex=-1 to insert at the beginning. | 204 | // Use afterIndex=-1 to insert at the beginning. | ||
| 150 | // The new pane becomes active (visible). | 205 | // The new pane becomes active (visible). | ||
| @@ -172,44 +227,16 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 172 | Int("current_count", len(sv.panes)). | 227 | Int("current_count", len(sv.panes)). | ||
| 173 | Msg("StackedView.InsertPaneAfter called") | 228 | Msg("StackedView.InsertPaneAfter called") | ||
| 174 | 229 | ||||
| 175 | - | // Create title bar - must not expand vertically | 230 | + | // Create title bar components |
| 176 | - | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | 231 | + | tb := sv.createTitleBar(title, faviconIconName) |
| 177 | - | titleBar.AddCssClass("stacked-pane-titlebar") | |||
| 178 | - | titleBar.SetVexpand(false) | |||
| 179 | - | ||||
| 180 | - | // Create favicon image | |||
| 181 | - | favicon := sv.factory.NewImage() | |||
| 182 | - | if faviconIconName != "" { | |||
| 183 | - | favicon.SetFromIconName(faviconIconName) | |||
| 184 | - | } else { | |||
| 185 | - | favicon.SetFromIconName("web-browser-symbolic") | |||
| 186 | - | } | |||
| 187 | - | favicon.SetPixelSize(16) | |||
| 188 | - | titleBar.Append(favicon) | |||
| 189 | - | ||||
| 190 | - | // Create title label | |||
| 191 | - | label := sv.factory.NewLabel(title) | |||
| 192 | - | label.SetEllipsize(EllipsizeEnd) | |||
| 193 | - | label.SetMaxWidthChars(stackedTitleMaxWidthChars) | |||
| 194 | - | label.SetHexpand(true) | |||
| 195 | - | label.SetXalign(0.0) | |||
| 196 | - | titleBar.Append(label) | |||
| 197 | - | ||||
| 198 | - | // Make title bar clickable - ensure it doesn't expand vertically | |||
| 199 | - | titleButton := sv.factory.NewButton() | |||
| 200 | - | titleButton.SetChild(titleBar) | |||
| 201 | - | titleButton.AddCssClass("stacked-pane-title-button") | |||
| 202 | - | titleButton.SetFocusOnClick(false) | |||
| 203 | - | titleButton.SetVexpand(false) | |||
| 204 | - | titleButton.SetHexpand(true) | |||
| 205 | 232 | ||||
| 206 | pane := &stackedPane{ | 233 | pane := &stackedPane{ | ||
| 207 | paneID: paneID, | 234 | paneID: paneID, | ||
| 208 | - | titleBar: titleBar, | 235 | + | titleBar: tb.titleBar, |
| 209 | container: container, | 236 | container: container, | ||
| 210 | title: title, | 237 | title: title, | ||
| 211 | - | favicon: favicon, | 238 | + | favicon: tb.favicon, |
| 212 | - | label: label, | 239 | + | label: tb.label, |
| 213 | isActive: false, | 240 | isActive: false, | ||
| 214 | } | 241 | } | ||
| 215 | 242 | ||||
| @@ -218,19 +245,25 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 218 | copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:]) | 245 | copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:]) | ||
| 219 | sv.panes[insertIndex] = pane | 246 | sv.panes[insertIndex] = pane | ||
| 220 | 247 | ||||
| 221 | - | // Connect click handler - use captured index | 248 | + | // Connect click handlers using paneID (not index, to handle removals) |
| 222 | - | capturedIndex := insertIndex | 249 | + | sv.connectTitleBarHandlers(tb, paneID) |
| 223 | - | titleButton.ConnectClicked(func() { | |||
| 224 | - | sv.mu.RLock() | |||
| 225 | - | callback := sv.onActivate | |||
| 226 | - | sv.mu.RUnlock() | |||
| 227 | - | ||||
| 228 | - | if callback != nil { | |||
| 229 | - | callback(capturedIndex) | |||
| 230 | - | } | |||
| 231 | - | }) | |||
| 232 | 250 | ||||
| 233 | // Insert widgets at correct position in GTK box | 251 | // Insert widgets at correct position in GTK box | ||
| 252 | + | sv.insertTitleBarWidgets(tb.titleButton, container, insertIndex) | |||
| 253 | + | ||||
| 254 | + | // Set this pane as active | |||
| 255 | + | sv.setActiveInternal(ctx, insertIndex) | |||
| 256 | + | ||||
| 257 | + | log.Debug(). | |||
| 258 | + | Int("insert_index", insertIndex). | |||
| 259 | + | Int("new_count", len(sv.panes)). | |||
| 260 | + | Msg("StackedView.InsertPaneAfter completed") | |||
| 261 | + | ||||
| 262 | + | return insertIndex | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // insertTitleBarWidgets inserts the title bar button and container at the correct position. | |||
| 266 | + | func (sv *StackedView) insertTitleBarWidgets(titleButton ButtonWidget, container Widget, insertIndex int) { | |||
| 234 | if insertIndex > 0 && sv.panes[insertIndex-1] != nil { | 267 | if insertIndex > 0 && sv.panes[insertIndex-1] != nil { | ||
| 235 | // Insert after the previous pane's container | 268 | // Insert after the previous pane's container | ||
| 236 | prevPane := sv.panes[insertIndex-1] | 269 | prevPane := sv.panes[insertIndex-1] | ||
| @@ -262,16 +295,6 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 262 | } | 295 | } | ||
| 263 | sv.box.Prepend(titleButton) | 296 | sv.box.Prepend(titleButton) | ||
| 264 | } | 297 | } | ||
| 265 | - | ||||
| 266 | - | // Set this pane as active | |||
| 267 | - | sv.setActiveInternal(ctx, insertIndex) | |||
| 268 | - | ||||
| 269 | - | log.Debug(). | |||
| 270 | - | Int("insert_index", insertIndex). | |||
| 271 | - | Int("new_count", len(sv.panes)). | |||
| 272 | - | Msg("StackedView.InsertPaneAfter completed") | |||
| 273 | - | ||||
| 274 | - | return insertIndex | |||
| 275 | } | 298 | } | ||
| 276 | 299 | ||||
| 277 | // RemovePane removes a pane from the stack by index. | 300 | // RemovePane removes a pane from the stack by index. | ||
| @@ -429,6 +452,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int { | |||||
| 429 | sv.mu.RLock() | 452 | sv.mu.RLock() | ||
| 430 | defer sv.mu.RUnlock() | 453 | defer sv.mu.RUnlock() | ||
| 431 | 454 | ||||
| 455 | + | return sv.findPaneIndexInternal(paneID) | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // findPaneIndexInternal returns the index of a pane by ID. | |||
| 459 | + | // Caller must hold at least a read lock on sv.mu. | |||
| 460 | + | func (sv *StackedView) findPaneIndexInternal(paneID string) int { | |||
| 432 | for i, pane := range sv.panes { | 461 | for i, pane := range sv.panes { | ||
| 433 | if pane.paneID == paneID { | 462 | if pane.paneID == paneID { | ||
| 434 | return i | 463 | return i | ||
| @@ -445,6 +474,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) { | |||||
| 445 | sv.onActivate = fn | 474 | sv.onActivate = fn | ||
| 446 | } | 475 | } | ||
| 447 | 476 | ||||
| 477 | + | // SetOnClosePane sets the callback for when a pane's close button is clicked. | |||
| 478 | + | func (sv *StackedView) SetOnClosePane(fn func(paneID string)) { | |||
| 479 | + | sv.mu.Lock() | |||
| 480 | + | defer sv.mu.Unlock() | |||
| 481 | + | ||||
| 482 | + | sv.onClosePane = fn | |||
| 483 | + | } | |||
| 484 | + | ||||
| 448 | // UpdateTitle updates the title of a pane at the given index. | 485 | // UpdateTitle updates the title of a pane at the given index. | ||
| 449 | func (sv *StackedView) UpdateTitle(index int, title string) error { | 486 | func (sv *StackedView) UpdateTitle(index int, title string) error { | ||
| 450 | sv.mu.Lock() | 487 | sv.mu.Lock() | ||
M internal/ui/layout/stacked_test.go +26 -0
| @@ -32,6 +32,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||
| 32 | 32 | mockFavicon := mocks.NewMockImageWidget(t) | |
| 33 | 33 | mockLabel := mocks.NewMockLabelWidget(t) | |
| 34 | 34 | mockButton := mocks.NewMockButtonWidget(t) | |
| 35 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |
| 35 | 36 | mockContainer := mocks.NewMockWidget(t) | |
| 36 | 37 | ||
| 37 | 38 | // Title bar creation | |
| @@ -53,6 +54,15 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||
| 53 | 54 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | |
| 54 | 55 | mockTitleBar.EXPECT().Append(mockLabel).Once() | |
| 55 | 56 | ||
| 57 | + | // Close button (uses SetIconName directly instead of child image) | |
| 58 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |
| 59 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |
| 60 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |
| 61 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |
| 62 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |
| 63 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |
| 64 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |
| 65 | + | ||
| 56 | 66 | // Button wrapping title bar | |
| 57 | 67 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | |
| 58 | 68 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | |
| @@ -60,7 +70,10 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||
| 60 | 70 | mockButton.EXPECT().SetFocusOnClick(false).Once() | |
| 61 | 71 | mockButton.EXPECT().SetVexpand(false).Once() | |
| 62 | 72 | mockButton.EXPECT().SetHexpand(true).Once() | |
| 73 | + | ||
| 74 | + | // Click handlers (title bar activation and close button) | |
| 63 | 75 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | |
| 76 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |
| 64 | 77 | ||
| 65 | 78 | // Adding to main box | |
| 66 | 79 | mockBox.EXPECT().Append(mockButton).Once() | |
| @@ -559,6 +572,7 @@ func setupInsertPaneMocks( | |||
| 559 | 572 | mockFavicon := mocks.NewMockImageWidget(t) | |
| 560 | 573 | mockLabel := mocks.NewMockLabelWidget(t) | |
| 561 | 574 | mockButton := mocks.NewMockButtonWidget(t) | |
| 575 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |
| 562 | 576 | mockContainer := mocks.NewMockWidget(t) | |
| 563 | 577 | ||
| 564 | 578 | // Title bar creation | |
| @@ -580,6 +594,15 @@ func setupInsertPaneMocks( | |||
| 580 | 594 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | |
| 581 | 595 | mockTitleBar.EXPECT().Append(mockLabel).Once() | |
| 582 | 596 | ||
| 597 | + | // Close button (uses SetIconName directly instead of child image) | |
| 598 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |
| 599 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |
| 600 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |
| 601 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |
| 602 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |
| 603 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |
| 604 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |
| 605 | + | ||
| 583 | 606 | // Button wrapping title bar | |
| 584 | 607 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | |
| 585 | 608 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | |
| @@ -587,7 +610,10 @@ func setupInsertPaneMocks( | |||
| 587 | 610 | mockButton.EXPECT().SetFocusOnClick(false).Once() | |
| 588 | 611 | mockButton.EXPECT().SetVexpand(false).Once() | |
| 589 | 612 | mockButton.EXPECT().SetHexpand(true).Once() | |
| 613 | + | ||
| 614 | + | // Click handlers (title bar activation and close button) | |
| 590 | 615 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | |
| 616 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |
| 591 | 617 | ||
| 592 | 618 | // Position-aware insertion using InsertChildAfter | |
| 593 | 619 | if siblingContainer != nil { | |
| @@ -32,6 +32,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||||
| 32 | mockFavicon := mocks.NewMockImageWidget(t) | 32 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| 33 | mockLabel := mocks.NewMockLabelWidget(t) | 33 | mockLabel := mocks.NewMockLabelWidget(t) | ||
| 34 | mockButton := mocks.NewMockButtonWidget(t) | 34 | mockButton := mocks.NewMockButtonWidget(t) | ||
| 35 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |||
| 35 | mockContainer := mocks.NewMockWidget(t) | 36 | mockContainer := mocks.NewMockWidget(t) | ||
| 36 | 37 | ||||
| 37 | // Title bar creation | 38 | // Title bar creation | ||
| @@ -53,6 +54,15 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||||
| 53 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | 54 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | ||
| 54 | mockTitleBar.EXPECT().Append(mockLabel).Once() | 55 | mockTitleBar.EXPECT().Append(mockLabel).Once() | ||
| 55 | 56 | ||||
| 57 | + | // Close button (uses SetIconName directly instead of child image) | |||
| 58 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |||
| 59 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |||
| 60 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |||
| 61 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |||
| 62 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |||
| 63 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |||
| 64 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |||
| 65 | + | ||||
| 56 | // Button wrapping title bar | 66 | // Button wrapping title bar | ||
| 57 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | 67 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | ||
| 58 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | 68 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | ||
| @@ -60,7 +70,10 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||||
| 60 | mockButton.EXPECT().SetFocusOnClick(false).Once() | 70 | mockButton.EXPECT().SetFocusOnClick(false).Once() | ||
| 61 | mockButton.EXPECT().SetVexpand(false).Once() | 71 | mockButton.EXPECT().SetVexpand(false).Once() | ||
| 62 | mockButton.EXPECT().SetHexpand(true).Once() | 72 | mockButton.EXPECT().SetHexpand(true).Once() | ||
| 73 | + | ||||
| 74 | + | // Click handlers (title bar activation and close button) | |||
| 63 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | 75 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | ||
| 76 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |||
| 64 | 77 | ||||
| 65 | // Adding to main box | 78 | // Adding to main box | ||
| 66 | mockBox.EXPECT().Append(mockButton).Once() | 79 | mockBox.EXPECT().Append(mockButton).Once() | ||
| @@ -559,6 +572,7 @@ func setupInsertPaneMocks( | |||||
| 559 | mockFavicon := mocks.NewMockImageWidget(t) | 572 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| 560 | mockLabel := mocks.NewMockLabelWidget(t) | 573 | mockLabel := mocks.NewMockLabelWidget(t) | ||
| 561 | mockButton := mocks.NewMockButtonWidget(t) | 574 | mockButton := mocks.NewMockButtonWidget(t) | ||
| 575 | + | mockCloseButton := mocks.NewMockButtonWidget(t) | |||
| 562 | mockContainer := mocks.NewMockWidget(t) | 576 | mockContainer := mocks.NewMockWidget(t) | ||
| 563 | 577 | ||||
| 564 | // Title bar creation | 578 | // Title bar creation | ||
| @@ -580,6 +594,15 @@ func setupInsertPaneMocks( | |||||
| 580 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | 594 | mockLabel.EXPECT().SetXalign(float32(0.0)).Once() | ||
| 581 | mockTitleBar.EXPECT().Append(mockLabel).Once() | 595 | mockTitleBar.EXPECT().Append(mockLabel).Once() | ||
| 582 | 596 | ||||
| 597 | + | // Close button (uses SetIconName directly instead of child image) | |||
| 598 | + | mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once() | |||
| 599 | + | mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once() | |||
| 600 | + | mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once() | |||
| 601 | + | mockCloseButton.EXPECT().SetFocusOnClick(false).Once() | |||
| 602 | + | mockCloseButton.EXPECT().SetVexpand(false).Once() | |||
| 603 | + | mockCloseButton.EXPECT().SetHexpand(false).Once() | |||
| 604 | + | mockTitleBar.EXPECT().Append(mockCloseButton).Once() | |||
| 605 | + | ||||
| 583 | // Button wrapping title bar | 606 | // Button wrapping title bar | ||
| 584 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | 607 | mockFactory.EXPECT().NewButton().Return(mockButton).Once() | ||
| 585 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | 608 | mockButton.EXPECT().SetChild(mockTitleBar).Once() | ||
| @@ -587,7 +610,10 @@ func setupInsertPaneMocks( | |||||
| 587 | mockButton.EXPECT().SetFocusOnClick(false).Once() | 610 | mockButton.EXPECT().SetFocusOnClick(false).Once() | ||
| 588 | mockButton.EXPECT().SetVexpand(false).Once() | 611 | mockButton.EXPECT().SetVexpand(false).Once() | ||
| 589 | mockButton.EXPECT().SetHexpand(true).Once() | 612 | mockButton.EXPECT().SetHexpand(true).Once() | ||
| 613 | + | ||||
| 614 | + | // Click handlers (title bar activation and close button) | |||
| 590 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | 615 | mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once() | ||
| 616 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | |||
| 591 | 617 | ||||
| 592 | // Position-aware insertion using InsertChildAfter | 618 | // Position-aware insertion using InsertChildAfter | ||
| 593 | if siblingContainer != nil { | 619 | if siblingContainer != nil { | ||
M internal/ui/layout/widgets.go +1 -0
| @@ -179,6 +179,7 @@ type ButtonWidget interface { | |||
| 179 | 179 | GetLabel() string | |
| 180 | 180 | SetChild(child Widget) | |
| 181 | 181 | GetChild() Widget | |
| 182 | + | SetIconName(iconName string) | |
| 182 | 183 | ||
| 183 | 184 | // Connect click handler, returns signal ID for disconnection | |
| 184 | 185 | ConnectClicked(callback func()) uint32 | |
| @@ -179,6 +179,7 @@ type ButtonWidget interface { | |||||
| 179 | GetLabel() string | 179 | GetLabel() string | ||
| 180 | SetChild(child Widget) | 180 | SetChild(child Widget) | ||
| 181 | GetChild() Widget | 181 | GetChild() Widget | ||
| 182 | + | SetIconName(iconName string) | |||
| 182 | 183 | ||||
| 183 | // Connect click handler, returns signal ID for disconnection | 184 | // Connect click handler, returns signal ID for disconnection | ||
| 184 | ConnectClicked(callback func()) uint32 | 185 | ConnectClicked(callback func()) uint32 | ||
M internal/ui/theme/css.go +37 -0
| @@ -623,6 +623,43 @@ button.stacked-pane-title-button:focus { | |||
| 623 | 623 | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | |
| 624 | 624 | color: var(--accent); | |
| 625 | 625 | } | |
| 626 | + | ||
| 627 | + | /* Close button in stacked pane title bar */ | |
| 628 | + | .stacked-pane-close-button { | |
| 629 | + | background: transparent; | |
| 630 | + | border: none; | |
| 631 | + | border-radius: 4px; | |
| 632 | + | padding: 4px; | |
| 633 | + | margin: 0 2px; | |
| 634 | + | min-width: 20px; | |
| 635 | + | min-height: 20px; | |
| 636 | + | opacity: 0.4; | |
| 637 | + | transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out; | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | .stacked-pane-close-button > image { | |
| 641 | + | margin: 0; | |
| 642 | + | padding: 0; | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | .stacked-pane-close-button:hover { | |
| 646 | + | opacity: 1.0; | |
| 647 | + | background: alpha(var(--destructive), 0.2); | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | .stacked-pane-close-button:hover > image { | |
| 651 | + | color: var(--destructive); | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | /* Show close button more prominently when hovering title bar */ | |
| 655 | + | button.stacked-pane-title-button:hover .stacked-pane-close-button { | |
| 656 | + | opacity: 0.7; | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | .stacked-pane-close-button:focus { | |
| 660 | + | outline: none; | |
| 661 | + | box-shadow: none; | |
| 662 | + | } | |
| 626 | 663 | ` | |
| 627 | 664 | } | |
| 628 | 665 | ||
| @@ -623,6 +623,43 @@ button.stacked-pane-title-button:focus { | |||||
| 623 | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | 623 | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | ||
| 624 | color: var(--accent); | 624 | color: var(--accent); | ||
| 625 | } | 625 | } | ||
| 626 | + | ||||
| 627 | + | /* Close button in stacked pane title bar */ | |||
| 628 | + | .stacked-pane-close-button { | |||
| 629 | + | background: transparent; | |||
| 630 | + | border: none; | |||
| 631 | + | border-radius: 4px; | |||
| 632 | + | padding: 4px; | |||
| 633 | + | margin: 0 2px; | |||
| 634 | + | min-width: 20px; | |||
| 635 | + | min-height: 20px; | |||
| 636 | + | opacity: 0.4; | |||
| 637 | + | transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out; | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | .stacked-pane-close-button > image { | |||
| 641 | + | margin: 0; | |||
| 642 | + | padding: 0; | |||
| 643 | + | } | |||
| 644 | + | ||||
| 645 | + | .stacked-pane-close-button:hover { | |||
| 646 | + | opacity: 1.0; | |||
| 647 | + | background: alpha(var(--destructive), 0.2); | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | .stacked-pane-close-button:hover > image { | |||
| 651 | + | color: var(--destructive); | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | /* Show close button more prominently when hovering title bar */ | |||
| 655 | + | button.stacked-pane-title-button:hover .stacked-pane-close-button { | |||
| 656 | + | opacity: 0.7; | |||
| 657 | + | } | |||
| 658 | + | ||||
| 659 | + | .stacked-pane-close-button:focus { | |||
| 660 | + | outline: none; | |||
| 661 | + | box-shadow: none; | |||
| 662 | + | } | |||
| 626 | ` | 663 | ` | ||
| 627 | } | 664 | } | ||
| 628 | 665 | ||||