diff --git a/docs/specs/layout.md b/docs/specs/layout.md index fddd7617..1aedc7c1 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -147,7 +147,7 @@ Wall starts in `command` mode by default. Embedders may pass `initialMode="passt ### Passthrough mode - All keyboard input routes to the active session's xterm.js instance -- Only the mode-exit gesture (LCmd → RCmd) is intercepted +- Only the mode-exit gesture (LCmd → RCmd, or LShift → RShift) is intercepted - In the VS Code host, selected workbench chords are mirrored: xterm still processes the key, and Dormouse also asks the extension host to run the matching VS Code command. See [the VS Code host spec](vscode.md) for the allowlist. - Selection overlay shows 2px solid border with glow - Terminal has DOM focus @@ -166,8 +166,8 @@ Wall starts in `command` mode by default. Embedders may pass `initialMode="passt - Focus is deferred via `requestAnimationFrame` to prevent dockview from stealing it **Enter command mode:** -- Left Cmd keydown, then Right Cmd keydown within 500ms -- Detected via capture-phase `keydown` listener on `e.key === 'Meta'` and `e.location` (1 = left, 2 = right) +- Left Cmd keydown, then Right Cmd keydown within 500ms — or the same left-then-right gesture with Shift (Left Shift, then Right Shift within 500ms) +- Detected via capture-phase `keydown` listener on `e.key === 'Meta'` (or `e.key === 'Shift'`) and `e.location` (1 = left, 2 = right). The Meta and Shift tracks are independent, so a Left Cmd followed by a Right Shift does not trigger. - Works even when xterm has DOM focus because listener uses capture phase ## Keyboard shortcuts (command mode) diff --git a/lib/src/components/wall/keyboard/handle-dual-tap.test.ts b/lib/src/components/wall/keyboard/handle-dual-tap.test.ts new file mode 100644 index 00000000..7b956154 --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-dual-tap.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleDualTap } from './handle-dual-tap'; +import type { DualTapState, WallKeyboardCtx } from './types'; + +function makeCtx(mode: 'command' | 'passthrough' = 'passthrough'): { + ctx: WallKeyboardCtx; + exitTerminalMode: ReturnType; +} { + const exitTerminalMode = vi.fn(); + const ctx = { + modeRef: { current: mode }, + exitTerminalMode, + } as unknown as WallKeyboardCtx; + return { ctx, exitTerminalMode }; +} + +function makeState(): DualTapState { + return { + lastCmdSide: { current: null }, + lastCmdTime: { current: 0 }, + lastShiftSide: { current: null }, + lastShiftTime: { current: 0 }, + }; +} + +/** location 1 = left, 2 = right (DOM_KEY_LOCATION_{LEFT,RIGHT}). */ +function keydown(key: string, location: number): KeyboardEvent { + return new KeyboardEvent('keydown', { key, location }); +} + +describe('handleDualTap', () => { + let now = 0; + + beforeEach(() => { + now = 1000; + vi.spyOn(Date, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exits passthrough on left-then-right Meta within 500ms', () => { + const { ctx, exitTerminalMode } = makeCtx('passthrough'); + const state = makeState(); + + expect(handleDualTap(keydown('Meta', 1), ctx, state)).toBe(true); + expect(exitTerminalMode).not.toHaveBeenCalled(); + + now += 200; + expect(handleDualTap(keydown('Meta', 2), ctx, state)).toBe(true); + expect(exitTerminalMode).toHaveBeenCalledTimes(1); + // State resets after a completed gesture so the next press starts fresh. + expect(state.lastCmdSide.current).toBeNull(); + }); + + it('does not exit when the right press lands after the 500ms window', () => { + const { ctx, exitTerminalMode } = makeCtx('passthrough'); + const state = makeState(); + + handleDualTap(keydown('Meta', 1), ctx, state); + now += 500; // boundary is exclusive (< 500), so 500ms is too late + handleDualTap(keydown('Meta', 2), ctx, state); + + expect(exitTerminalMode).not.toHaveBeenCalled(); + }); + + it('does not exit on right-then-left ordering', () => { + const { ctx, exitTerminalMode } = makeCtx('passthrough'); + const state = makeState(); + + handleDualTap(keydown('Meta', 2), ctx, state); + now += 100; + handleDualTap(keydown('Meta', 1), ctx, state); + + expect(exitTerminalMode).not.toHaveBeenCalled(); + }); + + it('exits passthrough on left-then-right Shift, independently of Meta', () => { + const { ctx, exitTerminalMode } = makeCtx('passthrough'); + const state = makeState(); + + expect(handleDualTap(keydown('Shift', 1), ctx, state)).toBe(true); + now += 100; + expect(handleDualTap(keydown('Shift', 2), ctx, state)).toBe(true); + + expect(exitTerminalMode).toHaveBeenCalledTimes(1); + expect(state.lastShiftSide.current).toBeNull(); + }); + + it('does not cross-trigger between Meta and Shift', () => { + const { ctx, exitTerminalMode } = makeCtx('passthrough'); + const state = makeState(); + + // Left Meta then right Shift is not a completed gesture for either track. + handleDualTap(keydown('Meta', 1), ctx, state); + now += 100; + handleDualTap(keydown('Shift', 2), ctx, state); + + expect(exitTerminalMode).not.toHaveBeenCalled(); + }); + + it('consumes Meta and Shift but ignores other keys', () => { + const { ctx } = makeCtx('passthrough'); + const state = makeState(); + + expect(handleDualTap(keydown('Meta', 1), ctx, state)).toBe(true); + expect(handleDualTap(keydown('Shift', 1), ctx, state)).toBe(true); + expect(handleDualTap(keydown('a', 0), ctx, state)).toBe(false); + expect(handleDualTap(keydown('Enter', 0), ctx, state)).toBe(false); + }); + + it('completes the gesture but does not call exit when already in command mode', () => { + const { ctx, exitTerminalMode } = makeCtx('command'); + const state = makeState(); + + handleDualTap(keydown('Meta', 1), ctx, state); + now += 100; + handleDualTap(keydown('Meta', 2), ctx, state); + + expect(exitTerminalMode).not.toHaveBeenCalled(); + // The gesture still resets its state even when the mode guard skips exit. + expect(state.lastCmdSide.current).toBeNull(); + }); +});