diff --git a/supacode/Features/Canvas/Models/CanvasSelectionState.swift b/supacode/Features/Canvas/Models/CanvasSelectionState.swift index 6f72faf1..3e264d8e 100644 --- a/supacode/Features/Canvas/Models/CanvasSelectionState.swift +++ b/supacode/Features/Canvas/Models/CanvasSelectionState.swift @@ -90,4 +90,57 @@ struct CanvasSelectionState: Equatable { clear() } } + + /// Prune against `currentOrder` and, if the primary was removed while cards + /// remain visible, auto-focus the nearest surviving neighbor in + /// `previousOrder` (searching forward first, then backward). This keeps a + /// card highlighted after the primary is closed so users never lose the + /// selection indicator for the "next" focused card. + mutating func pruneAutoAdvancingPrimary( + previousOrder: [TerminalTabID], + currentOrder: [TerminalTabID] + ) { + let previousPrimary = primaryTabID + let currentSet = Set(currentOrder) + prune(to: currentSet) + + guard primaryTabID == nil, + let previousPrimary, + !currentSet.contains(previousPrimary), + !currentOrder.isEmpty + else { + return + } + + let replacement = + Self.nearestSurvivor( + of: previousPrimary, + previousOrder: previousOrder, + currentSet: currentSet + ) ?? currentOrder[0] + focusSingle(replacement) + } + + private static func nearestSurvivor( + of removedID: TerminalTabID, + previousOrder: [TerminalTabID], + currentSet: Set + ) -> TerminalTabID? { + guard let removedIndex = previousOrder.firstIndex(of: removedID) else { + return nil + } + var offset = 1 + while offset < previousOrder.count { + let forward = removedIndex + offset + if forward < previousOrder.count, currentSet.contains(previousOrder[forward]) { + return previousOrder[forward] + } + let backward = removedIndex - offset + if backward >= 0, currentSet.contains(previousOrder[backward]) { + return previousOrder[backward] + } + offset += 1 + } + return nil + } } diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 98644311..ec5842f5 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -41,7 +41,7 @@ struct CanvasView: View { Color.clear .onAppear { ensureLayouts(for: allCardKeys) - pruneSelection(to: Set(allTabIDs), states: activeStates) + pruneSelection(previousOrder: [], currentOrder: allTabIDs, states: activeStates) syncBroadcastCallbacks(states: activeStates) } .onChange(of: allCardKeys) { _, newKeys in @@ -51,8 +51,8 @@ struct CanvasView: View { ensureLayouts(for: newKeys) syncBroadcastCallbacks(states: activeStates) } - .onChange(of: allTabIDs) { _, newTabIDs in - pruneSelection(to: Set(newTabIDs), states: activeStates) + .onChange(of: allTabIDs) { oldTabIDs, newTabIDs in + pruneSelection(previousOrder: oldTabIDs, currentOrder: newTabIDs, states: activeStates) } .contentShape(.rect) .accessibilityAddTraits(.isButton) @@ -556,9 +556,13 @@ struct CanvasView: View { } } - private func pruneSelection(to visibleTabIDs: Set, states: [WorktreeTerminalState]) { + private func pruneSelection( + previousOrder: [TerminalTabID], + currentOrder: [TerminalTabID], + states: [WorktreeTerminalState] + ) { let previousPrimaryTabID = selectionState.primaryTabID - selectionState.prune(to: visibleTabIDs) + selectionState.pruneAutoAdvancingPrimary(previousOrder: previousOrder, currentOrder: currentOrder) syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) syncBroadcastCallbacks(states: states) } diff --git a/supacodeTests/CanvasSelectionStateTests.swift b/supacodeTests/CanvasSelectionStateTests.swift index e7386e61..c01a7e55 100644 --- a/supacodeTests/CanvasSelectionStateTests.swift +++ b/supacodeTests/CanvasSelectionStateTests.swift @@ -127,4 +127,75 @@ struct CanvasSelectionStateTests { #expect(state.selectedTabIDs == [tab1, tab2]) #expect(state.selectionOrder == [tab1, tab2]) } + + @Test func pruneAutoAdvancingMovesPrimaryToForwardNeighborWhenClosed() { + var state = CanvasSelectionState() + state.focusSingle(tab2) + + state.pruneAutoAdvancingPrimary( + previousOrder: [tab1, tab2, tab3], + currentOrder: [tab1, tab3] + ) + + #expect(state.primaryTabID == tab3) + #expect(state.selectedTabIDs == [tab3]) + #expect(state.mode == .idle) + } + + @Test func pruneAutoAdvancingFallsBackToBackwardNeighborWhenLastClosed() { + var state = CanvasSelectionState() + state.focusSingle(tab3) + + state.pruneAutoAdvancingPrimary( + previousOrder: [tab1, tab2, tab3], + currentOrder: [tab1, tab2] + ) + + #expect(state.primaryTabID == tab2) + #expect(state.selectedTabIDs == [tab2]) + } + + @Test func pruneAutoAdvancingClearsWhenAllCardsClosed() { + var state = CanvasSelectionState() + state.focusSingle(tab1) + + state.pruneAutoAdvancingPrimary( + previousOrder: [tab1], + currentOrder: [] + ) + + #expect(state.primaryTabID == nil) + #expect(state.selectedTabIDs.isEmpty) + } + + @Test func pruneAutoAdvancingKeepsPrimaryWhenSurviving() { + var state = CanvasSelectionState() + state.focusSingle(tab2) + + state.pruneAutoAdvancingPrimary( + previousOrder: [tab1, tab2, tab3], + currentOrder: [tab2, tab3] + ) + + #expect(state.primaryTabID == tab2) + #expect(state.selectedTabIDs == [tab2]) + } + + @Test func pruneAutoAdvancingPromotesRemainingBroadcastSelection() { + var state = CanvasSelectionState() + state.toggleSelection(tab1) + state.toggleSelection(tab2) + state.toggleSelection(tab3) + + state.pruneAutoAdvancingPrimary( + previousOrder: [tab1, tab2, tab3], + currentOrder: [tab1, tab2] + ) + + // Primary (tab3) closed while broadcasting. `prune` should promote the + // newest surviving broadcast member instead of auto-advancing to a + // non-selected neighbor. + #expect(state.primaryTabID == tab2) + #expect(state.selectedTabIDs == [tab1, tab2]) + } }