Skip to content

Commit 0bda5d4

Browse files
committed
Fix mobile scrolling by defaulting to wheel semantics
Unify finger and side-button scrolling behind the same strategy path so OpenCode, Claude Code, and plain tmux shells behave consistently, while keeping key-based paging as an explicit fallback.
1 parent 07f4d3a commit 0bda5d4

10 files changed

Lines changed: 178 additions & 42 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Turns a ttyd web terminal into a touch-optimised tmux client with toolbar, gestu
99
- **Two-row toolbar** — Esc, Ctrl (sticky modifier), Tab, arrows, C-c, Enter, tmux prev/next, paste, drawer toggle
1010
- **Context-aware drawers** — tab-based command drawer (tmux, custom contexts) with title-based auto-switching
1111
- **Swipe gestures** — swipe left/right to switch tmux windows
12+
- **Touch scrolling modes** — default wheel scrolling for broad app compatibility, optional key paging
1213
- **Pinch-to-zoom** — adjust font size with two-finger pinch
1314
- **Font controls** — dedicated +/- buttons, top-right overlay
1415
- **Help overlay** — in-app reference for all controls and gestures
@@ -96,13 +97,24 @@ export default defineConfig({
9697
},
9798
gestures: {
9899
swipe: { enabled: true, threshold: 80, maxDuration: 400 },
100+
scroll: {
101+
enabled: true,
102+
strategy: 'wheel',
103+
sensitivity: 40,
104+
wheelIntervalMs: 24,
105+
},
99106
pinch: { enabled: true },
100107
},
101108
})
102109
```
103110

104111
All fields are optional — defaults are filled in via `defineConfig()`.
105112

113+
`gestures.scroll.strategy` controls touch scroll behaviour:
114+
115+
- `wheel` (default): sends SGR mouse wheel events with touch-mapped terminal coordinates.
116+
- `keys`: sends `PageUp` / `PageDown` for app-level paging when preferred.
117+
106118
### Programmatic API
107119

108120
```typescript

‎cli.ts‎

