From 1fabe9f33c17c23987f87dc2db487ce0f6e120f0 Mon Sep 17 00:00:00 2001 From: BillionToken Date: Wed, 18 Mar 2026 08:52:59 +0800 Subject: [PATCH] fix(terminal): prevent doomscroll when reviewing scrollback (#1616) The synchronizeScrollView() method was constantly resetting the scroll position to match the terminal's scrollbar state, even when the user had manually scrolled up to review scrollback. This caused the 'doomscroll' bug where panes would fight the user's scroll position. - Add userScrolledAwayFromBottom flag to track scroll intent - Only auto-scroll when at bottom or when scrollbar indicates following - Reset flag when user scrolls back to bottom - Add 5-point threshold to tolerate minor float drift Fixes #1577 Co-authored-by: BillionClaw <267901332+BillionClaw@users.noreply.github.com> --- Sources/GhosttyTerminalView.swift | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3d420c1d..56009f7e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6106,6 +6106,12 @@ final class GhosttySurfaceScrollView: NSView { private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false private var lastSentRow: Int? + /// Tracks whether the user has scrolled away from the bottom to review scrollback. + /// 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 + /// 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 private var lastFocusRefreshAt: CFTimeInterval = 0 private var activeDropZone: DropZone? @@ -6433,6 +6439,8 @@ final class GhosttySurfaceScrollView: NSView { queue: .main ) { [weak self] _ in self?.isLiveScrolling = false + // Final scroll position check to update userScrolledAwayFromBottom state + self?.handleLiveScroll() }) observers.append(NotificationCenter.default.addObserver( @@ -8349,11 +8357,29 @@ final class GhosttySurfaceScrollView: NSView { let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight let targetOrigin = CGPoint(x: 0, y: offsetY) - if !pointApproximatelyEqual(scrollView.contentView.bounds.origin, targetOrigin) { + + // Check if we're currently at the bottom (with threshold for float drift) + let currentOrigin = scrollView.contentView.bounds.origin + let documentHeight = documentView.frame.height + let viewportHeight = scrollView.contentView.bounds.height + let distanceFromBottom = documentHeight - currentOrigin.y - viewportHeight + let isAtBottom = distanceFromBottom <= Self.scrollToBottomThreshold + + // Update userScrolledAwayFromBottom based on current position + if isAtBottom { + 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) + + if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) { #if DEBUG logDragGeometryChange( event: "scrollOrigin", - old: scrollView.contentView.bounds.origin, + old: currentOrigin, new: targetOrigin ) #endif @@ -8380,6 +8406,14 @@ final class GhosttySurfaceScrollView: NSView { let visibleRect = scrollView.contentView.documentVisibleRect let documentHeight = documentView.frame.height let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + + // Track if user has scrolled away from bottom to review scrollback + if scrollOffset > Self.scrollToBottomThreshold { + userScrolledAwayFromBottom = true + } else if scrollOffset <= 0 { + userScrolledAwayFromBottom = false + } + let row = Int(scrollOffset / cellHeight) guard row != lastSentRow else { return }