Skip to content

Commit 3efd947

Browse files
Merge pull request manaflow-ai#1965 from manaflow-ai/task-scrollbar-fix-mainline
Preserve explicit wheel scrollback against passive follow
2 parents db4538b + f4932ce commit 3efd947

2 files changed

Lines changed: 120 additions & 11 deletions

File tree

Sources/GhosttyTerminalView.swift

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6058,6 +6058,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
60586058
}
60596059

60606060
override func scrollWheel(with event: NSEvent) {
6061+
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self)
60616062
guard let surface = surface else { return }
60626063
lastScrollEventTime = CACurrentMediaTime()
60636064
Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))")
@@ -6459,6 +6460,7 @@ enum GhosttyNotificationKey {
64596460
extension Notification.Name {
64606461
static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar")
64616462
static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize")
6463+
static let ghosttyDidReceiveWheelScroll = Notification.Name("ghosttyDidReceiveWheelScroll")
64626464
static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus")
64636465
static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload")
64646466
static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange")
@@ -6592,6 +6594,8 @@ final class GhosttySurfaceScrollView: NSView {
65926594
/// When true, auto-scroll should be suspended to prevent the "doomscroll" bug
65936595
/// where the terminal fights the user's scroll position.
65946596
private var userScrolledAwayFromBottom = false
6597+
private var pendingExplicitWheelScroll = false
6598+
private var allowExplicitScrollbarSync = false
65956599
/// Threshold in points from bottom to consider "at bottom" (allows for minor float drift)
65966600
private static let scrollToBottomThreshold: CGFloat = 5.0
65976601
private var isActive = true
@@ -7017,6 +7021,14 @@ final class GhosttySurfaceScrollView: NSView {
70177021
self?.handleScrollbarUpdate(notification)
70187022
})
70197023

7024+
observers.append(NotificationCenter.default.addObserver(
7025+
forName: .ghosttyDidReceiveWheelScroll,
7026+
object: surfaceView,
7027+
queue: .main
7028+
) { [weak self] _ in
7029+
self?.pendingExplicitWheelScroll = true
7030+
})
7031+
70207032
observers.append(NotificationCenter.default.addObserver(
70217033
forName: .ghosttySearchFocus,
70227034
object: nil,
@@ -9034,26 +9046,21 @@ final class GhosttySurfaceScrollView: NSView {
90349046
userScrolledAwayFromBottom = false
90359047
}
90369048

9037-
// Only auto-scroll if user hasn't manually scrolled away from bottom
9038-
// or if we're following terminal output (scrollbar shows we're at bottom)
9039-
let shouldAutoScroll = !userScrolledAwayFromBottom ||
9040-
(scrollbar.offset + scrollbar.len >= scrollbar.total)
9049+
// Passive bottom packets should not override an explicit scrollback review,
9050+
// but the first scrollbar packet caused by the user's own wheel input should
9051+
// still move the viewport to the requested scrollback position.
9052+
let shouldAutoScroll = !userScrolledAwayFromBottom || allowExplicitScrollbarSync
90419053

90429054
if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) {
9043-
#if DEBUG
9044-
logDragGeometryChange(
9045-
event: "scrollOrigin",
9046-
old: currentOrigin,
9047-
new: targetOrigin
9048-
)
9049-
#endif
90509055
scrollView.contentView.scroll(to: targetOrigin)
90519056
didChangeGeometry = true
90529057
}
90539058
lastSentRow = Int(scrollbar.offset)
90549059
}
90559060
}
90569061

9062+
allowExplicitScrollbarSync = false
9063+
90579064
if didChangeGeometry {
90589065
scrollView.reflectScrolledClipView(scrollView.contentView)
90599066
}
@@ -9089,6 +9096,11 @@ final class GhosttySurfaceScrollView: NSView {
90899096
guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else {
90909097
return
90919098
}
9099+
if pendingExplicitWheelScroll {
9100+
userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total
9101+
allowExplicitScrollbarSync = true
9102+
pendingExplicitWheelScroll = false
9103+
}
90929104
surfaceView.scrollbar = scrollbar
90939105
synchronizeScrollView()
90949106
}

cmuxTests/TerminalAndGhosttyTests.swift

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1683,6 +1683,30 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
16831683
}
16841684
}
16851685

