From 0adae4222176f66b75a9ba340e43f1906b0dc223 Mon Sep 17 00:00:00 2001 From: alvarolb Date: Sat, 21 Feb 2026 12:52:13 +0100 Subject: [PATCH] Fix iOS scroll locked to bottom during incoming data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS, updateScroller() unconditionally forced contentOffset to the bottom on every new line of output, preventing the user from scrolling up while the terminal was receiving data. Changes: 1. Derive contentOffset from yDisp — updateScroller() now uses the terminal's yDisp (already managed by Terminal.scroll) instead of always forcing to bottom. When userScrolling is true, yDisp stays stable so the view won't snap. 2. Track scroll via panGestureRecognizer — uses addTarget on the existing pan gesture instead of setting delegate = self, preserving delegate semantics for consumers of TerminalView. 3. Synchronize yDisp from contentOffset — after the user finishes scrolling (including deceleration), yDisp is updated to match the visual position, keeping buffer state in sync. 4. Detect deceleration end via CADisplayLink — uses the existing display link to check when deceleration ends, avoiding contentOffset didSet overrides that conflict with UIKit bounce animations. 5. Reset on buffer switch — bufferActivated() clears userScrolling so alternate screen transitions always snap to bottom. 6. Reset on keyboard input — commitTextInput() clears userScrolling and scrolls to bottom, since typing implies the user wants to see current output. 7. Conditional ensureCaretIsVisible — only scrolls to bottom if the caret is actually off-screen, matching macOS behavior. --- Package.resolved | 95 +++++++++++++++++++++ Sources/SwiftTerm/iOS/iOSTerminalView.swift | 77 +++++++++++++++-- 2 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..81a397c11 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,95 @@ +{ + "pins" : [ + { + "identity" : "hdrhistogram-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/HdrHistogram/hdrhistogram-swift.git", + "state" : { + "revision" : "de0b9b8a27956b9bfc9b4dce7d1c38ad7c579f19", + "version" : "0.1.4" + } + }, + { + "identity" : "package-benchmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/package-benchmark", + "state" : { + "revision" : "a1075611342d4b164bf6e977edb02a3db4434afd", + "version" : "1.29.11" + } + }, + { + "identity" : "package-jemalloc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/package-jemalloc.git", + "state" : { + "revision" : "e8a5db026963f5bfeac842d9d3f2cc8cde323b49", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "e977f65879f82b375a044c8837597f690c067da6", + "version" : "1.4.6" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "texttable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/TextTable.git", + "state" : { + "revision" : "a27a07300cf4ae322e0079ca0a475c5583dd575f", + "version" : "0.0.2" + } + } + ], + "version" : 2 +} diff --git a/Sources/SwiftTerm/iOS/iOSTerminalView.swift b/Sources/SwiftTerm/iOS/iOSTerminalView.swift index d1856b587..25ad2531c 100644 --- a/Sources/SwiftTerm/iOS/iOSTerminalView.swift +++ b/Sources/SwiftTerm/iOS/iOSTerminalView.swift @@ -260,6 +260,8 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi setupProgressBar() setupGestures () setupAccessoryView () + + panGestureRecognizer.addTarget(self, action: #selector(handleScrollPanGesture(_:))) } func setupDisplayUpdates () @@ -335,6 +337,9 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi @objc func step(displaylink: CADisplayLink) { updateDisplay() + if userScrolling { + checkScrollDeceleration() + } } func startDisplayUpdates() @@ -1041,6 +1046,8 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi var lineLeading: CGFloat = 0 open func bufferActivated(source: Terminal) { + userScrolling = false + terminal.userScrolling = false updateScroller () } @@ -1133,14 +1140,56 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi let displayBuffer = terminal.displayBuffer contentSize = CGSize (width: CGFloat (displayBuffer.cols) * cellDimension.width, height: CGFloat (displayBuffer.lines.count) * cellDimension.height) - //contentOffset = CGPoint (x: 0, y: CGFloat (displayBuffer.lines.count-displayBuffer.rows)*cellDimension.height) - contentOffset = CGPoint (x: 0, y: CGFloat (displayBuffer.lines.count-displayBuffer.rows)*cellDimension.height) - //Xscroller.doubleValue = scrollPosition - //Xscroller.knobProportion = scrollThumbsize + if !userScrolling { + contentOffset = CGPoint (x: 0, y: CGFloat (displayBuffer.yDisp) * cellDimension.height) + } } - + var userScrolling = false + @objc private func handleScrollPanGesture(_ gesture: UIPanGestureRecognizer) { + switch gesture.state { + case .began: + userScrolling = true + terminal.userScrolling = true + case .ended, .cancelled, .failed: + if !isDecelerating { + syncYDispFromContentOffset() + } + default: + break + } + } + + func checkScrollDeceleration() { + if userScrolling && !isTracking && !isDecelerating { + syncYDispFromContentOffset() + } + } + + private func syncYDispFromContentOffset() { + let displayBuffer = terminal.displayBuffer + let maxRow = displayBuffer.lines.count - displayBuffer.rows + guard maxRow > 0 else { + userScrolling = false + terminal.userScrolling = false + return + } + + let row = Int(round(contentOffset.y / cellDimension.height)) + let clampedRow = max(0, min(row, maxRow)) + + if clampedRow != displayBuffer.yDisp { + terminal.setViewYDisp(clampedRow) + } + + // If at or near the bottom, resume auto-scrolling + if clampedRow >= maxRow { + userScrolling = false + terminal.userScrolling = false + } + } + func getCurrentGraphicsContext () -> CGContext? { UIGraphicsGetCurrentContext () @@ -1259,6 +1308,14 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi } private func commitTextInput(_ text: String, applyModifiers: Bool) { + if userScrolling { + userScrolling = false + terminal.userScrolling = false + let displayBuffer = terminal.displayBuffer + terminal.setViewYDisp(displayBuffer.yBase) + updateScroller() + } + let hadPendingAutoPeriodDelete = pendingAutoPeriodDeleteWasSpace if text != ". " { pendingAutoPeriodDeleteWasSpace = false @@ -1382,7 +1439,15 @@ open class TerminalView: UIScrollView, UITextInputTraits, UIKeyInput, UIScrollVi func ensureCaretIsVisible () { let displayBuffer = terminal.displayBuffer - contentOffset = CGPoint (x: 0, y: CGFloat (displayBuffer.lines.count-displayBuffer.rows)*cellDimension.height) + let realCaret = displayBuffer.y + displayBuffer.yBase + let viewportEnd = displayBuffer.yDisp + displayBuffer.rows + + // Only scroll to bottom if the caret is actually off-screen + if realCaret >= viewportEnd || realCaret < displayBuffer.yDisp { + userScrolling = false + terminal.userScrolling = false + contentOffset = CGPoint (x: 0, y: CGFloat (displayBuffer.yBase) * cellDimension.height) + } } public func deleteBackward() {