Skip to content
Merged
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: 5 additions & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ Source of truth: `handleDualTap` in `lib/src/components/wall/keyboard/handle-dua

`docs/specs/shortcuts.md` tables every binding; this section owns the dispatch behavior behind it.

**Must leave Cmd/Ctrl/Alt chords unhandled except swaps and printed symbols** ([Spatial navigation](#spatial-navigation)): Shift permits any binding; Option/AltGr permits `|`, `%`, `"`, `>`.

**Must use `,` to rename the selected terminal pane or Workspace tab in command mode**, without activating an inactive Workspace.

**Must support Workspace navigation in command mode through bare `1`–`9`, arrows, and `Enter`.** Digits select by strip position; out-of-range positions are consumed without switching. **Never bind `c`, `n`/`p`, `$`, or `&`.** Rename inputs and confirmation dialogs retain their own controls.
Expand Down Expand Up @@ -261,7 +263,7 @@ A split from an existing pane (`|`/`%`/`-`/`"` or the header split buttons) spaw

Dirty Tool close consent: `docs/specs/dor-tool.md` → Closing unsaved Tools.

`x`/`k` (or the kill button, which first leaves passthrough) shows a confirmation with a random lowercase letter; typing it confirms the kill. **Never draw `x`, `k`, or Reopen's `u` as that letter**, so neither a double-tap nor a reopen accepts it. Which closes skip it: `docs/specs/reopen.md` → "The rule"; under Labs one that would show it becomes a pending kill instead (`docs/specs/reopen.md` → "Labs: No-confirm delayed kill"). `Escape`, the cancel button, and clicking another panel cancel; any other key dismisses it.
`x`/`k` (or the kill button, which first leaves passthrough) shows a confirmation with a random lowercase letter; typing it confirms the kill. **Never draw `x`, `k`, or Reopen's `u` as that letter**, so neither a double-tap nor a reopen accepts it. Which closes skip it: `docs/specs/reopen.md` → "The rule"; under Labs one that would show it becomes a pending kill instead (`docs/specs/reopen.md` → "Labs: No-confirm delayed kill"). `Escape`, the cancel button, and clicking another panel cancel; any other answer key dismisses it. **Never answer a kill prompt with a bare modifier or a Cmd/Ctrl/Alt chord.**

**Must return keyboard selection to the next surviving Door after killing a revealed Door**, falling back to previous Doors, then a pane only if no Doors remain. Apply this to confirmed and unasked kills only while the revealed pane, or the Door, is still selected in command mode; cancellation, refusal, or navigating away discards the return target.