1686+
private final class ScrollbarPostingSurfaceView: GhosttyNSView {
1687+
var nextScrollbar: GhosttyScrollbar?
1688+
1689+
override func scrollWheel(with event: NSEvent) {
1690+
super.scrollWheel(with: event)
1691+
guard let nextScrollbar else { return }
1692+
NotificationCenter.default.post(
1693+
name: .ghosttyDidUpdateScrollbar,
1694+
object: self,
1695+
userInfo: [GhosttyNotificationKey.scrollbar: nextScrollbar]
1696+
)
1697+
}
1698+
}
1699+
1700+
private func makeScrollbar(total: UInt64, offset: UInt64, len: UInt64) -> GhosttyScrollbar {
1701+
GhosttyScrollbar(
1702+
c: ghostty_action_scrollbar_s(
1703+
total: total,
1704+
offset: offset,
1705+
len: len
1706+
)
1707+
)
1708+
}
1709+
16861710
private func findEditableTextField(in view: NSView) -> NSTextField? {
16871711
if let field = view as? NSTextField, field.isEditable {
16881712
return field
@@ -1768,6 +1792,79 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
17681792
)
17691793
}
17701794

1795+
func testExplicitWheelScrollKeepsScrollbackPinnedAgainstLaterBottomPacket() {
1796+
let window = NSWindow(
1797+
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
1798+
styleMask: [.titled, .closable],
1799+
backing: .buffered,
1800+
defer: false
1801+
)
1802+
defer { window.orderOut(nil) }
1803+
1804+
guard let contentView = window.contentView else {
1805+
XCTFail("Expected content view")
1806+
return
1807+
}
1808+
1809+
let surfaceView = ScrollbarPostingSurfaceView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
1810+
surfaceView.cellSize = CGSize(width: 10, height: 10)
1811+
let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
1812+
hostedView.frame = contentView.bounds
1813+
hostedView.autoresizingMask = [.width, .height]
1814+
contentView.addSubview(hostedView)
1815+
1816+
window.makeKeyAndOrderFront(nil)
1817+
window.displayIfNeeded()
1818+
contentView.layoutSubtreeIfNeeded()
1819+
hostedView.layoutSubtreeIfNeeded()
1820+
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
1821+
1822+
guard let scrollView = hostedView.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView else {
1823+
XCTFail("Expected hosted terminal scroll view")
1824+
return
1825+
}
1826+
1827+
NotificationCenter.default.post(
1828+
name: .ghosttyDidUpdateScrollbar,
1829+
object: surfaceView,
1830+
userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)]
1831+
)
1832+
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
1833+
XCTAssertEqual(scrollView.contentView.bounds.origin.y, 0, accuracy: 0.01)
1834+
1835+
surfaceView.nextScrollbar = makeScrollbar(total: 100, offset: 40, len: 10)
1836+
1837+
guard let cgEvent = CGEvent(
1838+
scrollWheelEvent2Source: nil,
1839+
units: .pixel,
1840+
wheelCount: 2,
1841+
wheel1: 0,
1842+
wheel2: -12,
1843+
wheel3: 0
1844+
), let scrollEvent = NSEvent(cgEvent: cgEvent) else {
1845+
XCTFail("Expected scroll wheel event")
1846+
return
1847+
}
1848+
1849+
scrollView.scrollWheel(with: scrollEvent)
1850+
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
1851+
XCTAssertEqual(scrollView.contentView.bounds.origin.y, 500, accuracy: 0.01)
1852+
1853+
NotificationCenter.default.post(
1854+
name: .ghosttyDidUpdateScrollbar,
1855+
object: surfaceView,
1856+
userInfo: [GhosttyNotificationKey.scrollbar: makeScrollbar(total: 100, offset: 90, len: 10)]
1857+
)
1858+
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
1859+
1860+
XCTAssertEqual(
1861+
scrollView.contentView.bounds.origin.y,
1862+
500,
1863+
accuracy: 0.01,
1864+
"A passive bottom packet should not yank the viewport after an explicit wheel scroll into scrollback"
1865+
)
1866+
}
1867+
17711868
func testInactiveOverlayVisibilityTracksRequestedState() {
17721869
let hostedView = GhosttySurfaceScrollView(
17731870
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))

0 commit comments

Comments
 (0)