From 65cc5a1d1033990bee21009a46bfe73dd449f33d Mon Sep 17 00:00:00 2001 From: autoantohaki Date: Wed, 21 Oct 2026 20:15:08 -0300 Subject: [PATCH] feat: add layered keybindings with help and key hints --- CHANGELOG.md | 1 + README.md | 25 +++++- docs/architecture.md | 3 +- docs/keyboard.md | 52 +++++++++++ src/demo/DemoApp.tsx | 46 +++++++--- src/index.ts | 1 + src/keys/HelpOverlay.tsx | 65 ++++++++++++++ src/keys/KeyHints.tsx | 26 ++++++ src/keys/chord.ts | 149 ++++++++++++++++++++++++++++++++ src/keys/index.ts | 27 ++++++ src/keys/react.tsx | 76 ++++++++++++++++ src/keys/registry.ts | 181 +++++++++++++++++++++++++++++++++++++++ tests/chord.test.ts | 52 +++++++++++ tests/cli.test.ts | 2 +- tests/demo.test.tsx | 17 ++++ tests/helpers.ts | 29 +++++++ tests/keymap.test.tsx | 89 +++++++++++++++++++ tests/registry.test.ts | 116 +++++++++++++++++++++++++ 18 files changed, 940 insertions(+), 17 deletions(-) create mode 100644 docs/keyboard.md create mode 100644 src/keys/HelpOverlay.tsx create mode 100644 src/keys/KeyHints.tsx create mode 100644 src/keys/chord.ts create mode 100644 src/keys/index.ts create mode 100644 src/keys/react.tsx create mode 100644 src/keys/registry.ts create mode 100644 tests/chord.test.ts create mode 100644 tests/demo.test.tsx create mode 100644 tests/helpers.ts create mode 100644 tests/keymap.test.tsx create mode 100644 tests/registry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c463f84..7728926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,3 +3,4 @@ ## Unreleased - Added the full-screen `ShellFrame`, the `runShell` renderer, Relay themes, and the `relay-shell` demonstration command. +- Added layered keybindings with `KeymapProvider`, `useKeybindings`, key specs, `HelpOverlay`, and `KeyHints`. diff --git a/README.md b/README.md index 5452332..86ccc70 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ npm install npm run demo ``` -Press `q` to quit. To print one frame without opening the interactive screen: +Press `?` for the keyboard reference and `q` to quit. To print one frame without opening the interactive screen: ```sh npx tsx src/cli.tsx --demo --snapshot @@ -30,6 +30,29 @@ await runShell( `runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits. +## Keybindings + +Components declare the keys they handle, and Relay Shell routes each key press to the right one. Overlays sit above panes, and panes sit above application-wide keys. + +```tsx +import {useApp} from 'ink'; +import {KeyHints, KeymapProvider, ShellFrame, runShell, useKeybindings} from '@relay-cli/shell'; + +const App = () => { + const {exit} = useApp(); + useKeybindings([{keys: ['q', 'ctrl+c'], description: 'Quit', run: () => exit()}]); + return } />; +}; + +await runShell( + + + , +); +``` + +See [the keybinding guide](./docs/keyboard.md) for key specs, layers, and text input. + ## Development ```sh diff --git a/docs/architecture.md b/docs/architecture.md index aaf1f3a..67a7132 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,5 +4,6 @@ Relay Shell is the frame a full-screen terminal application lives in. It does no 1. `runShell` renders an application on the alternate screen and resolves when it exits. 2. `ShellFrame` sizes the application to the terminal and reserves a title row and a footer. +3. `KeymapProvider` reads the keyboard once and asks a `KeyRegistry` which binding should receive each key. See [keybindings](./keyboard.md). -Layout, focus, keybindings, and the status bar are added as separate modules so an application can use only the parts it needs. +Layout, focus, and the status bar are added as separate modules so an application can use only the parts it needs. diff --git a/docs/keyboard.md b/docs/keyboard.md new file mode 100644 index 0000000..d425508 --- /dev/null +++ b/docs/keyboard.md @@ -0,0 +1,52 @@ +# Keybindings + +Relay Shell reads the keyboard once, in `KeymapProvider`, and routes each key press through a registry of bindings. Components declare the keys they handle with `useKeybindings`; they never call Ink's `useInput` directly, so two components cannot both react to the same key by accident. + +```tsx +useKeybindings([ + {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'Application', run: () => exit()}, + {keys: 'ctrl+s', description: 'Save', run: save}, +]); +``` + +## Key specs + +A spec is a key name with optional modifiers joined by `+`. + +| Spec | Meaning | +| -------------------------------------------------------- | ---------------------------------------------------------- | +| `q`, `?`, `/` | A printable character. Letters are case-sensitive. | +| `Q`, `shift+q` | A capital letter | +| `ctrl+s`, `alt+x` | A modified key. `meta` and `option` are aliases for `alt`. | +| `shift+tab` | A modified named key | +| `up`, `down`, `left`, `right` | Arrow keys | +| `pageup`, `pagedown`, `home`, `end` | Navigation keys (`pgup` and `pgdn` also work) | +| `enter`, `escape`, `tab`, `space`, `backspace`, `delete` | Named keys (`esc` and `return` also work) | + +## Layers + +Every set of bindings belongs to a layer. Higher layers are asked first: + +| Layer | Value | Typical use | +| --------- | ----- | --------------------------- | +| `global` | 0 | Quit, help, switching views | +| `pane` | 10 | Keys for the focused pane | +| `overlay` | 20 | Dialogs and the help screen | + +Within a layer the most recently mounted set wins. A handler that returns `false` passes the key on to the next set. + +An `exclusive` set stops keys it does not handle from reaching lower layers. A `fallback` receives keys that none of its bindings matched, which is how a text field accepts typing while the application still owns `Escape` and `Enter`: + +```tsx +useKeybindings([{keys: 'enter', run: submit}], { + layer: keyLayers.pane, + fallback: (input) => { + setDraft((draft) => draft + input); + return true; + }, +}); +``` + +## Help and hints + +Bindings with a `description` are listed by `HelpOverlay`, grouped by `group`, and summarized on one line by `KeyHints`. A key that a higher layer already handles is listed only once, under the binding that will receive it. diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx index 66b39bb..ab8fa9b 100644 --- a/src/demo/DemoApp.tsx +++ b/src/demo/DemoApp.tsx @@ -1,5 +1,7 @@ -import {Box, Text, useApp, useInput} from 'ink'; +import {useState} from 'react'; +import {Box, Text, useApp} from 'ink'; import {ShellFrame} from '../frame/index.js'; +import {HelpOverlay, KeyHints, KeymapProvider, useKeybindings} from '../keys/index.js'; import {relayTheme} from '../theme.js'; import type {ShellTheme} from '../theme.js'; @@ -11,28 +13,44 @@ export interface DemoAppProps { interactive?: boolean; } -export const DemoApp = ({theme = relayTheme, width, height, interactive = true}: DemoAppProps) => { +const DemoScreen = ({theme, width, height}: Omit) => { const {exit} = useApp(); - useInput( - (input, key) => { - if (input === 'q' || (key.ctrl && input === 'c')) exit(); - }, - {isActive: interactive}, - ); + const [help, setHelp] = useState(false); + const [count, setCount] = useState(0); + useKeybindings([ + {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'Application', run: () => exit()}, + {keys: '?', description: 'Help', group: 'Application', run: () => setHelp(true)}, + {keys: 'space', description: 'Count', group: 'Demo', run: () => setCount(count + 1)}, + ]); + const colors = theme ?? relayTheme; return ( q quit} + footer={} > - - - The frame fills the terminal and follows it when the window is resized. - + + + + The frame fills the terminal and follows it when the window is resized. + + Space pressed {count} times. + + {help ? ( + + setHelp(false)} /> + + ) : null} ); }; + +export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => ( + + + +); diff --git a/src/index.ts b/src/index.ts index 28d4cc9..e7c8750 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './frame/index.js'; +export * from './keys/index.js'; export {runShell, type RunShellOptions} from './run.js'; export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js'; export {relayShellVersion} from './version.js'; diff --git a/src/keys/HelpOverlay.tsx b/src/keys/HelpOverlay.tsx new file mode 100644 index 0000000..315d8a3 --- /dev/null +++ b/src/keys/HelpOverlay.tsx @@ -0,0 +1,65 @@ +import {Box, Text} from 'ink'; +import {relayTheme} from '../theme.js'; +import type {ShellTheme} from '../theme.js'; +import {keyLayers} from './registry.js'; +import type {ListedBinding} from './registry.js'; +import {useKeyHints, useKeybindings} from './react.js'; + +export interface HelpOverlayProps { + onClose: () => void; + title?: string; + theme?: ShellTheme; +} + +const groupBindings = (bindings: readonly ListedBinding[]): Map => { + const groups = new Map(); + for (const binding of bindings) { + const group = binding.group ?? 'General'; + groups.set(group, [...(groups.get(group) ?? []), binding]); + } + return groups; +}; + +/** + * Lists every advertised binding, grouped by heading. While it is open it + * captures the keyboard; `Esc` or `?` closes it. + */ +export const HelpOverlay = ({ + onClose, + title = 'Keyboard', + theme = relayTheme, +}: HelpOverlayProps) => { + const hints = useKeyHints(); + useKeybindings([{keys: ['escape', '?'], run: onClose}], { + layer: keyLayers.overlay, + exclusive: true, + }); + const width = Math.max(10, ...hints.map((hint) => hint.keys.length)); + return ( + + + {title} + + {[...groupBindings(hints)].map(([group, bindings]) => ( + + {group} + {bindings.map((binding) => ( + + {binding.keys.padEnd(width)} + {binding.description} + + ))} + + ))} + + Esc close + + + ); +}; diff --git a/src/keys/KeyHints.tsx b/src/keys/KeyHints.tsx new file mode 100644 index 0000000..6c07726 --- /dev/null +++ b/src/keys/KeyHints.tsx @@ -0,0 +1,26 @@ +import {Text} from 'ink'; +import {relayTheme} from '../theme.js'; +import type {ShellTheme} from '../theme.js'; +import {useKeyHints} from './react.js'; + +export interface KeyHintsProps { + /** Show at most this many hints, highest layer first. */ + limit?: number; + theme?: ShellTheme; +} + +/** A single line of the most relevant bindings, such as `Tab next · q quit`. */ +export const KeyHints = ({limit = 6, theme = relayTheme}: KeyHintsProps) => { + const hints = useKeyHints().slice(0, limit); + return ( + + {hints.map((hint, index) => ( + + {index === 0 ? '' : · } + {hint.keys} + {hint.description.toLowerCase()} + + ))} + + ); +}; diff --git a/src/keys/chord.ts b/src/keys/chord.ts new file mode 100644 index 0000000..8f70229 --- /dev/null +++ b/src/keys/chord.ts @@ -0,0 +1,149 @@ +import type {Key} from 'ink'; + +/** One key press, normalized so that specs and terminal input compare equal. */ +export interface KeyChord { + /** A named key such as `tab` or `pageup`, or the printable character itself. */ + key: string; + ctrl: boolean; + meta: boolean; + shift: boolean; +} + +const namedKeys = new Map([ + ['up', 'up'], + ['down', 'down'], + ['left', 'left'], + ['right', 'right'], + ['pageup', 'pageup'], + ['pgup', 'pageup'], + ['pagedown', 'pagedown'], + ['pgdn', 'pagedown'], + ['home', 'home'], + ['end', 'end'], + ['enter', 'enter'], + ['return', 'enter'], + ['escape', 'escape'], + ['esc', 'escape'], + ['tab', 'tab'], + ['backspace', 'backspace'], + ['delete', 'delete'], + ['space', ' '], +]); + +const modifierNames = new Map([ + ['ctrl', 'ctrl'], + ['control', 'ctrl'], + ['meta', 'meta'], + ['alt', 'meta'], + ['option', 'meta'], + ['shift', 'shift'], +]); + +const isLetter = (value: string): boolean => /^[a-z]$/i.test(value); + +const segmenter = new Intl.Segmenter(); + +/** One user-perceived character, so an emoji with modifiers still counts as one key. */ +const isSingleCharacter = (value: string): boolean => { + const segments = segmenter.segment(value)[Symbol.iterator](); + return segments.next().done === false && segments.next().done === true; +}; + +/** + * Parse a key spec such as `q`, `?`, `ctrl+s`, `shift+tab`, or `pagedown`. + * + * Printable characters are case-sensitive: `Q` and `shift+q` are the same + * chord. `ctrl+` combinations ignore case because terminals cannot report it. + */ +export const parseKeySpec = (spec: string): KeyChord => { + const trimmed = spec.trim(); + if (trimmed.length === 0) throw new Error('A key spec cannot be empty.'); + // A trailing "+" is the plus key itself, as in "ctrl++". + const parts = trimmed.endsWith('++') + ? [...trimmed.slice(0, -2).split('+'), '+'] + : trimmed === '+' + ? ['+'] + : trimmed.split('+'); + const chord: KeyChord = {key: '', ctrl: false, meta: false, shift: false}; + for (const [index, part] of parts.entries()) { + const last = index === parts.length - 1; + const lower = part.toLowerCase(); + if (!last) { + const modifier = modifierNames.get(lower); + if (modifier === undefined) throw new Error(`Unknown modifier "${part}" in "${spec}".`); + chord[modifier] = true; + continue; + } + const named = namedKeys.get(lower); + if (named !== undefined && part.length > 1) chord.key = named; + else if (isSingleCharacter(part)) chord.key = part; + else throw new Error(`Unknown key "${part}" in "${spec}".`); + } + return normalizeChord(chord); +}; + +const normalizeChord = (chord: KeyChord): KeyChord => { + // Symbols such as "?" already carry Shift in the character itself. + if (isSingleCharacter(chord.key) && !isLetter(chord.key)) return {...chord, shift: false}; + if (!isLetter(chord.key)) return chord; + if (chord.ctrl) return {...chord, key: chord.key.toLowerCase()}; + if (chord.shift) return {...chord, key: chord.key.toUpperCase(), shift: false}; + if (chord.key === chord.key.toUpperCase()) return {...chord, shift: false}; + return chord; +}; + +/** Convert Ink's `useInput` arguments into a chord, or `undefined` for pasted text. */ +export const chordFromInput = (input: string, key: Key): KeyChord | undefined => { + const base = {ctrl: key.ctrl, meta: key.meta, shift: key.shift}; + if (key.upArrow) return {...base, key: 'up'}; + if (key.downArrow) return {...base, key: 'down'}; + if (key.leftArrow) return {...base, key: 'left'}; + if (key.rightArrow) return {...base, key: 'right'}; + if (key.pageUp) return {...base, key: 'pageup'}; + if (key.pageDown) return {...base, key: 'pagedown'}; + if (key.home) return {...base, key: 'home'}; + if (key.end) return {...base, key: 'end'}; + if (key.return) return {...base, key: 'enter'}; + if (key.escape) return {...base, key: 'escape'}; + if (key.tab) return {...base, key: 'tab'}; + if (key.backspace) return {...base, key: 'backspace'}; + if (key.delete) return {...base, key: 'delete'}; + if (!isSingleCharacter(input)) return undefined; + return normalizeChord({...base, key: input}); +}; + +export const chordsEqual = (left: KeyChord, right: KeyChord): boolean => + left.key === right.key && + left.ctrl === right.ctrl && + left.meta === right.meta && + left.shift === right.shift; + +const keyLabels = new Map([ + ['up', '↑'], + ['down', '↓'], + ['left', '←'], + ['right', '→'], + ['pageup', 'PgUp'], + ['pagedown', 'PgDn'], + ['home', 'Home'], + ['end', 'End'], + ['enter', 'Enter'], + ['escape', 'Esc'], + ['tab', 'Tab'], + ['backspace', 'Backspace'], + ['delete', 'Delete'], + [' ', 'Space'], +]); + +/** A short label for help screens and status hints, such as `Ctrl+S` or `Shift+Tab`. */ +export const formatChord = (chord: KeyChord): string => { + const parts: string[] = []; + if (chord.ctrl) parts.push('Ctrl'); + if (chord.meta) parts.push('Alt'); + if (chord.shift) parts.push('Shift'); + const label = keyLabels.get(chord.key) ?? chord.key; + parts.push(chord.ctrl && isLetter(chord.key) ? label.toUpperCase() : label); + return parts.join('+'); +}; + +export const formatKeySpec = (spec: string): string => formatChord(parseKeySpec(spec)); diff --git a/src/keys/index.ts b/src/keys/index.ts new file mode 100644 index 0000000..96d55ba --- /dev/null +++ b/src/keys/index.ts @@ -0,0 +1,27 @@ +export { + chordFromInput, + chordsEqual, + formatChord, + formatKeySpec, + parseKeySpec, + type KeyChord, +} from './chord.js'; +export {HelpOverlay, type HelpOverlayProps} from './HelpOverlay.js'; +export {KeyHints, type KeyHintsProps} from './KeyHints.js'; +export { + KeymapProvider, + useKeyHints, + useKeyRegistry, + useKeybindings, + type KeymapProviderProps, + type UseKeybindingsOptions, +} from './react.js'; +export { + KeyRegistry, + keyLayers, + resolveBinding, + type KeyBinding, + type KeyHandlerOptions, + type ListedBinding, + type ResolvedBinding, +} from './registry.js'; diff --git a/src/keys/react.tsx b/src/keys/react.tsx new file mode 100644 index 0000000..e2551a1 --- /dev/null +++ b/src/keys/react.tsx @@ -0,0 +1,76 @@ +import {createContext, useContext, useEffect, useRef, useState, useSyncExternalStore} from 'react'; +import type {ReactNode} from 'react'; +import {useInput} from 'ink'; +import {KeyRegistry} from './registry.js'; +import type {KeyBinding, KeyHandlerOptions, ListedBinding} from './registry.js'; + +const KeyRegistryContext = createContext(undefined); + +export interface KeymapProviderProps { + /** Supply a registry to dispatch keys from outside React, for example in tests. */ + registry?: KeyRegistry; + /** Stop reading the keyboard, for example while a child process owns the terminal. */ + active?: boolean; + children?: ReactNode; +} + +/** Reads the keyboard once and routes every key press through the registry. */ +export const KeymapProvider = ({registry, active = true, children}: KeymapProviderProps) => { + const [owned] = useState(() => registry ?? new KeyRegistry()); + useInput( + (input, key) => { + owned.dispatch(input, key); + }, + {isActive: active}, + ); + return {children}; +}; + +export const useKeyRegistry = (): KeyRegistry => { + const registry = useContext(KeyRegistryContext); + if (registry === undefined) throw new Error('Key bindings must be used inside .'); + return registry; +}; + +export interface UseKeybindingsOptions extends KeyHandlerOptions { + /** Inactive bindings are removed from dispatch and from help. */ + active?: boolean; +} + +/** + * Register bindings for as long as the component is mounted and active. + * Handlers always see the latest props, so inline functions are fine. + */ +export const useKeybindings = ( + bindings: readonly KeyBinding[], + options: UseKeybindingsOptions = {}, +): void => { + const registry = useKeyRegistry(); + const {active = true, ...handlerOptions} = options; + const id = useRef(undefined); + const latest = useRef({bindings, handlerOptions}); + latest.current = {bindings, handlerOptions}; + + useEffect(() => { + if (!active) return; + const registered = registry.register(latest.current.bindings, latest.current.handlerOptions); + id.current = registered; + return () => { + registry.unregister(registered); + id.current = undefined; + }; + }, [registry, active]); + + useEffect(() => { + if (id.current !== undefined) registry.update(id.current, bindings, handlerOptions); + }); +}; + +/** The bindings a key press can currently reach, for help screens and hints. */ +export const useKeyHints = (): readonly ListedBinding[] => { + const registry = useKeyRegistry(); + return useSyncExternalStore( + (listener) => registry.subscribe(listener), + () => registry.list(), + ); +}; diff --git a/src/keys/registry.ts b/src/keys/registry.ts new file mode 100644 index 0000000..83f75ec --- /dev/null +++ b/src/keys/registry.ts @@ -0,0 +1,181 @@ +import type {Key} from 'ink'; +import {chordFromInput, chordsEqual, formatChord, parseKeySpec} from './chord.js'; +import type {KeyChord} from './chord.js'; + +/** How strongly a set of bindings claims keys. Higher layers are asked first. */ +export const keyLayers = { + /** Application-wide bindings such as quit and help. */ + global: 0, + /** Bindings that belong to the focused pane. */ + pane: 10, + /** Overlays and dialogs that sit above everything else. */ + overlay: 20, +} as const; + +export interface KeyBinding { + /** One spec or several alternatives, such as `['q', 'ctrl+c']`. */ + keys: string | readonly string[]; + /** Shown in help and status hints. Bindings without one are not listed. */ + description?: string; + /** Heading the binding is listed under in help. */ + group?: string; + /** Return `false` to let the key continue to lower layers. */ + run: () => unknown; +} + +export interface KeyHandlerOptions { + layer?: number; + /** Stop keys that no binding here handles from reaching lower layers. */ + exclusive?: boolean; + /** + * Called with keys that no binding in this set matched, before lower layers. + * Return `true` when the key was used, for example by a text field. + */ + fallback?: (input: string, key: Key) => boolean; +} + +export interface ResolvedBinding { + chords: readonly KeyChord[]; + labels: readonly string[]; + description?: string; + group?: string; + run: () => unknown; +} + +export interface ListedBinding { + keys: string; + description: string; + group?: string; + layer: number; +} + +interface Entry { + id: number; + layer: number; + exclusive: boolean; + bindings: readonly ResolvedBinding[]; + fallback?: (input: string, key: Key) => boolean; +} + +export const resolveBinding = (binding: KeyBinding): ResolvedBinding => { + const specs = typeof binding.keys === 'string' ? [binding.keys] : binding.keys; + const chords = specs.map(parseKeySpec); + return { + chords, + labels: chords.map(formatChord), + run: binding.run, + ...(binding.description === undefined ? {} : {description: binding.description}), + ...(binding.group === undefined ? {} : {group: binding.group}), + }; +}; + +/** + * Holds every active set of bindings and routes each key press to the + * highest layer that handles it. Within a layer the most recently registered + * set wins, so a pane mounted inside another pane takes precedence. + */ +export class KeyRegistry { + #entries = new Map(); + #nextId = 1; + #listeners = new Set<() => void>(); + #version = 0; + #listed: readonly ListedBinding[] = []; + + register(bindings: readonly KeyBinding[], options: KeyHandlerOptions = {}): number { + const id = this.#nextId; + this.#nextId += 1; + this.#entries.set(id, this.#entry(id, bindings, options)); + this.#changed(); + return id; + } + + update(id: number, bindings: readonly KeyBinding[], options: KeyHandlerOptions = {}): void { + if (!this.#entries.has(id)) return; + const before = this.#signature(); + this.#entries.set(id, this.#entry(id, bindings, options)); + if (this.#signature() !== before) this.#changed(); + } + + unregister(id: number): void { + if (this.#entries.delete(id)) this.#changed(); + } + + /** Route one key press. Returns `true` when a binding or fallback used it. */ + dispatch(input: string, key: Key): boolean { + const chord = chordFromInput(input, key); + for (const entry of this.#ordered()) { + if (chord !== undefined) { + for (const binding of entry.bindings) { + if (!binding.chords.some((candidate) => chordsEqual(candidate, chord))) continue; + if (binding.run() !== false) return true; + } + } + if (entry.fallback?.(input, key) === true) return true; + if (entry.exclusive) return false; + } + return false; + } + + /** Bindings with a description that a key press can reach, highest layer first. */ + list(): readonly ListedBinding[] { + return this.#listed; + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + get version(): number { + return this.#version; + } + + #entry(id: number, bindings: readonly KeyBinding[], options: KeyHandlerOptions): Entry { + return { + id, + layer: options.layer ?? keyLayers.global, + exclusive: options.exclusive ?? false, + bindings: bindings.map(resolveBinding), + ...(options.fallback === undefined ? {} : {fallback: options.fallback}), + }; + } + + #ordered(): Entry[] { + return [...this.#entries.values()].sort( + (left, right) => right.layer - left.layer || right.id - left.id, + ); + } + + #computeList(): readonly ListedBinding[] { + const seen = new Set(); + const listed: ListedBinding[] = []; + for (const entry of this.#ordered()) { + const advertises = entry.bindings.some((binding) => binding.description !== undefined); + for (const binding of entry.bindings) { + const labels = binding.labels.filter((label) => !seen.has(label)); + for (const label of binding.labels) seen.add(label); + if (binding.description === undefined || labels.length === 0) continue; + listed.push({ + keys: labels.join(' / '), + description: binding.description, + layer: entry.layer, + ...(binding.group === undefined ? {} : {group: binding.group}), + }); + } + // An exclusive set that lists its own keys, such as a dialog, hides the + // keys beneath it. One that lists nothing, such as a help screen, does not. + if (entry.exclusive && advertises) break; + } + return listed; + } + + #signature(): string { + return JSON.stringify(this.#computeList()); + } + + #changed(): void { + this.#listed = this.#computeList(); + this.#version += 1; + for (const listener of this.#listeners) listener(); + } +} diff --git a/tests/chord.test.ts b/tests/chord.test.ts new file mode 100644 index 0000000..8eed097 --- /dev/null +++ b/tests/chord.test.ts @@ -0,0 +1,52 @@ +import type {Key} from 'ink'; +import {describe, expect, it} from 'vitest'; +import {chordFromInput, chordsEqual, formatKeySpec, parseKeySpec} from '../src/keys/index.js'; +import {keyOf, noKey} from './helpers.js'; + +const matches = (spec: string, input: string, key: Partial = {}): boolean => { + const chord = chordFromInput(input, keyOf(key)); + return chord !== undefined && chordsEqual(parseKeySpec(spec), chord); +}; + +describe('key specs', () => { + it('parses modifiers, named keys, and aliases', () => { + expect(parseKeySpec('Ctrl+S')).toEqual({key: 's', ctrl: true, meta: false, shift: false}); + expect(parseKeySpec('alt+pgdn')).toMatchObject({key: 'pagedown', meta: true}); + expect(parseKeySpec('shift+tab')).toMatchObject({key: 'tab', shift: true}); + expect(parseKeySpec('space').key).toBe(' '); + expect(parseKeySpec('ctrl++')).toMatchObject({key: '+', ctrl: true}); + }); + + it('treats shifted letters and capitals as the same chord', () => { + expect(parseKeySpec('shift+q')).toEqual(parseKeySpec('Q')); + }); + + it('rejects unknown keys and modifiers', () => { + expect(() => parseKeySpec('hyper+x')).toThrow('Unknown modifier'); + expect(() => parseKeySpec('ctrl+banana')).toThrow('Unknown key'); + expect(() => parseKeySpec('')).toThrow('empty'); + }); + + it('formats specs for help screens', () => { + expect(formatKeySpec('ctrl+s')).toBe('Ctrl+S'); + expect(formatKeySpec('shift+tab')).toBe('Shift+Tab'); + expect(formatKeySpec('pagedown')).toBe('PgDn'); + expect(formatKeySpec('?')).toBe('?'); + }); +}); + +describe('terminal input', () => { + it('matches what Ink reports for common keys', () => { + expect(matches('ctrl+s', 's', {ctrl: true})).toBe(true); + expect(matches('shift+tab', '', {tab: true, shift: true})).toBe(true); + expect(matches('tab', '', {tab: true, shift: true})).toBe(false); + expect(matches('Q', 'Q', {shift: true})).toBe(true); + expect(matches('q', 'Q', {shift: true})).toBe(false); + expect(matches('?', '?', {shift: true})).toBe(true); + expect(matches('alt+x', 'x', {meta: true})).toBe(true); + }); + + it('ignores pasted text', () => { + expect(chordFromInput('hello', noKey)).toBeUndefined(); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a329b92..268f202 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -11,7 +11,7 @@ describe('relay-shell command', () => { it('prints a snapshot of the demonstration without a terminal', async () => { const {stdout} = await run(process.execPath, [tsx, cli, '--demo', '--snapshot']); expect(stdout).toContain('Relay Shell'); - expect(stdout).toContain('q quit'); + expect(stdout).toContain('q / Ctrl+C quit'); }, 30_000); it('explains how to start when no mode is chosen', async () => { diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx new file mode 100644 index 0000000..fac694e --- /dev/null +++ b/tests/demo.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, it} from 'vitest'; +import {DemoApp} from '../src/demo/index.js'; +import {tick} from './helpers.js'; + +describe('demonstration', () => { + it('opens the keyboard reference over the frame', async () => { + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('q / Ctrl+C quit'); + view.stdin.write('?'); + await tick(); + expect(view.lastFrame()).toContain('Keyboard'); + expect(view.lastFrame()).toContain('Application'); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..8b6d9af --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,29 @@ +import type {Key} from 'ink'; + +export const noKey: Key = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, +}; + +export const keyOf = (key: Partial = {}): Key => ({...noKey, ...key}); + +/** Give Ink a moment to read stdin and re-render. */ +export const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); diff --git a/tests/keymap.test.tsx b/tests/keymap.test.tsx new file mode 100644 index 0000000..439074e --- /dev/null +++ b/tests/keymap.test.tsx @@ -0,0 +1,89 @@ +import React, {useState} from 'react'; +import {Text} from 'ink'; +import {render} from 'ink-testing-library'; +import {describe, expect, it} from 'vitest'; +import { + HelpOverlay, + KeyHints, + KeymapProvider, + keyLayers, + useKeybindings, +} from '../src/keys/index.js'; +import {tick} from './helpers.js'; + +const Counter = () => { + const [count, setCount] = useState(0); + const [help, setHelp] = useState(false); + useKeybindings([ + {keys: '+', description: 'Increment', group: 'Counter', run: () => setCount(count + 1)}, + {keys: '?', description: 'Help', run: () => setHelp(true)}, + ]); + return ( + <> + count {count} + + {help ? setHelp(false)} /> : null} + + ); +}; + +describe('keymap components', () => { + it('routes keys to handlers that see the latest state', async () => { + const view = render( + + + , + ); + await tick(); + view.stdin.write('+'); + await tick(); + view.stdin.write('+'); + await tick(); + expect(view.lastFrame()).toContain('count 2'); + expect(view.lastFrame()).toContain('+ increment · ? help'); + }); + + it('opens help, captures keys while open, and closes on Escape', async () => { + const view = render( + + + , + ); + await tick(); + view.stdin.write('?'); + await tick(); + expect(view.lastFrame()).toContain('Keyboard'); + expect(view.lastFrame()).toContain('Counter'); + view.stdin.write('+'); + await tick(); + expect(view.lastFrame()).toContain('count 0'); + view.stdin.write('\u001B'); + await tick(); + await tick(); + expect(view.lastFrame()).not.toContain('Keyboard'); + }); + + it('removes inactive bindings', async () => { + const Toggle = ({active}: {active: boolean}) => { + useKeybindings([{keys: 'x', description: 'Explode', run: () => undefined}], { + active, + layer: keyLayers.pane, + }); + return ; + }; + const view = render( + + + , + ); + await tick(); + expect(view.lastFrame()).toContain('explode'); + view.rerender( + + + , + ); + await tick(); + expect(view.lastFrame()).not.toContain('explode'); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 0000000..caeb830 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,116 @@ +import type {Key} from 'ink'; +import {describe, expect, it, vi} from 'vitest'; +import {KeyRegistry, keyLayers} from '../src/keys/index.js'; +import {keyOf} from './helpers.js'; + +const press = (registry: KeyRegistry, input: string, key: Partial = {}): boolean => + registry.dispatch(input, keyOf(key)); + +describe('KeyRegistry', () => { + it('asks higher layers first and stops at the first handler', () => { + const registry = new KeyRegistry(); + const global = vi.fn(); + const pane = vi.fn(); + registry.register([{keys: 'j', run: global}]); + registry.register([{keys: 'j', run: pane}], {layer: keyLayers.pane}); + expect(press(registry, 'j')).toBe(true); + expect(pane).toHaveBeenCalledOnce(); + expect(global).not.toHaveBeenCalled(); + }); + + it('prefers the most recent set within a layer', () => { + const registry = new KeyRegistry(); + const first = vi.fn(); + const second = vi.fn(); + registry.register([{keys: 'x', run: first}]); + registry.register([{keys: 'x', run: second}]); + press(registry, 'x'); + expect(second).toHaveBeenCalledOnce(); + expect(first).not.toHaveBeenCalled(); + }); + + it('passes a key on when a handler returns false', () => { + const registry = new KeyRegistry(); + const global = vi.fn(); + registry.register([{keys: 'enter', run: global}]); + registry.register([{keys: 'enter', run: () => false}], {layer: keyLayers.pane}); + press(registry, '', {return: true}); + expect(global).toHaveBeenCalledOnce(); + }); + + it('lets exclusive sets block lower layers', () => { + const registry = new KeyRegistry(); + const quit = vi.fn(); + registry.register([{keys: 'q', run: quit}]); + const dialog = registry.register([{keys: 'escape', run: vi.fn()}], { + layer: keyLayers.overlay, + exclusive: true, + }); + expect(press(registry, 'q')).toBe(false); + expect(quit).not.toHaveBeenCalled(); + registry.unregister(dialog); + press(registry, 'q'); + expect(quit).toHaveBeenCalledOnce(); + }); + + it('offers unmatched keys to a fallback such as a text field', () => { + const registry = new KeyRegistry(); + const typed: string[] = []; + const global = vi.fn(); + registry.register([{keys: 'q', run: global}]); + registry.register([{keys: 'enter', run: vi.fn()}], { + layer: keyLayers.pane, + fallback: (input) => { + typed.push(input); + return true; + }, + }); + press(registry, 'q'); + press(registry, 'hello'); + expect(typed).toEqual(['q', 'hello']); + expect(global).not.toHaveBeenCalled(); + }); + + it('lists reachable bindings without shadowed duplicates', () => { + const registry = new KeyRegistry(); + registry.register([ + {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'App', run: vi.fn()}, + {keys: 'j', description: 'Global down', run: vi.fn()}, + {keys: 'r', run: vi.fn()}, + ]); + registry.register([{keys: ['j', 'down'], description: 'Next row', run: vi.fn()}], { + layer: keyLayers.pane, + }); + expect(registry.list().map((hint) => [hint.keys, hint.description])).toEqual([ + ['j / ↓', 'Next row'], + ['q / Ctrl+C', 'Quit'], + ]); + }); + + it('hides keys beneath a dialog that lists its own', () => { + const registry = new KeyRegistry(); + registry.register([{keys: 'q', description: 'Quit', run: vi.fn()}]); + registry.register([{keys: 'escape', run: vi.fn()}], { + layer: keyLayers.overlay, + exclusive: true, + }); + expect(registry.list().map((hint) => hint.description)).toEqual(['Quit']); + registry.register([{keys: 'y', description: 'Confirm', run: vi.fn()}], { + layer: keyLayers.overlay, + exclusive: true, + }); + expect(registry.list().map((hint) => hint.description)).toEqual(['Confirm']); + }); + + it('notifies subscribers only when the listed bindings change', () => { + const registry = new KeyRegistry(); + const listener = vi.fn(); + registry.subscribe(listener); + const id = registry.register([{keys: 'a', description: 'Add', run: vi.fn()}]); + expect(listener).toHaveBeenCalledTimes(1); + registry.update(id, [{keys: 'a', description: 'Add', run: vi.fn()}]); + expect(listener).toHaveBeenCalledTimes(1); + registry.update(id, [{keys: 'a', description: 'Append', run: vi.fn()}]); + expect(listener).toHaveBeenCalledTimes(2); + }); +});