Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
128 changes: 128 additions & 0 deletions lib/src/components/wall/keyboard/handle-dual-tap.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
} {
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();
});
});
Loading