From 7634abe6162117da5f59e0ce3b408335d30d4c9d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 22 Mar 2026 18:11:00 -0700 Subject: [PATCH 1/2] Add regression test for wheel scrollback follow bug --- cmuxTests/TerminalAndGhosttyTests.swift | 97 +++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index aca39b2467..5dd224fb18 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1590,6 +1590,30 @@ final class GhosttySurfaceOverlayTests: XCTestCase { } } + private final class ScrollbarPostingSurfaceView: GhosttyNSView { + var nextScrollbar: GhosttyScrollbar? + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + guard let nextScrollbar else { return } + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: self, + userInfo: [GhosttyNotificationKey.scrollbar: nextScrollbar] + ) + } + } + + private func makeScrollbar(total: UInt64, offset: UInt64, len: UInt64) -> GhosttyScrollbar { + GhosttyScrollbar( + c: ghostty_action_scrollbar_s( + total: total, + offset: offset, + len: len + ) + ) + } + private func findEditableTextField(in view: NSView) -> NSTextField? { if let field = view as? NSTextField, field.isEditable { return field @@ -1675,6 +1699,79 @@ final class GhosttySurfaceOverlayTests: XCTestCase { ) } + func testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let surfaceView = ScrollbarPostingSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + surfaceView.cellSize = CGSize(width: 10, height: 10) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else { + XCTFail("Expected hosted terminal scroll view") + return + } + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual(scrollView.contentView.bounds.origin.y, 0, accuracy: 0.01) + + surfaceView.nextScrollbar = makeScrollbar(total: 100, offset: 40, len: 10) + + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: .pixel, + wheelCount: 2, + wheel1: 0, + wheel2: -12, + wheel3: 0 + ), let scrollEvent = NSEvent(cgEvent: cgEvent) else { + XCTFail("Expected scroll wheel event") + return + } + + scrollView.scrollWheel(with: scrollEvent) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + XCTAssertEqual(scrollView.contentView.bounds.origin.y, 500, accuracy: 0.01) + + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)] + ) + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + + XCTAssertEqual( + scrollView.contentView.bounds.origin.y, + 500, + accuracy: 0.01, + "A passive bottom packet should not yank the viewport after an explicit wheel scroll into scrollback" + ) + } + func testInactiveOverlayVisibilityTracksRequestedState() { let hostedView = GhosttySurfaceScrollView( surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) From c1c028e62801bed11ca7eb5a6a176de2b1c3331b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 22 Mar 2026 18:11:06 -0700 Subject: [PATCH 2/2] 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 067622f4ff..72f66c12d1 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() }