diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index ec5842f5..c8d6a213 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -31,7 +31,12 @@ struct CanvasView: View { for: AppShortcuts.CommandID.selectAllCanvasCards, in: resolvedKeybindings ) - CanvasScrollContainer(offset: $canvasOffset, lastOffset: $lastCanvasOffset) { + CanvasScrollContainer( + offset: $canvasOffset, + lastOffset: $lastCanvasOffset, + scale: $canvasScale, + lastScale: $lastCanvasScale + ) { GeometryReader { _ in let activeStates = terminalManager.activeWorktreeStates let allCardKeys = collectCardKeys(from: activeStates) @@ -716,6 +721,8 @@ private struct ActiveResize { private struct CanvasScrollContainer: NSViewRepresentable { @Binding var offset: CGSize @Binding var lastOffset: CGSize + @Binding var scale: CGFloat + @Binding var lastScale: CGFloat @ViewBuilder var content: Content func makeCoordinator() -> CanvasScrollCoordinator { @@ -740,6 +747,8 @@ private struct CanvasScrollContainer: NSViewRepresentable { func updateNSView(_ nsView: CanvasScrollContainerView, context: Context) { context.coordinator.offset = $offset context.coordinator.lastOffset = $lastOffset + context.coordinator.scale = $scale + context.coordinator.lastScale = $lastScale if let hosting = nsView.subviews.first as? NSHostingView { hosting.rootView = content } @@ -749,6 +758,8 @@ private struct CanvasScrollContainer: NSViewRepresentable { private class CanvasScrollCoordinator { var offset: Binding = .constant(.zero) var lastOffset: Binding = .constant(.zero) + var scale: Binding = .constant(1.0) + var lastScale: Binding = .constant(1.0) func handleScroll(deltaX: CGFloat, deltaY: CGFloat) { let current = offset.wrappedValue @@ -759,6 +770,61 @@ private class CanvasScrollCoordinator { offset.wrappedValue = newOffset lastOffset.wrappedValue = newOffset } + + func handleZoom(deltaY: CGFloat, anchor: CGPoint, isPrecise: Bool) { + let result = CanvasZoomMath.zoom( + currentScale: scale.wrappedValue, + currentOffset: offset.wrappedValue, + deltaY: deltaY, + anchor: anchor, + isPrecise: isPrecise + ) + scale.wrappedValue = result.scale + lastScale.wrappedValue = result.scale + offset.wrappedValue = result.offset + lastOffset.wrappedValue = result.offset + } + + func setOffset(_ newOffset: CGSize) { + offset.wrappedValue = newOffset + lastOffset.wrappedValue = newOffset + } +} + +/// Pure zoom math, extracted for testability. +enum CanvasZoomMath { + static let minScale: CGFloat = 0.25 + static let maxScale: CGFloat = 2.0 + + struct Result: Equatable { + let scale: CGFloat + let offset: CGSize + } + + /// Compute the new scale and offset for a Cmd+wheel zoom step. + /// Keeps the canvas point under `anchor` fixed under the cursor: + /// `screen = canvas * scale + offset` ⇒ `canvas = (anchor - offset) / scale`. + static func zoom( + currentScale: CGFloat, + currentOffset: CGSize, + deltaY: CGFloat, + anchor: CGPoint, + isPrecise: Bool + ) -> Result { + let sensitivity: CGFloat = isPrecise ? 0.005 : 0.01 + let factor = exp(deltaY * sensitivity) + let newScale = max(minScale, min(maxScale, currentScale * factor)) + guard newScale != currentScale else { + return Result(scale: currentScale, offset: currentOffset) + } + let canvasX = (anchor.x - currentOffset.width) / currentScale + let canvasY = (anchor.y - currentOffset.height) / currentScale + let newOffset = CGSize( + width: anchor.x - canvasX * newScale, + height: anchor.y - canvasY * newScale + ) + return Result(scale: newScale, offset: newOffset) + } } private class CanvasScrollContainerView: NSView { @@ -775,7 +841,15 @@ private class CanvasScrollContainerView: NSView { /// cursor now sits on a focused terminal. private var bounceTimer: Timer? + // MARK: - Middle-click pan + private var middleButtonMonitor: Any? + private var isMiddlePanning = false + private var middlePanStartLocation: NSPoint = .zero + private var middlePanStartOffset: CGSize = .zero + private var hasPushedPanCursor = false + override func scrollWheel(with event: NSEvent) { + if handleZoomEventIfNeeded(event) { return } if event.phase == .began { startPanning() } @@ -786,6 +860,21 @@ private class CanvasScrollContainerView: NSView { super.scrollWheel(with: event) } + /// If the event is a Cmd+scroll, route it to canvas zoom and report `true`. + /// Used by both the direct `scrollWheel` override and the local monitor so + /// pressing Cmd mid-gesture switches behavior immediately. + fileprivate func handleZoomEventIfNeeded(_ event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command), event.scrollingDeltaY != 0 else { return false } + let viewLocation = convert(event.locationInWindow, from: nil) + let anchor = CGPoint(x: viewLocation.x, y: bounds.height - viewLocation.y) + scrollCoordinator?.handleZoom( + deltaY: event.scrollingDeltaY, + anchor: anchor, + isPrecise: event.hasPreciseScrollingDeltas + ) + return true + } + // MARK: - Pan lifecycle private func startPanning() { @@ -802,6 +891,9 @@ private class CanvasScrollContainerView: NSView { scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in guard let self, event.window === self.window else { return event } + // Cmd toggled mid-gesture — switch to zoom for this event. + if self.handleZoomEventIfNeeded(event) { return nil } + // --- New gesture ------------------------------------------------ if event.phase == .began { if self.isPanning { @@ -875,8 +967,83 @@ private class CanvasScrollContainerView: NSView { } } + // MARK: - Middle-click pan + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + installMiddleButtonMonitor() + } else { + tearDownMiddleButtonMonitor() + } + } + + private func installMiddleButtonMonitor() { + guard middleButtonMonitor == nil else { return } + let mask: NSEvent.EventTypeMask = [.otherMouseDown, .otherMouseDragged, .otherMouseUp] + middleButtonMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in + guard let self, event.window === self.window, event.buttonNumber == 2 else { return event } + + switch event.type { + case .otherMouseDown: + let location = self.convert(event.locationInWindow, from: nil) + guard self.bounds.contains(location) else { return event } + self.beginMiddlePan(at: event.locationInWindow) + return nil + case .otherMouseDragged: + guard self.isMiddlePanning else { return event } + self.updateMiddlePan(to: event.locationInWindow) + return nil + case .otherMouseUp: + guard self.isMiddlePanning else { return event } + self.endMiddlePan() + return nil + default: + return event + } + } + } + + private func beginMiddlePan(at windowLocation: NSPoint) { + isMiddlePanning = true + middlePanStartLocation = windowLocation + middlePanStartOffset = scrollCoordinator?.offset.wrappedValue ?? .zero + if !hasPushedPanCursor { + NSCursor.closedHand.push() + hasPushedPanCursor = true + } + } + + private func updateMiddlePan(to windowLocation: NSPoint) { + let deltaX = windowLocation.x - middlePanStartLocation.x + // Window Y grows upward; canvas offset Y grows downward (SwiftUI top-left). + let deltaY = middlePanStartLocation.y - windowLocation.y + let newOffset = CGSize( + width: middlePanStartOffset.width + deltaX, + height: middlePanStartOffset.height + deltaY + ) + scrollCoordinator?.setOffset(newOffset) + } + + private func endMiddlePan() { + isMiddlePanning = false + if hasPushedPanCursor { + NSCursor.pop() + hasPushedPanCursor = false + } + } + + private func tearDownMiddleButtonMonitor() { + if isMiddlePanning { endMiddlePan() } + if let monitor = middleButtonMonitor { + middleButtonMonitor = nil + DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } + } + } + override func removeFromSuperview() { tearDownMonitor() + tearDownMiddleButtonMonitor() super.removeFromSuperview() } } diff --git a/supacodeTests/CanvasZoomMathTests.swift b/supacodeTests/CanvasZoomMathTests.swift new file mode 100644 index 00000000..89de2b65 --- /dev/null +++ b/supacodeTests/CanvasZoomMathTests.swift @@ -0,0 +1,118 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import supacode + +struct CanvasZoomMathTests { + @Test func positiveDeltaIncreasesScale() { + let result = CanvasZoomMath.zoom( + currentScale: 1.0, + currentOffset: .zero, + deltaY: 10, + anchor: .zero, + isPrecise: false + ) + + #expect(result.scale > 1.0) + } + + @Test func negativeDeltaDecreasesScale() { + let result = CanvasZoomMath.zoom( + currentScale: 1.0, + currentOffset: .zero, + deltaY: -10, + anchor: .zero, + isPrecise: false + ) + + #expect(result.scale < 1.0) + } + + @Test func scaleIsClampedToMaximum() { + let result = CanvasZoomMath.zoom( + currentScale: CanvasZoomMath.maxScale, + currentOffset: .zero, + deltaY: 1000, + anchor: .zero, + isPrecise: false + ) + + #expect(result.scale == CanvasZoomMath.maxScale) + } + + @Test func scaleIsClampedToMinimum() { + let result = CanvasZoomMath.zoom( + currentScale: CanvasZoomMath.minScale, + currentOffset: .zero, + deltaY: -1000, + anchor: .zero, + isPrecise: false + ) + + #expect(result.scale == CanvasZoomMath.minScale) + } + + @Test func anchorPointStaysPutAfterZoom() { + // The canvas point under the anchor must map to the same screen position + // before and after zooming. Using `screen = canvas * scale + offset`, + // the canvas point under `anchor` is `(anchor - offset) / scale`. After + // applying the new scale and offset, it must still land on `anchor`. + let currentScale: CGFloat = 1.0 + let currentOffset = CGSize(width: 50, height: 30) + let anchor = CGPoint(x: 200, y: 150) + + let result = CanvasZoomMath.zoom( + currentScale: currentScale, + currentOffset: currentOffset, + deltaY: 25, + anchor: anchor, + isPrecise: false + ) + + let canvasX = (anchor.x - currentOffset.width) / currentScale + let canvasY = (anchor.y - currentOffset.height) / currentScale + let projectedX = canvasX * result.scale + result.offset.width + let projectedY = canvasY * result.scale + result.offset.height + + #expect(abs(projectedX - anchor.x) < 0.0001) + #expect(abs(projectedY - anchor.y) < 0.0001) + } + + @Test func clampedScaleLeavesOffsetUnchanged() { + // When the new scale is clamped to the same as the current scale, the + // offset should not drift — otherwise the canvas would jump even though + // the zoom did nothing. + let currentOffset = CGSize(width: 100, height: 80) + let result = CanvasZoomMath.zoom( + currentScale: CanvasZoomMath.maxScale, + currentOffset: currentOffset, + deltaY: 50, + anchor: CGPoint(x: 300, y: 200), + isPrecise: false + ) + + #expect(result.offset == currentOffset) + #expect(result.scale == CanvasZoomMath.maxScale) + } + + @Test func preciseScrollUsesGentlerSensitivity() { + let imprecise = CanvasZoomMath.zoom( + currentScale: 1.0, + currentOffset: .zero, + deltaY: 10, + anchor: .zero, + isPrecise: false + ) + let precise = CanvasZoomMath.zoom( + currentScale: 1.0, + currentOffset: .zero, + deltaY: 10, + anchor: .zero, + isPrecise: true + ) + + #expect(precise.scale < imprecise.scale) + #expect(precise.scale > 1.0) + } +}