TerminalView cannot be scrolled when feeding data via DispatchQueue.main.async
**Describe the bug**
On iOS, `TerminalView` cannot be scrolled by the user when data is fed via `DispatchQueue.main.async` from a background thread — the standard pattern for WebSocket connections.
Feeding the same data at the same rate via `Timer.scheduledTimer` works perfectly.
**Root cause**
`DispatchQueue.main.async` fires during all RunLoop modes, including `.tracking` (active while the user is scrolling). Each `feed()` call triggers `updateScroller()` which unconditionally resets `contentOffset` to the bottom. This fights the scroll gesture.
`Timer.scheduledTimer` only fires in `.default` RunLoop mode, which is suspended during `.tracking`. So the Timer naturally pauses data delivery while the user scrolls — that's why it works.
**To Reproduce**
Minimal single-file repro (Demo: [SwiftTermScrollBugRepro.swift.txt](https://github.com/user-attachments/files/25749448/SwiftTermScrollBugRepro.swift.txt)):
1. Create a new iOS project, add SwiftTerm as a package dependency
2. Replace `ViewController.swift` with the attached file
3. Run on device or simulator
4. Tap **"Async"** — 100 lines/sec via `DispatchQueue.global()` → `DispatchQueue.main.async` → `feed()`
5. Try scrolling up — **impossible**
6. Tap **"Stop"**, then **"Timer"** — same data, same rate, via `Timer.scheduledTimer`
7. Try scrolling up — **works fine**
Both feeds deliver identical data at ~100 lines/sec. Only the dispatch mechanism differs.
**Expected behavior**
The user should be able to scroll up through the terminal scrollback buffer while data is being fed via `DispatchQueue.main.async`. This is the standard pattern for any app receiving data from WebSocket, URLSession, or other async sources.
**Smartphone (please complete the following information):**
- Device: iPhone (tested on multiple models)
- OS: iOS 18.x
- Version: SwiftTerm main branch, Xcode 16.x
**Additional context**
### Why this matters
Any iOS app using SwiftTerm with a network backend (WebSocket, SSH via NIO, etc.) will hit this bug, because the natural pattern is:
```swift
// Background thread receives data, dispatches to main for UI update
DispatchQueue.main.async {
terminalView.feed(text: data)
}
```
This is fundamentally broken for scrolling because `DispatchQueue.main.async` fires during `.tracking` RunLoop mode.
A client-side workaround exists (buffer data and drain via `Timer.scheduledTimer` so delivery pauses during scroll tracking), but:
- It requires **every** SwiftTerm consumer to implement the same buffer hack
- It only works for **simple text feeds** — with real terminal sessions (escape sequences, cursor positioning, resize events), we still see rendering issues (lines at wrong positions, `contentOffset.x` drift, inability to scroll through scrollback)
The fix should be in `updateScroller()` itself — it should not reset `contentOffset` while the user is actively scrolling.
### PR #468
[PR #468](https://github.com/migueldeicaza/SwiftTerm/pull/468) by @alvarolb adds a `userScrolling` flag that guards `contentOffset` updates in `updateScroller()`. We applied it to a local fork and it **fixed the core scrolling problem** — the user can scroll up while data is streaming.
However, we ran into secondary `contentOffset` ↔ `yDisp` synchronization issues:
1. **Lines rendered in wrong order** — When the circular buffer wraps during scrolling, `contentOffset` stays fixed but `displayBuffer.lines[N]` returns different content (because `startIndex` shifted).
2. **Can't scroll to the very top / very bottom** — `userScrolling` stays `true` after the gesture ends (only cleared when user scrolls to the exact bottom). New content at the bottom becomes unreachable.
3. **Content glitch on over-scroll** — When bouncing past the top of the scroll range, incorrect rows briefly appear.
We tried splitting `userScrolling` (finger on screen) from `userScrolledBack` (user not at bottom, don't auto-scroll) and various delta adjustments, but couldn't fully resolve the rendering issues (30+ test variations).
**What's the recommended way to keep `contentOffset` and `yDisp` in sync during user scrolling?** Should `yDisp` be continuously updated from `contentOffset` during the scroll gesture? Should `contentOffset` be adjusted when the buffer shifts, and if so, how to prevent it from fighting the scroll?
Any guidance would be very appreciated. Happy to test patches.
5 条评论