From c1c028e62801bed11ca7eb5a6a176de2b1c3331b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 22 Mar 2026 18:11:06 -0700 Subject: [PATCH] Preserve explicit wheel scrollback against passive follow --- Sources/GhosttyTerminalView.swift | 34 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 067622f4..72f66c12 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6052,6 +6052,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func scrollWheel(with event: NSEvent) { + NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self) guard let surface = surface else { return } lastScrollEventTime = CACurrentMediaTime() Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))") @@ -6453,6 +6454,7 @@ enum GhosttyNotificationKey { extension Notification.Name { static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar") static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize") + static let ghosttyDidReceiveWheelScroll = Notification.Name("ghosttyDidReceiveWheelScroll") static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") @@ -6586,6 +6588,8 @@ final class GhosttySurfaceScrollView: NSView { /// When true, auto-scroll should be suspended to prevent the "doomscroll" bug /// where the terminal fights the user's scroll position. private var userScrolledAwayFromBottom = false + private var pendingExplicitWheelScroll = false + private var allowExplicitScrollbarSync = false /// Threshold in points from bottom to consider "at bottom" (allows for minor float drift) private static let scrollToBottomThreshold: CGFloat = 5.0 private var isActive = true @@ -7011,6 +7015,14 @@ final class GhosttySurfaceScrollView: NSView { self?.handleScrollbarUpdate(notification) }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidReceiveWheelScroll, + object: surfaceView, + queue: .main + ) { [weak self] _ in + self?.pendingExplicitWheelScroll = true + }) + observers.append(NotificationCenter.default.addObserver( forName: .ghosttySearchFocus, object: nil, @@ -9013,19 +9025,12 @@ final class GhosttySurfaceScrollView: NSView { userScrolledAwayFromBottom = false } - // Only auto-scroll if user hasn't manually scrolled away from bottom - // or if we're following terminal output (scrollbar shows we're at bottom) - let shouldAutoScroll = !userScrolledAwayFromBottom || - (scrollbar.offset + scrollbar.len >= scrollbar.total) + // Passive bottom packets should not override an explicit scrollback review, + // but the first scrollbar packet caused by the user's own wheel input should + // still move the viewport to the requested scrollback position. + let shouldAutoScroll = !userScrolledAwayFromBottom || allowExplicitScrollbarSync if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) { -#if DEBUG - logDragGeometryChange( - event: "scrollOrigin", - old: currentOrigin, - new: targetOrigin - ) -#endif scrollView.contentView.scroll(to: targetOrigin) didChangeGeometry = true } @@ -9033,6 +9038,8 @@ final class GhosttySurfaceScrollView: NSView { } } + allowExplicitScrollbarSync = false + if didChangeGeometry { scrollView.reflectScrolledClipView(scrollView.contentView) } @@ -9068,6 +9075,11 @@ final class GhosttySurfaceScrollView: NSView { guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { return } + if pendingExplicitWheelScroll { + userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total + allowExplicitScrollbarSync = true + pendingExplicitWheelScroll = false + } surfaceView.scrollbar = scrollbar synchronizeScrollView() }