native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #238 from onevcat/feat/canvas-mouse-zoom-pan

feat(canvas): Cmd+wheel zoom and middle-click pan

authored by

Wei Wang and committed by
GitHub
6b338e87 a4c04ee2

+350 -3
+232 -3
supacode/Features/Canvas/Views/CanvasView.swift
··· 18 18 @State private var activeResize: [TerminalTabID: ActiveResize] = [:] 19 19 @State private var hasPerformedInitialFit = false 20 20 @State private var viewportSize: CGSize = .zero 21 + @State private var showsCanvasHelp = false 21 22 22 23 private let minCardWidth: CGFloat = 300 23 24 private let minCardHeight: CGFloat = 200 ··· 25 26 private let maxCardHeight: CGFloat = 1600 26 27 private let titleBarHeight: CGFloat = 28 27 28 private let cardSpacing: CGFloat = 20 29 + /// Reserved height at the bottom of the viewport for the help button and 30 + /// layout toolbar so cards don't sit underneath them after auto-fit. 31 + /// Cards end up shifted upward by half of this amount. 32 + private let bottomToolbarReserve: CGFloat = 50 28 33 29 34 var body: some View { 30 35 let selectAllCanvasShortcut = AppShortcuts.resolvedShortcut( 31 36 for: AppShortcuts.CommandID.selectAllCanvasCards, 32 37 in: resolvedKeybindings 33 38 ) 34 - CanvasScrollContainer(offset: $canvasOffset, lastOffset: $lastCanvasOffset) { 39 + CanvasScrollContainer( 40 + offset: $canvasOffset, 41 + lastOffset: $lastCanvasOffset, 42 + scale: $canvasScale, 43 + lastScale: $lastCanvasScale 44 + ) { 35 45 GeometryReader { _ in 36 46 let activeStates = terminalManager.activeWorktreeStates 37 47 let allCardKeys = collectCardKeys(from: activeStates) ··· 159 169 } 160 170 .overlay(alignment: .bottomTrailing) { 161 171 canvasToolbar 172 + } 173 + .overlay(alignment: .bottomLeading) { 174 + canvasHelpButton 162 175 } 163 176 .onKeyPress(.escape) { 164 177 guard selectionState.isBroadcasting else { return .ignored } ··· 389 402 390 403 guard minX.isFinite else { return } 391 404 392 - let padding: CGFloat = 40 405 + let padding: CGFloat = 30 393 406 let bboxW = maxX - minX + padding * 2 394 407 let bboxH = maxY - minY + padding * 2 395 408 let bboxCenterX = (minX + maxX) / 2 ··· 399 412 400 413 canvasOffset = CGSize( 401 414 width: canvasSize.width / 2 - bboxCenterX * newScale, 402 - height: canvasSize.height / 2 - bboxCenterY * newScale 415 + height: (canvasSize.height - bottomToolbarReserve) / 2 - bboxCenterY * newScale 403 416 ) 404 417 canvasScale = newScale 405 418 lastCanvasScale = newScale ··· 418 431 layoutStore.cardLayouts = layouts 419 432 } 420 433 434 + private var canvasHelpButton: some View { 435 + Button { 436 + showsCanvasHelp.toggle() 437 + } label: { 438 + Image(systemName: "questionmark.circle") 439 + .font(.body) 440 + .accessibilityLabel("Canvas navigation help") 441 + } 442 + .buttonStyle(.bordered) 443 + .help("Canvas navigation help") 444 + .popover(isPresented: $showsCanvasHelp, arrowEdge: .bottom) { 445 + canvasHelpContent 446 + } 447 + .padding() 448 + } 449 + 450 + private var canvasHelpContent: some View { 451 + VStack(alignment: .leading, spacing: 14) { 452 + Text("Canvas Navigation") 453 + .font(.headline) 454 + 455 + VStack(alignment: .leading, spacing: 12) { 456 + canvasHelpRow( 457 + icon: "plus.magnifyingglass", 458 + title: "Zoom in/out", 459 + detail: "⌘ + scroll, or pinch gesture" 460 + ) 461 + canvasHelpRow( 462 + icon: "hand.draw", 463 + title: "Pan canvas", 464 + detail: "Drag empty area, middle-click drag, or two-finger swipe" 465 + ) 466 + } 467 + } 468 + .padding() 469 + .frame(width: 320, alignment: .leading) 470 + } 471 + 472 + private func canvasHelpRow(icon: String, title: String, detail: String) -> some View { 473 + HStack(alignment: .firstTextBaseline, spacing: 10) { 474 + Image(systemName: icon) 475 + .foregroundStyle(.secondary) 476 + .frame(width: 18) 477 + .accessibilityHidden(true) 478 + VStack(alignment: .leading, spacing: 2) { 479 + Text(title).font(.callout).fontWeight(.medium) 480 + Text(detail) 481 + .font(.caption) 482 + .foregroundStyle(.secondary) 483 + .fixedSize(horizontal: false, vertical: true) 484 + } 485 + } 486 + } 487 + 421 488 private var canvasToolbar: some View { 422 489 HStack(spacing: 8) { 423 490 if selectionState.isBroadcasting { ··· 716 783 private struct CanvasScrollContainer<Content: View>: NSViewRepresentable { 717 784 @Binding var offset: CGSize 718 785 @Binding var lastOffset: CGSize 786 + @Binding var scale: CGFloat 787 + @Binding var lastScale: CGFloat 719 788 @ViewBuilder var content: Content 720 789 721 790 func makeCoordinator() -> CanvasScrollCoordinator { ··· 740 809 func updateNSView(_ nsView: CanvasScrollContainerView, context: Context) { 741 810 context.coordinator.offset = $offset 742 811 context.coordinator.lastOffset = $lastOffset 812 + context.coordinator.scale = $scale 813 + context.coordinator.lastScale = $lastScale 743 814 if let hosting = nsView.subviews.first as? NSHostingView<Content> { 744 815 hosting.rootView = content 745 816 } ··· 749 820 private class CanvasScrollCoordinator { 750 821 var offset: Binding<CGSize> = .constant(.zero) 751 822 var lastOffset: Binding<CGSize> = .constant(.zero) 823 + var scale: Binding<CGFloat> = .constant(1.0) 824 + var lastScale: Binding<CGFloat> = .constant(1.0) 752 825 753 826 func handleScroll(deltaX: CGFloat, deltaY: CGFloat) { 754 827 let current = offset.wrappedValue ··· 759 832 offset.wrappedValue = newOffset 760 833 lastOffset.wrappedValue = newOffset 761 834 } 835 + 836 + func handleZoom(deltaY: CGFloat, anchor: CGPoint, isPrecise: Bool) { 837 + let result = CanvasZoomMath.zoom( 838 + currentScale: scale.wrappedValue, 839 + currentOffset: offset.wrappedValue, 840 + deltaY: deltaY, 841 + anchor: anchor, 842 + isPrecise: isPrecise 843 + ) 844 + scale.wrappedValue = result.scale 845 + lastScale.wrappedValue = result.scale 846 + offset.wrappedValue = result.offset 847 + lastOffset.wrappedValue = result.offset 848 + } 849 + 850 + func setOffset(_ newOffset: CGSize) { 851 + offset.wrappedValue = newOffset 852 + lastOffset.wrappedValue = newOffset 853 + } 854 + } 855 + 856 + /// Pure zoom math, extracted for testability. 857 + enum CanvasZoomMath { 858 + static let minScale: CGFloat = 0.25 859 + static let maxScale: CGFloat = 2.0 860 + 861 + struct Result: Equatable { 862 + let scale: CGFloat 863 + let offset: CGSize 864 + } 865 + 866 + /// Compute the new scale and offset for a Cmd+wheel zoom step. 867 + /// Keeps the canvas point under `anchor` fixed under the cursor: 868 + /// `screen = canvas * scale + offset` ⇒ `canvas = (anchor - offset) / scale`. 869 + static func zoom( 870 + currentScale: CGFloat, 871 + currentOffset: CGSize, 872 + deltaY: CGFloat, 873 + anchor: CGPoint, 874 + isPrecise: Bool 875 + ) -> Result { 876 + let sensitivity: CGFloat = isPrecise ? 0.0025 : 0.005 877 + let factor = exp(deltaY * sensitivity) 878 + let newScale = max(minScale, min(maxScale, currentScale * factor)) 879 + guard newScale != currentScale else { 880 + return Result(scale: currentScale, offset: currentOffset) 881 + } 882 + let canvasX = (anchor.x - currentOffset.width) / currentScale 883 + let canvasY = (anchor.y - currentOffset.height) / currentScale 884 + let newOffset = CGSize( 885 + width: anchor.x - canvasX * newScale, 886 + height: anchor.y - canvasY * newScale 887 + ) 888 + return Result(scale: newScale, offset: newOffset) 889 + } 762 890 } 763 891 764 892 private class CanvasScrollContainerView: NSView { ··· 774 902 /// during this window is still treated as canvas panning, even if the 775 903 /// cursor now sits on a focused terminal. 776 904 private var bounceTimer: Timer? 905 + 906 + // MARK: - Middle-click pan 907 + private var middleButtonMonitor: Any? 908 + private var isMiddlePanning = false 909 + private var middlePanStartLocation: NSPoint = .zero 910 + private var middlePanStartOffset: CGSize = .zero 911 + private var hasPushedPanCursor = false 777 912 778 913 override func scrollWheel(with event: NSEvent) { 914 + if handleZoomEventIfNeeded(event) { return } 779 915 if event.phase == .began { 780 916 startPanning() 781 917 } ··· 786 922 super.scrollWheel(with: event) 787 923 } 788 924 925 + /// If the event is a Cmd+scroll, route it to canvas zoom and report `true`. 926 + /// Used by both the direct `scrollWheel` override and the local monitor so 927 + /// pressing Cmd mid-gesture switches behavior immediately. 928 + fileprivate func handleZoomEventIfNeeded(_ event: NSEvent) -> Bool { 929 + guard event.modifierFlags.contains(.command), event.scrollingDeltaY != 0 else { return false } 930 + let viewLocation = convert(event.locationInWindow, from: nil) 931 + let anchor = CGPoint(x: viewLocation.x, y: bounds.height - viewLocation.y) 932 + scrollCoordinator?.handleZoom( 933 + deltaY: event.scrollingDeltaY, 934 + anchor: anchor, 935 + isPrecise: event.hasPreciseScrollingDeltas 936 + ) 937 + return true 938 + } 939 + 789 940 // MARK: - Pan lifecycle 790 941 791 942 private func startPanning() { ··· 801 952 private func installMonitor() { 802 953 scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in 803 954 guard let self, event.window === self.window else { return event } 955 + 956 + // Cmd toggled mid-gesture — switch to zoom for this event. 957 + if self.handleZoomEventIfNeeded(event) { return nil } 804 958 805 959 // --- New gesture ------------------------------------------------ 806 960 if event.phase == .began { ··· 875 1029 } 876 1030 } 877 1031 1032 + // MARK: - Middle-click pan 1033 + 1034 + override func viewDidMoveToWindow() { 1035 + super.viewDidMoveToWindow() 1036 + if window != nil { 1037 + installMiddleButtonMonitor() 1038 + } else { 1039 + tearDownMiddleButtonMonitor() 1040 + } 1041 + } 1042 + 1043 + private func installMiddleButtonMonitor() { 1044 + guard middleButtonMonitor == nil else { return } 1045 + let mask: NSEvent.EventTypeMask = [.otherMouseDown, .otherMouseDragged, .otherMouseUp] 1046 + middleButtonMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in 1047 + guard let self, event.window === self.window, event.buttonNumber == 2 else { return event } 1048 + 1049 + switch event.type { 1050 + case .otherMouseDown: 1051 + let location = self.convert(event.locationInWindow, from: nil) 1052 + guard self.bounds.contains(location) else { return event } 1053 + self.beginMiddlePan(at: event.locationInWindow) 1054 + return nil 1055 + case .otherMouseDragged: 1056 + guard self.isMiddlePanning else { return event } 1057 + self.updateMiddlePan(to: event.locationInWindow) 1058 + return nil 1059 + case .otherMouseUp: 1060 + guard self.isMiddlePanning else { return event } 1061 + self.endMiddlePan() 1062 + return nil 1063 + default: 1064 + return event 1065 + } 1066 + } 1067 + } 1068 + 1069 + private func beginMiddlePan(at windowLocation: NSPoint) { 1070 + isMiddlePanning = true 1071 + middlePanStartLocation = windowLocation 1072 + middlePanStartOffset = scrollCoordinator?.offset.wrappedValue ?? .zero 1073 + if !hasPushedPanCursor { 1074 + NSCursor.closedHand.push() 1075 + hasPushedPanCursor = true 1076 + } 1077 + } 1078 + 1079 + private func updateMiddlePan(to windowLocation: NSPoint) { 1080 + let deltaX = windowLocation.x - middlePanStartLocation.x 1081 + // Window Y grows upward; canvas offset Y grows downward (SwiftUI top-left). 1082 + let deltaY = middlePanStartLocation.y - windowLocation.y 1083 + let newOffset = CGSize( 1084 + width: middlePanStartOffset.width + deltaX, 1085 + height: middlePanStartOffset.height + deltaY 1086 + ) 1087 + scrollCoordinator?.setOffset(newOffset) 1088 + } 1089 + 1090 + private func endMiddlePan() { 1091 + isMiddlePanning = false 1092 + if hasPushedPanCursor { 1093 + NSCursor.pop() 1094 + hasPushedPanCursor = false 1095 + } 1096 + } 1097 + 1098 + private func tearDownMiddleButtonMonitor() { 1099 + if isMiddlePanning { endMiddlePan() } 1100 + if let monitor = middleButtonMonitor { 1101 + middleButtonMonitor = nil 1102 + DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } 1103 + } 1104 + } 1105 + 878 1106 override func removeFromSuperview() { 879 1107 tearDownMonitor() 1108 + tearDownMiddleButtonMonitor() 880 1109 super.removeFromSuperview() 881 1110 } 882 1111 }
+118
supacodeTests/CanvasZoomMathTests.swift
··· 1 + import CoreGraphics 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + struct CanvasZoomMathTests { 8 + @Test func positiveDeltaIncreasesScale() { 9 + let result = CanvasZoomMath.zoom( 10 + currentScale: 1.0, 11 + currentOffset: .zero, 12 + deltaY: 10, 13 + anchor: .zero, 14 + isPrecise: false 15 + ) 16 + 17 + #expect(result.scale > 1.0) 18 + } 19 + 20 + @Test func negativeDeltaDecreasesScale() { 21 + let result = CanvasZoomMath.zoom( 22 + currentScale: 1.0, 23 + currentOffset: .zero, 24 + deltaY: -10, 25 + anchor: .zero, 26 + isPrecise: false 27 + ) 28 + 29 + #expect(result.scale < 1.0) 30 + } 31 + 32 + @Test func scaleIsClampedToMaximum() { 33 + let result = CanvasZoomMath.zoom( 34 + currentScale: CanvasZoomMath.maxScale, 35 + currentOffset: .zero, 36 + deltaY: 1000, 37 + anchor: .zero, 38 + isPrecise: false 39 + ) 40 + 41 + #expect(result.scale == CanvasZoomMath.maxScale) 42 + } 43 + 44 + @Test func scaleIsClampedToMinimum() { 45 + let result = CanvasZoomMath.zoom( 46 + currentScale: CanvasZoomMath.minScale, 47 + currentOffset: .zero, 48 + deltaY: -1000, 49 + anchor: .zero, 50 + isPrecise: false 51 + ) 52 + 53 + #expect(result.scale == CanvasZoomMath.minScale) 54 + } 55 + 56 + @Test func anchorPointStaysPutAfterZoom() { 57 + // The canvas point under the anchor must map to the same screen position 58 + // before and after zooming. Using `screen = canvas * scale + offset`, 59 + // the canvas point under `anchor` is `(anchor - offset) / scale`. After 60 + // applying the new scale and offset, it must still land on `anchor`. 61 + let currentScale: CGFloat = 1.0 62 + let currentOffset = CGSize(width: 50, height: 30) 63 + let anchor = CGPoint(x: 200, y: 150) 64 + 65 + let result = CanvasZoomMath.zoom( 66 + currentScale: currentScale, 67 + currentOffset: currentOffset, 68 + deltaY: 25, 69 + anchor: anchor, 70 + isPrecise: false 71 + ) 72 + 73 + let canvasX = (anchor.x - currentOffset.width) / currentScale 74 + let canvasY = (anchor.y - currentOffset.height) / currentScale 75 + let projectedX = canvasX * result.scale + result.offset.width 76 + let projectedY = canvasY * result.scale + result.offset.height 77 + 78 + #expect(abs(projectedX - anchor.x) < 0.0001) 79 + #expect(abs(projectedY - anchor.y) < 0.0001) 80 + } 81 + 82 + @Test func clampedScaleLeavesOffsetUnchanged() { 83 + // When the new scale is clamped to the same as the current scale, the 84 + // offset should not drift — otherwise the canvas would jump even though 85 + // the zoom did nothing. 86 + let currentOffset = CGSize(width: 100, height: 80) 87 + let result = CanvasZoomMath.zoom( 88 + currentScale: CanvasZoomMath.maxScale, 89 + currentOffset: currentOffset, 90 + deltaY: 50, 91 + anchor: CGPoint(x: 300, y: 200), 92 + isPrecise: false 93 + ) 94 + 95 + #expect(result.offset == currentOffset) 96 + #expect(result.scale == CanvasZoomMath.maxScale) 97 + } 98 + 99 + @Test func preciseScrollUsesGentlerSensitivity() { 100 + let imprecise = CanvasZoomMath.zoom( 101 + currentScale: 1.0, 102 + currentOffset: .zero, 103 + deltaY: 10, 104 + anchor: .zero, 105 + isPrecise: false 106 + ) 107 + let precise = CanvasZoomMath.zoom( 108 + currentScale: 1.0, 109 + currentOffset: .zero, 110 + deltaY: 10, 111 + anchor: .zero, 112 + isPrecise: true 113 + ) 114 + 115 + #expect(precise.scale < imprecise.scale) 116 + #expect(precise.scale > 1.0) 117 + } 118 + }