Expand Down Expand Up @@ -300,6 +302,8 @@ Source of truth: `resolvePaneElement` in `lib/src/components/wall/resolve-pane-e

## Spatial navigation

**Must leave Alt-modified arrows unhandled.**

**Must resolve arrow navigation through the engine-neutral `WallNav` seam to Lath's `neighborOf`, never a DOM rect scan.** Neighbor eligibility: `docs/specs/tiling-engine.md` → "Layout".

Back-navigation: a breadcrumb tracks the last navigation direction and origin pane, and **the opposite direction must return to the origin instead of doing a spatial lookup**, so asymmetric layouts navigate reversibly.
Expand Down
6 changes: 4 additions & 2 deletions docs/specs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ A focused cross-origin iframe surface swallows the gesture; the proxy shim detec

## Pane actions (command mode)

Modifier handling: [layout.md](layout.md#keyboard-shortcuts-command-mode).

| Key | Action | Description |
|-----|--------|-------------|
| `\|` or `%` | Split left/right | New pane to the right, selected, in passthrough. |
Expand Down Expand Up @@ -45,7 +47,7 @@ Standalone and the desktop playground — a bare Wall (VS Code) leaves these key

| Key | Action | Description |
|-----|--------|-------------|
| `↑` / `↓` / `←` / `→` | Move selection | Navigate panes and doors; opposite directions backtrack between panes. Down with no pane below selects the first door; Up from a door selects the last pane. Up from a top-edge pane highlights the active Workspace tab. |
| `↑` / `↓` / `←` / `→` (without Cmd/Ctrl/Alt) | Move selection | Navigate panes and doors; opposite directions backtrack between panes. Down with no pane below selects the first door; Up from a door selects the last pane. Up from a top-edge pane highlights the active Workspace tab. |
| `←` / `→` (Workspace strip) | Highlight tab / `+` | Traverse in strip order (`docs/specs/layout.md` → Workspace tabs), without activation, stopping at either end. Down returns to the originating live pane, or the first if gone. |
| `⌘`+arrows or `Ctrl`+arrows | Swap surfaces | Swap the two panes' Surfaces; the opposite chord swaps back exactly. Either modifier, every platform; consumed no-op on non-pane chrome. |

Expand Down Expand Up @@ -85,7 +87,7 @@ Every key not claimed above forwards to the embedded page while a screencast pan
| `Shift+F10` / `ContextMenu` | Workspace alert settings | On a focused baseboard alarm button (`docs/specs/alert.md` → Settings dialog). |
| `Shift+F10` / `ContextMenu` | Workspace tab menu | On a focused Workspace tab. |
| `↑` / `↓`, `Home` / `End` | Move in a menu | Tab menu rows; `Enter` chooses. |
| Prompted letter | Confirm kill | Type the letter shown to confirm; other keys reaching the prompt cancel (see layout's dispatch order). |
| Prompted letter (unmodified, Shift optional) | Confirm kill | Type the letter shown; prompt routing: [layout.md](layout.md#kill-confirmation). |

## VS Code host

Expand Down
4 changes: 2 additions & 2 deletions lib/src/components/WorkspaceKillConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import type { ModalLayer } from './design';
import { KillConfirmModal } from './KillConfirm';
import { acceptsKillChar } from './wall/keyboard/handle-kill-confirm';
import { acceptsKillChar, isKillAnswerKey } from './wall/keyboard/handle-kill-confirm';

/** One typed-letter interaction for Workspace close and move, window close, and
* app quit. */
Expand All @@ -28,7 +28,7 @@ export function WorkspaceKillConfirm({ char, onConfirm, onCancel, canConfirm, ti
// ModalFrame handles Escape first on the same capture target.
if (event.key === 'Escape' && event.defaultPrevented) return;
// Cmd+Q is another quit request, never an answer even when the letter is q.
if (event.metaKey || event.ctrlKey || event.altKey || ['Shift', 'Meta', 'Control', 'Alt'].includes(event.key)) return;
if (!isKillAnswerKey(event)) return;
event.preventDefault();
event.stopImmediatePropagation();
const latest = latestRef.current;
Expand Down
36 changes: 36 additions & 0 deletions lib/src/components/wall/keyboard/handle-kill-confirm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { handleKillConfirm } from './handle-kill-confirm';
import type { WallKeyboardCtx } from './types';

function context() {
return { confirmKillRef: { current: { char: 'q' } }, acceptKill: vi.fn(), rejectKill: vi.fn() } as unknown as WallKeyboardCtx;
}

describe('pane kill answers', () => {
it.each([
{ key: 'q', metaKey: true }, { key: 'q', ctrlKey: true }, { key: 'q', altKey: true },
{ key: 'Meta' }, { key: 'Control' }, { key: 'Alt' }, { key: 'Shift' },
])('leaves modifier/chord %j unanswered and available to the host', init => {
const ctx = context();
const event = new KeyboardEvent('keydown', { ...init, cancelable: true });
expect(handleKillConfirm(event, ctx)).toBe(true);
expect(ctx.acceptKill).not.toHaveBeenCalled();
expect(ctx.rejectKill).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});

it.each([{ key: 'q' }, { key: 'Q' }, { key: 'Q', shiftKey: true }])('accepts the typed letter %j', init => {
const ctx = context();
const event = new KeyboardEvent('keydown', { ...init, cancelable: true });
expect(handleKillConfirm(event, ctx)).toBe(true);
expect(ctx.acceptKill).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(true);
});

it('cancels on another ordinary key', () => {
const ctx = context();
handleKillConfirm(new KeyboardEvent('keydown', { key: 'Escape' }), ctx);
expect(ctx.rejectKill).toHaveBeenCalledOnce();
});
});
12 changes: 10 additions & 2 deletions lib/src/components/wall/keyboard/handle-kill-confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { WallKeyboardCtx } from './types';

/**
* Whether a key accepts a staged kill confirmation: a case-insensitive match of
* the confirm letter, so Caps Lock still confirms. Every other key rejects, which
* the confirm letter, so Caps Lock still confirms. Every other answer key rejects, which
* is why the confirmation hijacks each key it sees rather than testing for one.
* Shared with `WorkspaceKillConfirm`, which listens outside every
* Wall (`docs/specs/shortcuts.md` → "Dialogs, menus & prompts").
Expand All @@ -11,13 +11,21 @@ export function acceptsKillChar(key: string, char: string): boolean {
return key.toLowerCase() === char.toLowerCase();
}

/** Modifiers and host chords never answer a typed-letter confirmation. Shift
* may produce an uppercase letter, which acceptsKillChar handles normally. */
export function isKillAnswerKey(event: KeyboardEvent): boolean {
return !event.metaKey && !event.ctrlKey && !event.altKey
&& !['Shift', 'Meta', 'Control', 'Alt'].includes(event.key);
}

/**
* Kill-confirmation second-key handler. Once a kill is staged in confirmKillRef,
* we hijack every key: matching letter accepts, anything else rejects.
* we hijack answer keys: matching letter accepts, anything else rejects.
*/
export function handleKillConfirm(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean {
const ck = ctx.confirmKillRef.current;
if (!ck) return false;
if (!isKillAnswerKey(e)) return true;

e.preventDefault();
e.stopPropagation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ function context(): WallKeyboardCtx {
beforeEach(() => setWorkspaces({ workspaces: [{ id: 'ws-a', name: 'App' }, { id: 'ws-b', name: 'Build' }], activeId: 'ws-a' }));

describe('workspace row navigation', () => {
it('leaves Alt+Arrow to its host instead of navigating the Wall', () => {
const ctx = context();
const event = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, cancelable: true });
expect(handlePaneNavigation(event, ctx, { current: null })).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(ctx.selectWorkspace).not.toHaveBeenCalled();
expect(ctx.nav.findInDirection).not.toHaveBeenCalled();
});

it('moves from the top pane through tabs and + without activating, then returns to the pane', () => {
const ctx = context();
const history: NavHistoryRef = { current: null };
Expand Down
2 changes: 1 addition & 1 deletion lib/src/components/wall/keyboard/handle-pane-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function handlePaneNavigation(
ctx: WallKeyboardCtx,
navHistory: NavHistoryRef,
): boolean {
if (!isArrowKey(e.key) || e.metaKey || e.ctrlKey) {
if (!isArrowKey(e.key) || e.metaKey || e.ctrlKey || e.altKey) {
return false;
}
e.preventDefault();
Expand Down
35 changes: 35 additions & 0 deletions lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,41 @@ function keydownMeta(key: string): KeyboardEvent {
}

describe('handlePaneShortcuts kill behavior', () => {

it.each(['metaKey', 'ctrlKey', 'altKey'] as const)('leaves %s chords out of bare pane actions', modifier => {
for (const key of ['x', 't', '-', 'Enter', ',']) {
const ctx = makeCtx();
const event = new KeyboardEvent('keydown', { key, [modifier]: true, cancelable: true });
expect(handlePaneShortcuts(event, ctx, { current: null }), key).toBe(false);
expect(event.defaultPrevented, key).toBe(false);
expect(ctx.requestKill).not.toHaveBeenCalled();
expect(ctx.enterTerminalMode).not.toHaveBeenCalled();
expect(ctx.setRenamingPaneId).not.toHaveBeenCalled();
expect(ctx.wallActionsRef.current.onSplitV).not.toHaveBeenCalled();
}
});

it.each([
['|', { altKey: true }],
['|', { ctrlKey: true, altKey: true }],
['%', { altKey: true }],
['"', { ctrlKey: true, altKey: true }],
] as const)('accepts the printed split character %s through Option or AltGr', (key, modifiers) => {
const ctx = makeCtx();
const event = new KeyboardEvent('keydown', { key, ...modifiers, cancelable: true });
expect(handlePaneShortcuts(event, ctx, { current: null })).toBe(true);
expect(event.defaultPrevented).toBe(true);
expect(key === '"' ? ctx.wallActionsRef.current.onSplitV : ctx.wallActionsRef.current.onSplitH)
.toHaveBeenCalledWith('pane-a', 'keyboard');
});

it('does not turn a Command chord into a printed symbol binding', () => {
const ctx = makeCtx();
const event = new KeyboardEvent('keydown', { key: '|', metaKey: true, altKey: true, cancelable: true });
expect(handlePaneShortcuts(event, ctx, { current: null })).toBe(false);
expect(event.defaultPrevented).toBe(false);
});

beforeEach(() => vi.clearAllMocks());

it.each(['x', 'k'])('hands the %s gesture to the Wall kill request', (key) => {
Expand Down
6 changes: 5 additions & 1 deletion lib/src/components/wall/keyboard/handle-pane-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@ export function handlePaneShortcuts(
ctx: WallKeyboardCtx,
navHistory: NavHistoryRef,
): boolean {
// Option and AltGr can print the symbol bindings on non-US keyboards.
// Plain host chords still do not become pane actions; Cmd/Ctrl arrows are explicit.
const printedSymbol = e.altKey && ['|', '%', '"', '>'].includes(e.key);
if ((e.altKey && !printedSymbol) || ((e.metaKey || (e.ctrlKey && !printedSymbol)) && !isArrowKey(e.key))) return false;
const sid = ctx.selectedIdRef.current;
// Reopen acts on the Window, so it answers whatever is selected.
if (e.key === 'u' && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (e.key === 'u') {
e.preventDefault();
e.stopPropagation();
ctx.reopenClosed();
Expand Down
Loading