diff --git a/docs/components/canvas.md b/docs/components/canvas.md index e36da24f..6b472623 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -32,6 +32,8 @@ Canvas lets you **type a command once and send it to many agents at once**: Selection controls: - `⌘`-click a card body → toggle it in/out of the selection. +- `⌘`-click a terminal link → open it. Recognized links take priority over card + multi-selection, including when `⌘` is held across repeated clicks. - **Click (without `⌘`)** an already-selected card → make it the **primary** (the one you type into) without deselecting the rest. (`⌘`-clicking a selected card instead *removes* it from the selection.) diff --git a/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift new file mode 100644 index 00000000..21eb6575 --- /dev/null +++ b/supacode/Features/Canvas/Models/CanvasInteractionPolicy.swift @@ -0,0 +1,23 @@ +enum CanvasInteractionPolicy { + static func linkActivationRequested( + hasHoveredLink: Bool, + isCommandModifierActive: Bool + ) -> Bool { + hasHoveredLink && isCommandModifierActive + } + + static func showsSelectionShield( + commandSelectionActive: Bool, + selectionModeActive: Bool, + broadcastFollower: Bool, + linkActivationRequested: Bool + ) -> Bool { + if linkActivationRequested { + return false + } + if commandSelectionActive || selectionModeActive { + return true + } + return broadcastFollower + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index a11c31cb..815396f0 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -256,11 +256,22 @@ struct CanvasView: View { .onDisappear { deactivateCanvas() } } - func showsSelectionShield(for tabID: TerminalTabID) -> Bool { - if commandKeyObserver.isPressed { return true } - if selectionState.isSelecting { return true } - if selectionState.isBroadcasting, selectionState.primaryTabID != tabID { return true } - return false + func showsSelectionShield( + for tabID: TerminalTabID, + in tree: SplitTree + ) -> Bool { + let hasHoveredLink = tree.leaves().contains { + $0.bridge.state.mouseOverLink?.isEmpty == false + } + return CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: commandKeyObserver.isPressed, + selectionModeActive: selectionState.isSelecting, + broadcastFollower: selectionState.isBroadcasting && selectionState.primaryTabID != tabID, + linkActivationRequested: CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: hasHoveredLink, + isCommandModifierActive: NSEvent.modifierFlags.contains(.command) + ) + ) } // MARK: - Cards Layer @@ -358,7 +369,7 @@ struct CanvasView: View { isExpanded: isCardExpanded, expandHelp: expandHelp, canvasScale: isCardExpanded ? 1 : canvasScale, - showsSelectionShield: showsSelectionShield(for: tab.id), + showsSelectionShield: showsSelectionShield(for: tab.id, in: tree), onTap: { let cmdHeld = NSEvent.modifierFlags.contains(.command) if cmdHeld { diff --git a/supacodeTests/CanvasInteractionPolicyTests.swift b/supacodeTests/CanvasInteractionPolicyTests.swift new file mode 100644 index 00000000..ae6c3051 --- /dev/null +++ b/supacodeTests/CanvasInteractionPolicyTests.swift @@ -0,0 +1,77 @@ +import Testing + +@testable import supacode + +struct CanvasInteractionPolicyTests { + @Test func hoveredLinkTakesPriorityOverCommandSelection() { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: true, + isCommandModifierActive: true + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: true, + broadcastFollower: true, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested) + #expect(showsShield == false) + } + + @Test func commandSelectionStillShieldsNonLinkContent() { + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: true, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: false + ) + + #expect(showsShield) + } + + @Test func selectionModeStillShieldsNonLinkContent() { + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: true, + broadcastFollower: false, + linkActivationRequested: false + ) + + #expect(showsShield) + } + + @Test func broadcastingOnlyShieldsFollowerCards() { + let primaryShowsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: false, + broadcastFollower: false, + linkActivationRequested: false + ) + let followerShowsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: false, + broadcastFollower: true, + linkActivationRequested: false + ) + + #expect(primaryShowsShield == false) + #expect(followerShowsShield) + } + + @Test func hoveredLinkWithoutCommandStillUsesSelectionPolicy() { + let linkActivationRequested = CanvasInteractionPolicy.linkActivationRequested( + hasHoveredLink: true, + isCommandModifierActive: false + ) + let showsShield = CanvasInteractionPolicy.showsSelectionShield( + commandSelectionActive: false, + selectionModeActive: true, + broadcastFollower: false, + linkActivationRequested: linkActivationRequested + ) + + #expect(linkActivationRequested == false) + #expect(showsShield) + } +}