100644100755
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ export default defineConfig({
129129
// },
130130
// toolbar: { row1: [...], row2: [...] },
131131
// drawer: { commands: [...] },
132-
// gestures: { swipe: { enabled: true }, pinch: { enabled: true } },
132+
// gestures: {
133+
// swipe: { enabled: true },
134+
// pinch: { enabled: true },
135+
// scroll: { strategy: 'wheel', sensitivity: 40 },
136+
// },
133137
})
134138
`
135139
await Bun.write(targetPath, template)

‎src/config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const defaultFont: WebmuxConfig['font'] = {
1414
const defaultGestures: WebmuxConfig['gestures'] = {
1515
swipe: { enabled: true, threshold: 80, maxDuration: 400 },
1616
pinch: { enabled: false },
17-
scroll: { enabled: true, sensitivity: 40 },
17+
scroll: { enabled: true, sensitivity: 40, strategy: 'wheel', wheelIntervalMs: 24 },
1818
}
1919

2020
/** Default row 1 buttons (modifiers + nav) */
@@ -51,7 +51,7 @@ export const defaultDrawerCommands: readonly DrawerCommand[] = [
5151
{ label: 'Git', seq: '\x02g' },
5252
{ label: 'Files', seq: '\x02y' },
5353
{ label: 'Links', seq: '\x02u' },
54-
{ label: 'PgUp', seq: '\x02\x1b[5~' },
54+
{ label: 'PgUp', seq: '\x1b[5~' },
5555
{ label: 'PgDn', seq: '\x1b[6~' },
5656
{ label: 'Copy', seq: '\x02 ' },
5757
{ label: 'Help', seq: '\x02?' },

‎src/controls/scroll-buttons.ts‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import type { XTerminal } from '../types'
1+
import { pageSeq, scrollSeq } from '../gestures/scroll'
2+
import type { ScrollConfig, XTerminal } from '../types'
23
import { el } from '../util/dom'
34
import { conditionalFocus, isKeyboardOpen } from '../util/keyboard'
45
import { sendData } from '../util/terminal'
56

6-
const PGUP_SEQ = '\x02\x1b[5~'
7-
const PGDN_SEQ = '\x1b[6~'
8-
97
const LONG_PRESS_DELAY = 300
108
const REPEAT_INTERVAL = 100
119
const FADE_TIMEOUT = 2000
1210

1311
/** Create floating scroll buttons (PgUp ▲ / PgDn ▼) */
14-
export function createScrollButtons(term: XTerminal): { element: HTMLDivElement } {
12+
export function createScrollButtons(
13+
term: XTerminal,
14+
config: ScrollConfig,
15+
): { element: HTMLDivElement } {
1516
const container = el('div', { id: 'wt-scroll-buttons' })
1617

1718
const upBtn = el('button', { 'aria-label': 'Page Up' }, '\u25B2')
@@ -20,13 +21,31 @@ export function createScrollButtons(term: XTerminal): { element: HTMLDivElement
2021
container.appendChild(upBtn)
2122
container.appendChild(downBtn)
2223

23-
function wireButton(button: HTMLButtonElement, seq: string): void {
24+
function terminalCenterCell(): { x: number; y: number } {
25+
const cols = typeof term.cols === 'number' && term.cols > 0 ? Math.round(term.cols) : 80
26+
const rows = typeof term.rows === 'number' && term.rows > 0 ? Math.round(term.rows) : 24
27+
return {
28+
x: Math.max(1, Math.floor((cols + 1) / 2)),
29+
y: Math.max(1, Math.floor((rows + 1) / 2)),
30+
}
31+
}
32+
33+
function sequence(direction: 'up' | 'down'): string {
34+
if (config.strategy === 'keys') {
35+
return pageSeq(direction)
36+
}
37+
38+
const { x, y } = terminalCenterCell()
39+
return scrollSeq(direction, x, y)
40+
}
41+
42+
function wireButton(button: HTMLButtonElement, direction: 'up' | 'down'): void {
2443
let repeatTimer: ReturnType<typeof setInterval> | undefined
2544
let delayTimer: ReturnType<typeof setTimeout> | undefined
2645

2746
function send(): void {
2847
const kbWasOpen = isKeyboardOpen()
29-
sendData(term, seq)
48+
sendData(term, sequence(direction))
3049
conditionalFocus(term, kbWasOpen)
3150
}
3251

@@ -65,8 +84,8 @@ export function createScrollButtons(term: XTerminal): { element: HTMLDivElement
6584
})
6685
}
6786

68-
wireButton(upBtn, PGUP_SEQ)
69-
wireButton(downBtn, PGDN_SEQ)
87+
wireButton(upBtn, 'up')
88+
wireButton(downBtn, 'down')
7089

7190
// Auto-fade logic
7291
let fadeTimer: ReturnType<typeof setTimeout> | undefined

‎src/gestures/scroll.ts‎

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,52 @@ export function averageY(t0: { clientY: number }, t1: { clientY: number }): numb
99
}
1010

1111
/** SGR mouse wheel escape sequence for a given direction */
12-
export function scrollSeq(direction: 'up' | 'down'): string {
13-
return direction === 'up' ? '\x1b[\x3c64;1;1M' : '\x1b[\x3c65;1;1M'
12+
export function scrollSeq(direction: 'up' | 'down', x: number, y: number): string {
13+
const code = direction === 'up' ? 64 : 65
14+
return `\x1b[\x3c${code};${x};${y}M`
15+
}
16+
17+
/** Page navigation key sequence for a given direction */
18+
export function pageSeq(direction: 'up' | 'down'): string {
19+
return direction === 'up' ? '\x1b[5~' : '\x1b[6~'
20+
}
21+
22+
function clamp(value: number, min: number, max: number): number {
23+
return Math.min(max, Math.max(min, value))
24+
}
25+
26+
function terminalGrid(screenRect: DOMRect, term: XTerminal): { cols: number; rows: number } {
27+
const colsFromTerm = term.cols
28+
const rowsFromTerm = term.rows
29+
if (typeof colsFromTerm === 'number' && typeof rowsFromTerm === 'number') {
30+
if (colsFromTerm > 0 && rowsFromTerm > 0) {
31+
return { cols: Math.round(colsFromTerm), rows: Math.round(rowsFromTerm) }
32+
}
33+
}
34+
35+
const measure = document.querySelector('.xterm-char-measure-element')
36+
if (measure instanceof HTMLElement) {
37+
const measureRect = measure.getBoundingClientRect()
38+
if (measureRect.width > 0 && measureRect.height > 0) {
39+
const cols = Math.max(1, Math.round(screenRect.width / measureRect.width))
40+
const rows = Math.max(1, Math.round(screenRect.height / measureRect.height))
41+
return { cols, rows }
42+
}
43+
}
44+
45+
return { cols: 80, rows: 24 }
46+
}
47+
48+
function touchToCell(touch: Touch, screen: HTMLElement, term: XTerminal): { x: number; y: number } {
49+
const rect = screen.getBoundingClientRect()
50+
const { cols, rows } = terminalGrid(rect, term)
51+
const width = Math.max(1, rect.width)
52+
const height = Math.max(1, rect.height)
53+
const relX = clamp(touch.clientX - rect.left, 0, width)
54+
const relY = clamp(touch.clientY - rect.top, 0, height)
55+
const x = clamp(Math.floor((relX / width) * cols) + 1, 1, cols)
56+
const y = clamp(Math.floor((relY / height) * rows) + 1, 1, rows)
57+
return { x, y }
1458
}
1559

1660
/** Attach single-finger vertical scroll to the xterm screen */
@@ -23,8 +67,11 @@ export function attachScrollGesture(
2367
let startY = 0
2468
let lastY = 0
2569
let accDelta = 0
70+
let lastWheelAt = 0
71+
let screenEl: HTMLElement | null = null
2672

27-
function onTouchStart(e: TouchEvent): void {
73+
function onTouchStart(e: Event): void {
74+
if (!(e instanceof TouchEvent)) return
2875
if (e.touches.length === 1) {
2976
const t = e.touches[0]
3077
if (!t) return
@@ -34,7 +81,8 @@ export function attachScrollGesture(
3481
}
3582
}
3683

37-
function onTouchMove(e: TouchEvent): void {
84+
function onTouchMove(e: Event): void {
85+
if (!(e instanceof TouchEvent)) return
3886
if (e.touches.length !== 1 || isDrawerOpen()) return
3987
const t = e.touches[0]
4088
if (!t) return
@@ -56,33 +104,50 @@ export function attachScrollGesture(
56104
lastY = y
57105
accDelta += moveDy
58106

59-
// Send one wheel event per sensitivity-worth of pixels
107+
// Send one scroll action per sensitivity-worth of pixels
60108
while (Math.abs(accDelta) >= config.sensitivity) {
61109
const dir = accDelta < 0 ? 'down' : 'up'
62-
sendData(term, scrollSeq(dir))
110+
111+
if (config.strategy === 'keys') {
112+
sendData(term, pageSeq(dir))
113+
} else {
114+
const now = Date.now()
115+
if (now - lastWheelAt < config.wheelIntervalMs) break
116+
lastWheelAt = now
117+
118+
const screen = screenEl
119+
if (!screen) break
120+
const { x, y: row } = touchToCell(t, screen, term)
121+
sendData(term, scrollSeq(dir, x, row))
122+
}
123+
63124
accDelta -= (accDelta < 0 ? -1 : 1) * config.sensitivity
64125
}
65126
}
66127

67-
function onTouchEnd(): void {
128+
function onTouchEnd(e: Event): void {
129+
if (!(e instanceof TouchEvent)) return
68130
if (lock.current === 'scroll') {
69131
resetLock(lock)
70132
}
71133
}
72134

73135
function attach(): void {
74136
const screen = document.querySelector('.xterm-screen')
75-
if (!screen) {
137+
if (!(screen instanceof HTMLElement)) {
76138
setTimeout(attach, 200)
77139
return
78140
}
79-
screen.addEventListener('touchstart', (e: Event) => onTouchStart(e as TouchEvent), {
141+
142+
screenEl = screen
143+
screen.addEventListener('touchstart', onTouchStart, {
80144
passive: true,
81145
})
82-
screen.addEventListener('touchmove', (e: Event) => onTouchMove(e as TouchEvent), {
146+
screen.addEventListener('touchmove', onTouchMove, {
83147
passive: false,
84148
})
85-
screen.addEventListener('touchend', () => onTouchEnd(), { passive: true })
149+
screen.addEventListener('touchend', onTouchEnd, { passive: true })
150+
screen.addEventListener('touchcancel', onTouchEnd, { passive: true })
86151
}
87152

88153
attach()

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function init(config: WebmuxConfig = defaultConfig): void {
6161
document.body.appendChild(helpOverlay)
6262

6363
// Scroll buttons
64-
const { element: scrollButtons } = createScrollButtons(term)
64+
const { element: scrollButtons } = createScrollButtons(term, config.gestures.scroll)
6565
document.body.appendChild(scrollButtons)
6666

6767
// Gestures

‎src/types.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,15 @@ export interface PinchConfig {
6262
readonly enabled: boolean
6363
}
6464

65+
/** Scroll gesture configuration */
66+
export type ScrollStrategy = 'keys' | 'wheel'
67+
6568
/** Scroll gesture configuration */
6669
export interface ScrollConfig {
6770
readonly enabled: boolean
6871
readonly sensitivity: number
72+
readonly strategy: ScrollStrategy
73+
readonly wheelIntervalMs: number
6974
}
7075

7176
/** Gesture configuration */
@@ -99,6 +104,8 @@ export type DeepPartial<T> = {
99104
* Avoids importing the full xterm package.
100105
*/
101106
export interface XTerminal {
107+
cols?: number
108+
rows?: number
102109
options: {
103110
fontSize: number
104111
theme?: Record<string, string>

‎tests/commands.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ describe('defaultDrawerCommands', () => {
1313
}
1414
})
1515

16-
test('all seqs except PgDn start with tmux prefix (Ctrl-b)', () => {
16+
test('all non-scroll command seqs start with tmux prefix (Ctrl-b)', () => {
1717
for (const cmd of defaultDrawerCommands) {
18-
if (cmd.label === 'PgDn') {
19-
// PgDn sends raw escape — works inside copy mode without prefix
18+
if (cmd.label === 'PgUp' || cmd.label === 'PgDn') {
19+
// Scroll keys are app-level by default for better cross-app compatibility.
2020
expect(cmd.seq.startsWith('\x02')).toBe(false)
2121
} else {
2222
expect(cmd.seq.startsWith('\x02')).toBe(true)

‎tests/gestures.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from 'bun:test'
22
import { createGestureLock, resetLock, tryLock } from '../src/gestures/lock'
33
import { clampFontSize, touchDistance } from '../src/gestures/pinch'
4-
import { averageY, scrollSeq } from '../src/gestures/scroll'
4+
import { averageY, pageSeq, scrollSeq } from '../src/gestures/scroll'
55
import { isValidSwipe } from '../src/gestures/swipe'
66

77
describe('isValidSwipe', () => {
@@ -135,11 +135,21 @@ describe('averageY', () => {
135135

136136
describe('scrollSeq', () => {
137137
test('returns SGR mouse wheel up sequence', () => {
138-
expect(scrollSeq('up')).toBe('\x1b[<64;1;1M')
138+
expect(scrollSeq('up', 12, 8)).toBe('\x1b[<64;12;8M')
139139
})
140140

141141
test('returns SGR mouse wheel down sequence', () => {
142-
expect(scrollSeq('down')).toBe('\x1b[<65;1;1M')
142+
expect(scrollSeq('down', 2, 3)).toBe('\x1b[<65;2;3M')
143+
})
144+
})
145+
146+
describe('pageSeq', () => {
147+
test('returns page up sequence', () => {
148+
expect(pageSeq('up')).toBe('\x1b[5~')
149+
})
150+
151+
test('returns page down sequence', () => {
152+
expect(pageSeq('down')).toBe('\x1b[6~')
143153
})
144154

145155
test('uses natural scroll direction (negative delta → down)', async () => {
@@ -156,7 +166,6 @@ describe('scrollSeq', () => {
156166
const source = readFileSync(resolve(import.meta.dir, '../src/gestures/scroll.ts'), 'utf-8')
157167
// Source must use \x3c (hex escape) not literal < in SGR sequences
158168
// to avoid breaking HTML parsing when bundled into inline <script>
159-
expect(source).toContain('\\x3c64;1;1M')
160-
expect(source).toContain('\\x3c65;1;1M')
169+
expect(source).toContain('\\x3c${code};${x};${y}M')
161170
})
162171
})

0 commit comments

Comments
 (0)