Skip to content

Commit b032666

Browse files
committed
feat: forward touch swipe as mouse scroll events for full-screen TUI apps
When a full-screen TUI (e.g. Claude Code, vim, less) uses the alternate screen buffer, UIScrollView has no scrollable content and scrollViewDidScroll: never fires. This means touch swipe gestures are silently dropped instead of reaching the remote program. Changes: - Add UIPanGestureRecognizer to detect vertical swipes independently of UIScrollView's scroll state - Call exports.handleScrollDelta() from the pan gesture only, removing it from scrollViewDidScroll: to prevent spurious events on keyboard show/hide layout changes - Add exports.handleScrollDelta() in term.js (from PR ish-app#2708) which dispatches synthetic WheelEvents into hterm's mouse pipeline; hterm forwards them as xterm mouse scroll sequences when mouse reporting is active or on the alternate screen This enables touch scroll to work in tmux copy mode, allowing users to scroll through conversation history in Claude Code and similar TUIs.
1 parent cc391ff commit b032666

4 files changed

Lines changed: 54 additions & 4 deletions

File tree

app/TerminalView.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ enum OverrideAppearance {
1414
OverrideAppearanceDark,
1515
};
1616

17-
@interface TerminalView : UIView <UITextInput, WKScriptMessageHandler, UIScrollViewDelegate>
17+
@interface TerminalView : UIView <UITextInput, WKScriptMessageHandler, UIScrollViewDelegate, UIGestureRecognizerDelegate>
1818

1919
@property IBInspectable (nonatomic) BOOL canBecomeFirstResponder;
2020

app/TerminalView.m

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ - (void)userContentController:(WKUserContentController *)userContentController d
3131
}
3232
@end
3333

34-
@interface TerminalView ()
34+
@interface TerminalView () {
35+
CGFloat _panLastY;
36+
}
3537

3638
@property (nonatomic) NSMutableArray<UIKeyCommand *> *keyCommands;
3739
@property ScrollbarView *scrollbarView;
@@ -64,6 +66,12 @@ - (void)awakeFromNib {
6466
scrollbarView.bounces = NO;
6567
[self addSubview:scrollbarView];
6668

69+
// Pan gesture recognizer for forwarding scroll events when UIScrollView has no scrollable content
70+
// (e.g. when a full-screen TUI like Claude Code or vim uses the alternate screen)
71+
UIPanGestureRecognizer *panGR = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTUIScrollGesture:)];
72+
panGR.delegate = self;
73+
[scrollbarView addGestureRecognizer:panGR];
74+
6775
UserPreferences *prefs = UserPreferences.shared;
6876
[prefs observe:@[@"capsLockMapping", @"optionMapping", @"backtickMapEscape", @"overrideControlSpace"]
6977
options:0 owner:self usingBlock:^(typeof(self) self) {
@@ -271,6 +279,28 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView {
271279
[self.terminal.webView evaluateJavaScript:[NSString stringWithFormat:@"exports.newScrollTop(%f)", scrollView.contentOffset.y] completionHandler:nil];
272280
}
273281

282+
// Allow our pan GR and UIScrollView's built-in pan GR to fire simultaneously
283+
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)other {
284+
return YES;
285+
}
286+
287+
// Handles vertical swipe gestures when UIScrollView has no scrollable content (alternate screen TUI mode)
288+
- (void)_handleTUIScrollGesture:(UIPanGestureRecognizer *)gesture {
289+
if (gesture.state == UIGestureRecognizerStateBegan) {
290+
_panLastY = [gesture translationInView:self.scrollbarView].y;
291+
return;
292+
}
293+
if (gesture.state != UIGestureRecognizerStateChanged) {
294+
return;
295+
}
296+
CGFloat currentY = [gesture translationInView:self.scrollbarView].y;
297+
CGFloat delta = currentY - _panLastY;
298+
_panLastY = currentY;
299+
if (delta != 0) {
300+
[self.terminal.webView evaluateJavaScript:[NSString stringWithFormat:@"exports.handleScrollDelta(%f)", -delta] completionHandler:nil];
301+
}
302+
}
303+
274304
- (void)setKeyboardAppearance:(UIKeyboardAppearance)keyboardAppearance {
275305
BOOL needsFirstResponderDance = self.isFirstResponder && _keyboardAppearance != keyboardAppearance;
276306
if (needsFirstResponderDance) {

app/iSH.xcconfig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Change this to change all the bundle IDs and app groups
2-
ROOT_BUNDLE_IDENTIFIER = app.ish.iSH
2+
ROOT_BUNDLE_IDENTIFIER = com.manho.ish.iSH
33
// It's easiest to specify your development team ID in the project build settings, but you can alternatively put it here to reduce merge conflicts
4-
DEVELOPMENT_TEAM =
4+
DEVELOPMENT_TEAM = H4X7357NQA
55

66
// Choose logging channels to enable. Separate by spaces. Try "verbose strace".
77
ISH_LOG =

app/terminal/term.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,26 @@ exports.getCharacterSize = () => {
156156
exports.clearScrollback = () => term.clearScrollback();
157157
exports.setUserGesture = () => term.accessibilityReader_.hasUserGesture = true;
158158

159+
// Forward scroll delta as synthetic WheelEvents into hterm's onMouse_ pipeline.
160+
let scrollDeltaRemainder = 0;
161+
exports.handleScrollDelta = (delta) => {
162+
if (term.vt.mouseReport === term.vt.MOUSE_REPORT_DISABLED && term.isPrimaryScreen())
163+
return;
164+
const charH = term.scrollPort_.characterSize.height;
165+
if (!charH) return;
166+
scrollDeltaRemainder += delta;
167+
const lines = Math.trunc(scrollDeltaRemainder / charH);
168+
if (lines === 0) return;
169+
scrollDeltaRemainder -= lines * charH;
170+
for (let i = 0; i < Math.abs(lines); i++) {
171+
term.scrollPort_.screen_.dispatchEvent(new WheelEvent('wheel', {
172+
deltaY: lines > 0 ? 1 : -1,
173+
deltaMode: WheelEvent.DOM_DELTA_LINE,
174+
cancelable: true,
175+
}));
176+
}
177+
};
178+
159179
hterm.openUrl = (url) => native.openLink(url);
160180

161181
native.load();

0 commit comments

Comments
 (0)