native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #226 from onevcat/canvas/card-close-expand-buttons

Add hover close and expand buttons to canvas cards

authored by

Wei Wang and committed by
GitHub
03da4511 013be91e

+180 -5
+53
supacode/Features/Canvas/Models/CanvasSelectionState.swift
··· 90 90 clear() 91 91 } 92 92 } 93 + 94 + /// Prune against `currentOrder` and, if the primary was removed while cards 95 + /// remain visible, auto-focus the nearest surviving neighbor in 96 + /// `previousOrder` (searching forward first, then backward). This keeps a 97 + /// card highlighted after the primary is closed so users never lose the 98 + /// selection indicator for the "next" focused card. 99 + mutating func pruneAutoAdvancingPrimary( 100 + previousOrder: [TerminalTabID], 101 + currentOrder: [TerminalTabID] 102 + ) { 103 + let previousPrimary = primaryTabID 104 + let currentSet = Set(currentOrder) 105 + prune(to: currentSet) 106 + 107 + guard primaryTabID == nil, 108 + let previousPrimary, 109 + !currentSet.contains(previousPrimary), 110 + !currentOrder.isEmpty 111 + else { 112 + return 113 + } 114 + 115 + let replacement = 116 + Self.nearestSurvivor( 117 + of: previousPrimary, 118 + previousOrder: previousOrder, 119 + currentSet: currentSet 120 + ) ?? currentOrder[0] 121 + focusSingle(replacement) 122 + } 123 + 124 + private static func nearestSurvivor( 125 + of removedID: TerminalTabID, 126 + previousOrder: [TerminalTabID], 127 + currentSet: Set<TerminalTabID> 128 + ) -> TerminalTabID? { 129 + guard let removedIndex = previousOrder.firstIndex(of: removedID) else { 130 + return nil 131 + } 132 + var offset = 1 133 + while offset < previousOrder.count { 134 + let forward = removedIndex + offset 135 + if forward < previousOrder.count, currentSet.contains(previousOrder[forward]) { 136 + return previousOrder[forward] 137 + } 138 + let backward = removedIndex - offset 139 + if backward >= 0, currentSet.contains(previousOrder[backward]) { 140 + return previousOrder[backward] 141 + } 142 + offset += 1 143 + } 144 + return nil 145 + } 93 146 }
+40
supacode/Features/Canvas/Views/CanvasCardView.swift
··· 18 18 let onResizeEnd: () -> Void 19 19 let onSplitOperation: (TerminalSplitTreeView.Operation) -> Void 20 20 let onTitleBarTap: () -> Void 21 + let onExpand: () -> Void 22 + let onClose: () -> Void 21 23 22 24 enum CardResizeEdge { 23 25 case leading, trailing, top, bottom ··· 44 46 45 47 // Gesture-driven drag state: does NOT trigger body re-evaluation 46 48 @GestureState private var dragTranslation: CGSize = .zero 49 + @State private var isHoveringTitleBar: Bool = false 47 50 48 51 var body: some View { 49 52 VStack(spacing: 0) { ··· 114 117 .foregroundStyle(.secondary) 115 118 .lineLimit(1) 116 119 Spacer() 120 + titleBarActions 117 121 } 118 122 .padding(.horizontal, 8) 119 123 .frame(height: titleBarHeight) ··· 121 125 .background(titleBarBackground) 122 126 .accessibilityAddTraits(.isButton) 123 127 .onTapGesture { onTitleBarTap() } 128 + .onHover { hovering in 129 + isHoveringTitleBar = hovering 130 + } 124 131 .gesture( 125 132 DragGesture(coordinateSpace: .global) 126 133 .updating($dragTranslation) { value, state, _ in ··· 134 141 )) 135 142 } 136 143 ) 144 + } 145 + 146 + private var titleBarActions: some View { 147 + HStack(spacing: 2) { 148 + Button { 149 + onExpand() 150 + } label: { 151 + Image(systemName: "arrow.up.left.and.arrow.down.right") 152 + .font(.caption2.weight(.semibold)) 153 + .frame(width: 18, height: 18) 154 + .contentShape(.rect) 155 + } 156 + .buttonStyle(.plain) 157 + .foregroundStyle(.secondary) 158 + .help("Expand to tab view") 159 + .accessibilityLabel("Expand card") 160 + 161 + Button { 162 + onClose() 163 + } label: { 164 + Image(systemName: "xmark") 165 + .font(.caption2.weight(.semibold)) 166 + .frame(width: 18, height: 18) 167 + .contentShape(.rect) 168 + } 169 + .buttonStyle(.plain) 170 + .foregroundStyle(.secondary) 171 + .help("Close card") 172 + .accessibilityLabel("Close card") 173 + } 174 + .opacity(isHoveringTitleBar ? 1 : 0) 175 + .allowsHitTesting(isHoveringTitleBar) 176 + .animation(.easeInOut(duration: 0.15), value: isHoveringTitleBar) 137 177 } 138 178 139 179 @ViewBuilder
+16 -5
supacode/Features/Canvas/Views/CanvasView.swift
··· 41 41 Color.clear 42 42 .onAppear { 43 43 ensureLayouts(for: allCardKeys) 44 - pruneSelection(to: Set(allTabIDs), states: activeStates) 44 + pruneSelection(previousOrder: [], currentOrder: allTabIDs, states: activeStates) 45 45 syncBroadcastCallbacks(states: activeStates) 46 46 } 47 47 .onChange(of: allCardKeys) { _, newKeys in ··· 51 51 ensureLayouts(for: newKeys) 52 52 syncBroadcastCallbacks(states: activeStates) 53 53 } 54 - .onChange(of: allTabIDs) { _, newTabIDs in 55 - pruneSelection(to: Set(newTabIDs), states: activeStates) 54 + .onChange(of: allTabIDs) { oldTabIDs, newTabIDs in 55 + pruneSelection(previousOrder: oldTabIDs, currentOrder: newTabIDs, states: activeStates) 56 56 } 57 57 .contentShape(.rect) 58 58 .accessibilityAddTraits(.isButton) ··· 122 122 onExitToTab() 123 123 } 124 124 lastTitleBarTapDate = now 125 + }, 126 + onExpand: { 127 + focusSingleCard(tab.id, surfaceState: state, states: activeStates) 128 + onExitToTab() 129 + }, 130 + onClose: { 131 + state.closeTab(tab.id) 125 132 } 126 133 ) 127 134 .scaleEffect(canvasScale, anchor: .center) ··· 549 556 } 550 557 } 551 558 552 - private func pruneSelection(to visibleTabIDs: Set<TerminalTabID>, states: [WorktreeTerminalState]) { 559 + private func pruneSelection( 560 + previousOrder: [TerminalTabID], 561 + currentOrder: [TerminalTabID], 562 + states: [WorktreeTerminalState] 563 + ) { 553 564 let previousPrimaryTabID = selectionState.primaryTabID 554 - selectionState.prune(to: visibleTabIDs) 565 + selectionState.pruneAutoAdvancingPrimary(previousOrder: previousOrder, currentOrder: currentOrder) 555 566 syncPrimaryFocus(from: previousPrimaryTabID, to: selectionState.primaryTabID, states: states) 556 567 syncBroadcastCallbacks(states: states) 557 568 }
+71
supacodeTests/CanvasSelectionStateTests.swift
··· 127 127 #expect(state.selectedTabIDs == [tab1, tab2]) 128 128 #expect(state.selectionOrder == [tab1, tab2]) 129 129 } 130 + 131 + @Test func pruneAutoAdvancingMovesPrimaryToForwardNeighborWhenClosed() { 132 + var state = CanvasSelectionState() 133 + state.focusSingle(tab2) 134 + 135 + state.pruneAutoAdvancingPrimary( 136 + previousOrder: [tab1, tab2, tab3], 137 + currentOrder: [tab1, tab3] 138 + ) 139 + 140 + #expect(state.primaryTabID == tab3) 141 + #expect(state.selectedTabIDs == [tab3]) 142 + #expect(state.mode == .idle) 143 + } 144 + 145 + @Test func pruneAutoAdvancingFallsBackToBackwardNeighborWhenLastClosed() { 146 + var state = CanvasSelectionState() 147 + state.focusSingle(tab3) 148 + 149 + state.pruneAutoAdvancingPrimary( 150 + previousOrder: [tab1, tab2, tab3], 151 + currentOrder: [tab1, tab2] 152 + ) 153 + 154 + #expect(state.primaryTabID == tab2) 155 + #expect(state.selectedTabIDs == [tab2]) 156 + } 157 + 158 + @Test func pruneAutoAdvancingClearsWhenAllCardsClosed() { 159 + var state = CanvasSelectionState() 160 + state.focusSingle(tab1) 161 + 162 + state.pruneAutoAdvancingPrimary( 163 + previousOrder: [tab1], 164 + currentOrder: [] 165 + ) 166 + 167 + #expect(state.primaryTabID == nil) 168 + #expect(state.selectedTabIDs.isEmpty) 169 + } 170 + 171 + @Test func pruneAutoAdvancingKeepsPrimaryWhenSurviving() { 172 + var state = CanvasSelectionState() 173 + state.focusSingle(tab2) 174 + 175 + state.pruneAutoAdvancingPrimary( 176 + previousOrder: [tab1, tab2, tab3], 177 + currentOrder: [tab2, tab3] 178 + ) 179 + 180 + #expect(state.primaryTabID == tab2) 181 + #expect(state.selectedTabIDs == [tab2]) 182 + } 183 + 184 + @Test func pruneAutoAdvancingPromotesRemainingBroadcastSelection() { 185 + var state = CanvasSelectionState() 186 + state.toggleSelection(tab1) 187 + state.toggleSelection(tab2) 188 + state.toggleSelection(tab3) 189 + 190 + state.pruneAutoAdvancingPrimary( 191 + previousOrder: [tab1, tab2, tab3], 192 + currentOrder: [tab1, tab2] 193 + ) 194 + 195 + // Primary (tab3) closed while broadcasting. `prune` should promote the 196 + // newest surviving broadcast member instead of auto-advancing to a 197 + // non-selected neighbor. 198 + #expect(state.primaryTabID == tab2) 199 + #expect(state.selectedTabIDs == [tab1, tab2]) 200 + } 130 201 }