Skip to content

Commit 7999f8e

Browse files
committed
feat: add double-tap gesture for configurable terminal action
Opt-in gesture (disabled by default) that sends configurable data on double-tap of the terminal screen. Default data is \x02z (tmux pane zoom toggle). Configurable via gestures.doubleTap.{enabled,data,maxInterval}. Spatial proximity check (50px) prevents unrelated taps triggering. touch-action:manipulation on .xterm-screen disables browser double-tap zoom without needing preventDefault (consistent with d40fa46).
1 parent fda48f3 commit 7999f8e

10 files changed

Lines changed: 196 additions & 0 deletions

File tree

.agents/skills/remobi-setup/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,9 @@ Valid positions: `top-left | top-right | top-centre | bottom-left | bottom-right
393393
| `gestures.scroll.strategy` | `'wheel'` | `'wheel'` (recommended) sends SGR mouse wheel sequences — works in vim, less, htop. `'keys'` sends PageUp/PageDown — simpler, works everywhere |
394394
| `gestures.scroll.sensitivity` | `40` | |
395395
| `gestures.scroll.wheelIntervalMs` | `24` | |
396+
| `gestures.doubleTap.enabled` | `false` | Opt-in double-tap gesture on terminal screen |
397+
| `gestures.doubleTap.data` | `'\x02z'` | Data to send on double-tap (default: tmux zoom toggle) |
398+
| `gestures.doubleTap.maxInterval` | `300` | Max milliseconds between taps |
396399

397400
### Font
398401

src/config-schema.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,16 +190,30 @@ const scrollResolvedSchema = v.strictObject({
190190
wheelIntervalMs: finiteNumber,
191191
})
192192

193+
const doubleTapOverridesSchema = v.strictObject({
194+
enabled: v.optional(v.boolean()),
195+
data: v.optional(v.string()),
196+
maxInterval: v.optional(finiteNumber),
197+
})
198+
199+
const doubleTapResolvedSchema = v.strictObject({
200+
enabled: v.boolean(),
201+
data: v.string(),
202+
maxInterval: finiteNumber,
203+
})
204+
193205
const gestureOverridesSchema = v.strictObject({
194206
swipe: v.optional(swipeOverridesSchema),
195207
pinch: v.optional(pinchOverridesSchema),
196208
scroll: v.optional(scrollOverridesSchema),
209+
doubleTap: v.optional(doubleTapOverridesSchema),
197210
})
198211

199212
const gestureResolvedSchema = v.strictObject({
200213
swipe: swipeResolvedSchema,
201214
pinch: pinchResolvedSchema,
202215
scroll: scrollResolvedSchema,
216+
doubleTap: doubleTapResolvedSchema,
203217
})
204218

205219
// --- Mobile ---

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const defaultGestures: RemobiConfig['gestures'] = {
3030
},
3131
pinch: { enabled: false },
3232
scroll: { enabled: true, sensitivity: 40, strategy: 'wheel', wheelIntervalMs: 24 },
33+
doubleTap: { enabled: false, data: '\x02z', maxInterval: 300 },
3334
}
3435

3536
/** Default row 1 buttons (prefix + nav) */

src/controls/help.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ function renderGestures(config: RemobiConfig): DocumentFragment {
4646
}
4747
}
4848

49+
if (config.gestures.doubleTap.enabled) {
50+
table.appendChild(row('Double-tap', 'Toggle pane zoom (configurable)'))
51+
}
52+
4953
if (table.rows.length === 0) {
5054
table.appendChild(row('None', 'All gestures are disabled in config'))
5155
}

src/gestures/double-tap.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { DoubleTapConfig, XTerminal } from '../types'
2+
import { haptic } from '../util/haptic'
3+
import { sendData } from '../util/terminal'
4+
5+
const MAX_TAP_MOVEMENT = 10
6+
const MAX_TAP_DISTANCE = 50
7+
8+
/** Pure logic: is this a valid double-tap? */
9+
export function isDoubleTap(
10+
dt: number,
11+
distance: number,
12+
maxInterval: number,
13+
maxDistance: number,
14+
): boolean {
15+
return dt > 0 && dt <= maxInterval && distance <= maxDistance
16+
}
17+
18+
/** Attach double-tap gesture detection to the xterm screen */
19+
export function attachDoubleTapGesture(
20+
term: XTerminal,
21+
config: DoubleTapConfig,
22+
isDrawerOpen: () => boolean,
23+
): void {
24+
let lastTapTime = 0
25+
let lastTapX = 0
26+
let lastTapY = 0
27+
let startX = 0
28+
let startY = 0
29+
30+
function onTouchStart(e: TouchEvent): void {
31+
if (e.touches.length !== 1) return
32+
const touch = e.touches[0]
33+
if (!touch) return
34+
startX = touch.clientX
35+
startY = touch.clientY
36+
}
37+
38+
function onTouchEnd(e: TouchEvent): void {
39+
if (isDrawerOpen() || e.changedTouches.length !== 1) return
40+
const touch = e.changedTouches[0]
41+
if (!touch) return
42+
43+
// Reject if finger moved too far (was a scroll/swipe, not a tap)
44+
const moveDx = touch.clientX - startX
45+
const moveDy = touch.clientY - startY
46+
if (Math.sqrt(moveDx * moveDx + moveDy * moveDy) > MAX_TAP_MOVEMENT) return
47+
48+
const now = Date.now()
49+
const dt = now - lastTapTime
50+
const tapDx = touch.clientX - lastTapX
51+
const tapDy = touch.clientY - lastTapY
52+
const distance = Math.sqrt(tapDx * tapDx + tapDy * tapDy)
53+
54+
if (isDoubleTap(dt, distance, config.maxInterval, MAX_TAP_DISTANCE)) {
55+
sendData(term, config.data)
56+
haptic()
57+
// Reset to prevent triple-tap
58+
lastTapTime = 0
59+
} else {
60+
lastTapTime = now
61+
lastTapX = touch.clientX
62+
lastTapY = touch.clientY
63+
}
64+
}
65+
66+
function attach(): void {
67+
const screen = document.querySelector('.xterm-screen')
68+
if (!screen) {
69+
setTimeout(attach, 200)
70+
return
71+
}
72+
// oxlint-disable-next-line typescript/consistent-type-assertions -- DOM addEventListener types Event, not TouchEvent
73+
screen.addEventListener('touchstart', (e: Event) => onTouchStart(e as TouchEvent), {
74+
passive: true,
75+
})
76+
// oxlint-disable-next-line typescript/consistent-type-assertions -- DOM addEventListener types Event, not TouchEvent
77+
screen.addEventListener('touchend', (e: Event) => onTouchEnd(e as TouchEvent), {
78+
passive: true,
79+
})
80+
}
81+
82+
attach()
83+
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createFontControls } from './controls/font-size'
66
import { createHelpOverlay } from './controls/help'
77
import { createScrollButtons } from './controls/scroll-buttons'
88
import { createDrawer } from './drawer/drawer'
9+
import { attachDoubleTapGesture } from './gestures/double-tap'
910
import { createGestureLock } from './gestures/lock'
1011
import { attachPinchGestures } from './gestures/pinch'
1112
import { attachScrollGesture } from './gestures/scroll'
@@ -163,6 +164,9 @@ export function init(
163164
if (config.gestures.scroll.enabled) {
164165
attachScrollGesture(term, config.gestures.scroll, gestureLock, drawer.isOpen)
165166
}
167+
if (config.gestures.doubleTap.enabled) {
168+
attachDoubleTapGesture(term, config.gestures.doubleTap, drawer.isOpen)
169+
}
166170

167171
// Height management
168172
initHeightManager(toolbar)

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,19 @@ export interface ScrollConfig {
7474
readonly wheelIntervalMs: number
7575
}
7676

77+
/** Double-tap gesture configuration */
78+
export interface DoubleTapConfig {
79+
readonly enabled: boolean
80+
readonly data: string
81+
readonly maxInterval: number
82+
}
83+
7784
/** Gesture configuration */
7885
export interface GestureConfig {
7986
readonly swipe: SwipeConfig
8087
readonly pinch: PinchConfig
8188
readonly scroll: ScrollConfig
89+
readonly doubleTap: DoubleTapConfig
8290
}
8391

8492
/** Mobile-specific behaviour configuration */

styles/base.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ body {
88
@media (pointer: coarse) {
99
.xterm-screen {
1010
overscroll-behavior: none;
11+
touch-action: manipulation;
1112
}
1213
}
1314

tests/config-validate.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,37 @@ describe('assertValidConfigOverrides', () => {
9999
expect(message).toContain('string')
100100
})
101101

102+
test('accepts valid doubleTap overrides', () => {
103+
expect(() =>
104+
assertValidConfigOverrides({
105+
gestures: { doubleTap: { enabled: true } },
106+
}),
107+
).not.toThrow()
108+
expect(() =>
109+
assertValidConfigOverrides({
110+
gestures: { doubleTap: { data: '\x01z', maxInterval: 500 } },
111+
}),
112+
).not.toThrow()
113+
})
114+
115+
test('rejects non-string doubleTap data', () => {
116+
const message = getValidationMessage(
117+
{ gestures: { doubleTap: { data: 42 } } },
118+
assertValidConfigOverrides,
119+
)
120+
expect(message).toContain('config.gestures.doubleTap.data')
121+
expect(message).toContain('string')
122+
})
123+
124+
test('rejects non-number doubleTap maxInterval', () => {
125+
const message = getValidationMessage(
126+
{ gestures: { doubleTap: { maxInterval: 'fast' } } },
127+
assertValidConfigOverrides,
128+
)
129+
expect(message).toContain('config.gestures.doubleTap.maxInterval')
130+
expect(message).toContain('number')
131+
})
132+
102133
test('accepts valid partial mobile overrides including null initData', () => {
103134
expect(() => assertValidConfigOverrides({ mobile: { initData: null } })).not.toThrow()
104135
expect(() => assertValidConfigOverrides({ mobile: { initData: '\x02z' } })).not.toThrow()
@@ -319,6 +350,7 @@ describe('assertValidResolvedConfig', () => {
319350
},
320351
pinch: { enabled: false },
321352
scroll: { enabled: true, sensitivity: 40, strategy: 'wheel', wheelIntervalMs: 24 },
353+
doubleTap: { enabled: false, data: '\x02z', maxInterval: 300 },
322354
},
323355
mobile: { initData: null, widthThreshold: 768 },
324356
floatingButtons: [],

tests/gestures.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from 'vitest'
2+
import { isDoubleTap } from '../src/gestures/double-tap'
23
import { createGestureLock, resetLock, tryLock } from '../src/gestures/lock'
34
import { clampFontSize, touchDistance } from '../src/gestures/pinch'
45
import {
@@ -291,6 +292,51 @@ describe('touchToCell', () => {
291292
})
292293
})
293294

295+
describe('isDoubleTap', () => {
296+
const maxInterval = 300
297+
const maxDistance = 50
298+
299+
test('within interval and distance returns true', () => {
300+
expect(isDoubleTap(200, 30, maxInterval, maxDistance)).toBe(true)
301+
})
302+
303+
test('exceeds interval returns false', () => {
304+
expect(isDoubleTap(400, 30, maxInterval, maxDistance)).toBe(false)
305+
})
306+
307+
test('zero dt (simultaneous) returns false', () => {
308+
expect(isDoubleTap(0, 10, maxInterval, maxDistance)).toBe(false)
309+
})
310+
311+
test('exact interval boundary returns true', () => {
312+
expect(isDoubleTap(300, 30, maxInterval, maxDistance)).toBe(true)
313+
})
314+
315+
test('negative dt returns false', () => {
316+
expect(isDoubleTap(-100, 10, maxInterval, maxDistance)).toBe(false)
317+
})
318+
319+
test('within distance returns true', () => {
320+
expect(isDoubleTap(200, 49, maxInterval, maxDistance)).toBe(true)
321+
})
322+
323+
test('exceeds distance returns false', () => {
324+
expect(isDoubleTap(200, 60, maxInterval, maxDistance)).toBe(false)
325+
})
326+
327+
test('exact distance boundary returns true', () => {
328+
expect(isDoubleTap(200, 50, maxInterval, maxDistance)).toBe(true)
329+
})
330+
331+
test('within time but too far apart returns false', () => {
332+
expect(isDoubleTap(100, 80, maxInterval, maxDistance)).toBe(false)
333+
})
334+
335+
test('close enough but too slow returns false', () => {
336+
expect(isDoubleTap(500, 10, maxInterval, maxDistance)).toBe(false)
337+
})
338+
})
339+
294340
describe('resolveScrollAction', () => {
295341
test('keys strategy returns pageSeq for up', () => {
296342
const action = resolveScrollAction('up', 'keys', { x: 1, y: 1 }, 0, 1000, 50)

0 commit comments

Comments
 (0)