native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #204 from onevcat/fix/canvas-scroll-gesture-continuity

Fix canvas two-finger pan gesture interrupted by focused terminals

authored by

Wei Wang and committed by
GitHub
71362962 891dac9f

+130
+108
supacode/Features/Canvas/Views/CanvasView.swift
··· 753 753 private class CanvasScrollContainerView: NSView { 754 754 var scrollCoordinator: CanvasScrollCoordinator? 755 755 756 + /// Whether the container is actively redirecting scroll events to canvas 757 + /// panning (as opposed to the brief bounce period after a gesture ends). 758 + private var isPanning = false 759 + private var scrollMonitor: Any? 760 + /// Brief delay after finger-up to wait for momentum events. 761 + private var momentumTimer: Timer? 762 + /// Grace period after a pan gesture ends. A follow-up gesture that begins 763 + /// during this window is still treated as canvas panning, even if the 764 + /// cursor now sits on a focused terminal. 765 + private var bounceTimer: Timer? 766 + 756 767 override func scrollWheel(with event: NSEvent) { 768 + if event.phase == .began { 769 + startPanning() 770 + } 757 771 if event.phase == .began || event.phase == .changed || event.phase == .mayBegin || event.momentumPhase != [] { 758 772 scrollCoordinator?.handleScroll(deltaX: event.scrollingDeltaX, deltaY: event.scrollingDeltaY) 759 773 return 760 774 } 761 775 super.scrollWheel(with: event) 776 + } 777 + 778 + // MARK: - Pan lifecycle 779 + 780 + private func startPanning() { 781 + isPanning = true 782 + momentumTimer?.invalidate() 783 + momentumTimer = nil 784 + bounceTimer?.invalidate() 785 + bounceTimer = nil 786 + guard scrollMonitor == nil else { return } 787 + installMonitor() 788 + } 789 + 790 + private func installMonitor() { 791 + scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in 792 + guard let self, event.window === self.window else { return event } 793 + 794 + // --- New gesture ------------------------------------------------ 795 + if event.phase == .began { 796 + if self.isPanning { 797 + // Already panning (edge case). Let normal dispatch decide. 798 + return event 799 + } 800 + // Within the bounce window — treat as a continuation of panning. 801 + self.startPanning() 802 + self.scrollCoordinator?.handleScroll( 803 + deltaX: event.scrollingDeltaX, 804 + deltaY: event.scrollingDeltaY 805 + ) 806 + return nil 807 + } 808 + 809 + // Only intercept while actively panning (not during bounce). 810 + guard self.isPanning else { return event } 811 + 812 + // --- Ongoing gesture / momentum -------------------------------- 813 + self.momentumTimer?.invalidate() 814 + self.momentumTimer = nil 815 + 816 + if event.phase == .changed || event.momentumPhase != [] { 817 + self.scrollCoordinator?.handleScroll( 818 + deltaX: event.scrollingDeltaX, 819 + deltaY: event.scrollingDeltaY 820 + ) 821 + } 822 + 823 + // Finger lifted — momentum may follow shortly. 824 + if event.phase == .ended || event.phase == .cancelled { 825 + self.momentumTimer = Timer.scheduledTimer( 826 + withTimeInterval: 0.1, repeats: false 827 + ) { [weak self] _ in 828 + MainActor.assumeIsolated { self?.enterBounce() } 829 + } 830 + } 831 + 832 + // Momentum finished. 833 + if event.momentumPhase == .ended || event.momentumPhase == .cancelled { 834 + self.enterBounce() 835 + } 836 + 837 + return nil 838 + } 839 + } 840 + 841 + /// Transition from active panning to the bounce (grace) period. 842 + /// The monitor stays alive so a quick follow-up gesture resumes panning. 843 + private func enterBounce() { 844 + isPanning = false 845 + momentumTimer?.invalidate() 846 + momentumTimer = nil 847 + bounceTimer?.invalidate() 848 + bounceTimer = Timer.scheduledTimer( 849 + withTimeInterval: 0.3, repeats: false 850 + ) { [weak self] _ in 851 + MainActor.assumeIsolated { self?.tearDownMonitor() } 852 + } 853 + } 854 + 855 + private func tearDownMonitor() { 856 + isPanning = false 857 + momentumTimer?.invalidate() 858 + momentumTimer = nil 859 + bounceTimer?.invalidate() 860 + bounceTimer = nil 861 + if let monitor = scrollMonitor { 862 + scrollMonitor = nil 863 + DispatchQueue.main.async { MainActor.assumeIsolated { NSEvent.removeMonitor(monitor) } } 864 + } 865 + } 866 + 867 + override func removeFromSuperview() { 868 + tearDownMonitor() 869 + super.removeFromSuperview() 762 870 } 763 871 }
+22
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 924 924 925 925 override func scrollWheel(with event: NSEvent) { 926 926 guard let surface else { return } 927 + 928 + // In canvas mode, if the terminal has no scrollback content the scroll 929 + // event is useless here. Let it bubble up to the canvas container so 930 + // two-finger gestures pan the canvas instead of being swallowed. 931 + if scrollWrapper?.hostKind == .canvas, !hasScrollbackContent { 932 + scrollWrapper?.forwardScrollToParent(with: event) 933 + return 934 + } 935 + 927 936 var scrollX = event.scrollingDeltaX 928 937 var scrollY = event.scrollingDeltaY 929 938 if event.hasPreciseScrollingDeltas { ··· 931 940 scrollY *= 2 932 941 } 933 942 ghostty_surface_mouse_scroll(surface, scrollX, scrollY, scrollMods(for: event)) 943 + } 944 + 945 + private var hasScrollbackContent: Bool { 946 + guard let lastScrollbar else { return false } 947 + return lastScrollbar.total > lastScrollbar.length 934 948 } 935 949 936 950 override func pressureChange(with event: NSEvent) { ··· 2580 2594 func updateScrollbar(total: UInt64, offset: UInt64, length: UInt64) { 2581 2595 scrollbar = ScrollbarState(total: total, offset: offset, length: length) 2582 2596 synchronizeScrollView() 2597 + } 2598 + 2599 + /// Forward a scroll event to the parent responder chain, bypassing the 2600 + /// internal NSScrollView which would otherwise consume it. Used in 2601 + /// canvas mode when the terminal has no scrollback content so the event 2602 + /// can reach CanvasScrollContainerView for canvas panning. 2603 + func forwardScrollToParent(with event: NSEvent) { 2604 + super.scrollWheel(with: event) 2583 2605 } 2584 2606 2585 2607 func refreshAppearance() {