diff --git a/CHANGELOG.md b/CHANGELOG.md index f7387e9..7e5984c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ - Added layered keybindings with `KeymapProvider`, `useKeybindings`, key specs, `HelpOverlay`, and `KeyHints`. - Added `Split` and `Pane` layouts with fixed, percentage, and shared sizes, `Tab` focus movement, and keys that follow the focused pane. - Added `StatusBar` with segments published by components, transient notices, and `StatusStore` for status from outside React. +- Added `Shell`, which assembles the frame, panes, status bar, key hints, help, and quitting, and rebuilt the demonstration on it. diff --git a/README.md b/README.md index 9d3b9ce..5bd9dcf 100644 --- a/README.md +++ b/README.md @@ -17,39 +17,51 @@ npx tsx src/cli.tsx --demo --snapshot ## Use it in an application +`Shell` assembles the whole frame: panes with focus, a status bar, key hints, a keyboard reference on `?`, and quitting on `q` or `Ctrl+C`. + ```tsx import {Text} from 'ink'; -import {ShellFrame, runShell} from '@relay-cli/shell'; +import {Pane, Shell, Split, runShell, usePaneKeybindings} from '@relay-cli/shell'; + +const HostList = () => { + usePaneKeybindings([{keys: ['j', 'down'], description: 'Next host', run: selectNext}]); + return api-1; +}; await runShell( - - Hello from a full-screen application. - , + + + + + + + Select a host. + + + , ); ``` -`runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits. +`runShell` renders on the alternate screen, so the terminal's scrollback is restored when the application exits. Every part of `Shell` is also exported on its own; see [the API reference](./docs/api.md). ## Panes -`Split` and `Pane` divide the screen into regions. Each pane can take focus, and `Tab` moves focus between them. +`Split` and `Pane` divide the screen into regions. A size can be a number of cells, a percentage, or a share of the remaining space, and splits nest. Each pane can take focus, and `Tab` moves focus between them. ```tsx - - - - + + + + + + + + + + - - - - - - - - - + ``` See [the pane guide](./docs/panes.md) for sizes, focus order, and keys that belong to one pane. @@ -73,23 +85,10 @@ See [the status bar guide](./docs/status-bar.md). ## 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. +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, so the same key can mean different things in different panes. ```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( - - - , -); +useKeybindings([{keys: 'ctrl+s', description: 'Save', run: save}]); ``` See [the keybinding guide](./docs/keyboard.md) for key specs, layers, and text input. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..67dc2e3 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,39 @@ +# API + +Everything is exported from `@relay-cli/shell`. + +## Application + +| Export | Purpose | +| ---------- | ---------------------------------------------------------------------------------------- | +| `Shell` | The complete frame: keybindings, pane focus, status bar, hints, help on `?`, quit on `q` | +| `runShell` | Render on the alternate screen and resolve when the application exits | + +### `Shell` props + +| Prop | Default | Meaning | +| ----------------- | ------------------ | ----------------------------------------------------- | +| `title` | required | Shown at the top left | +| `subtitle` | | Shown after the title | +| `theme` | `relayTheme` | Colors for every part of the shell | +| `statusLabel` | focused pane id | A string, or a function of the focused pane id | +| `statusRight` | | Content at the right end of the status bar | +| `initialFocus` | first pane mounted | The pane focused first | +| `quitKeys` | `['q', 'ctrl+c']` | Keys that quit, or `false` | +| `onQuit` | exit Ink | Called on a quit key | +| `helpKey` | `'?'` | Opens the keyboard reference, or `false` | +| `hints` | `true` | Show one line of key hints under the status bar | +| `interactive` | `true` | Read the keyboard; disable it to print a single frame | +| `width`, `height` | terminal size | Fixed size, mainly for tests | + +## Building blocks + +`Shell` is assembled from parts that can also be used on their own. + +| Area | Exports | Guide | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| Frame | `ShellFrame` | [architecture](./architecture.md) | +| Keybindings | `KeymapProvider`, `useKeybindings`, `useKeyHints`, `KeyHints`, `HelpOverlay`, `KeyRegistry`, `keyLayers`, `parseKeySpec`, `formatKeySpec` | [keybindings](./keyboard.md) | +| Panes | `PaneFocusProvider`, `Split`, `Pane`, `usePane`, `usePaneKeybindings`, `useFocusedPane`, `usePaneFocus` | [panes](./panes.md) | +| Status bar | `StatusProvider`, `StatusBar`, `useStatusSegment`, `useNotify`, `StatusStore` | [status bar](./status-bar.md) | +| Themes | `relayTheme`, `monoTheme`, `shellThemes`, `ShellTheme` | | diff --git a/docs/architecture.md b/docs/architecture.md index 0126c47..0cdfc85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,5 +7,6 @@ Relay Shell is the frame a full-screen terminal application lives in. It does no 3. `KeymapProvider` reads the keyboard once and asks a `KeyRegistry` which binding should receive each key. See [keybindings](./keyboard.md). 4. `Split` and `Pane` divide the body into regions, and `PaneFocusProvider` decides which pane receives pane-layer keys. See [panes](./panes.md). 5. `StatusProvider` collects segments and notices from any component, and `StatusBar` draws them in the footer. See [the status bar](./status-bar.md). +6. `Shell` nests those providers in the right order and draws the frame, status bar, hints, and help. See [the API reference](./api.md). Each provider can be used on its own; an application that only needs keybindings does not have to adopt panes. diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx index 81cd6a5..f811458 100644 --- a/src/demo/DemoApp.tsx +++ b/src/demo/DemoApp.tsx @@ -1,16 +1,9 @@ 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 { - Pane, - PaneFocusProvider, - Split, - useFocusedPane, - usePane, - usePaneKeybindings, -} from '../panes/index.js'; -import {StatusBar, StatusProvider, useNotify, useStatusSegment} from '../status/index.js'; +import {Box, Text} from 'ink'; +import {useKeybindings} from '../keys/index.js'; +import {Pane, Split, usePane, usePaneKeybindings} from '../panes/index.js'; +import {Shell} from '../shell/index.js'; +import {useNotify, useStatusSegment} from '../status/index.js'; import {relayTheme} from '../theme.js'; import type {ShellTheme} from '../theme.js'; import {demoHosts} from './hosts.js'; @@ -95,14 +88,8 @@ const HostDetail = ({host, theme}: {host: DemoHost; theme: ShellTheme}) => ( ); -const paneLabels = new Map([ - ['hosts', 'HOSTS'], - ['detail', 'DETAIL'], - ['events', 'EVENTS'], -]); - -const DemoStatus = ({host, theme}: {host: DemoHost | undefined; theme: ShellTheme}) => { - const focused = useFocusedPane(); +const DemoStatus = ({host}: {host: DemoHost | undefined}) => { + const notify = useNotify(); const down = demoHosts.filter((candidate) => candidate.status === 'down').length; useStatusSegment( host === undefined @@ -119,22 +106,7 @@ const DemoStatus = ({host, theme}: {host: DemoHost | undefined; theme: ShellThem ); useStatusSegment({text: `${demoHosts.length} hosts`, side: 'right', order: 1}); useStatusSegment({text: `${down} down`, side: 'right', tone: down > 0 ? 'danger' : 'neutral'}); - return ( - <> - - - - ); -}; - -const DemoScreen = ({theme = relayTheme, width, height}: Omit) => { - const {exit} = useApp(); - const notify = useNotify(); - const [help, setHelp] = useState(false); - const [selected, setSelected] = useState(0); useKeybindings([ - {keys: ['q', 'ctrl+c'], description: 'Quit', group: 'Application', run: () => exit()}, - {keys: '?', description: 'Help', group: 'Application', run: () => setHelp(true)}, { keys: 'r', description: 'Refresh', @@ -142,16 +114,22 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit notify(`Refreshed ${demoHosts.length} hosts`, {tone: 'success'}), }, ]); + return null; +}; + +export const DemoApp = ({theme = relayTheme, width, height, interactive = true}: DemoAppProps) => { + const [selected, setSelected] = useState(0); const host = demoHosts[selected] ?? demoHosts[0]; return ( - } > + @@ -169,21 +147,6 @@ const DemoScreen = ({theme = relayTheme, width, height}: Omit - {help ? ( - - setHelp(false)} /> - - ) : null} - + ); }; - -export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => ( - - - - - - - -); diff --git a/src/index.ts b/src/index.ts index a6727fc..1ea7903 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,6 @@ export * from './keys/index.js'; export * from './panes/index.js'; export * from './status/index.js'; export {runShell, type RunShellOptions} from './run.js'; +export * from './shell/index.js'; export {monoTheme, relayTheme, shellThemes, type ShellTheme} from './theme.js'; export {relayShellVersion} from './version.js'; diff --git a/src/shell/Shell.tsx b/src/shell/Shell.tsx new file mode 100644 index 0000000..b871c9a --- /dev/null +++ b/src/shell/Shell.tsx @@ -0,0 +1,120 @@ +import {useState} from 'react'; +import type {ReactNode} from 'react'; +import {Box, useApp} from 'ink'; +import {ShellFrame} from '../frame/index.js'; +import {HelpOverlay, KeyHints, KeymapProvider, useKeybindings} from '../keys/index.js'; +import type {KeyBinding} from '../keys/index.js'; +import {PaneFocusProvider, useFocusedPane} from '../panes/index.js'; +import {StatusBar, StatusProvider} from '../status/index.js'; +import {relayTheme} from '../theme.js'; +import type {ShellTheme} from '../theme.js'; + +export interface ShellProps { + title: string; + subtitle?: string; + theme?: ShellTheme; + /** Fixed size, mainly for tests and snapshots. Defaults to the terminal size. */ + width?: number; + height?: number; + /** + * The label at the left of the status bar. Defaults to the focused pane's + * id in capitals. Pass a function to derive it from the focused pane. + */ + statusLabel?: string | ((focusedPane: string | undefined) => string | undefined); + /** Content at the right end of the status bar, such as a clock. */ + statusRight?: ReactNode; + /** The pane focused first. */ + initialFocus?: string; + /** Keys that quit. Pass `false` to bind quitting yourself. */ + quitKeys?: string | readonly string[] | false; + /** Called on a quit key. Defaults to exiting the Ink application. */ + onQuit?: () => void; + /** The key that opens the keyboard reference. Pass `false` to disable it. */ + helpKey?: string | false; + /** Show the one-line key hints under the status bar. */ + hints?: boolean; + /** Read the keyboard. Disable it to print a single frame. */ + interactive?: boolean; + children?: ReactNode; +} + +type ChromeProps = Omit; + +const defaultLabel = (focused: string | undefined): string | undefined => focused?.toUpperCase(); + +const ShellChrome = ({ + title, + subtitle, + theme = relayTheme, + width, + height, + statusLabel = defaultLabel, + statusRight, + quitKeys = ['q', 'ctrl+c'], + onQuit, + helpKey = '?', + hints = true, + children, +}: ChromeProps) => { + const {exit} = useApp(); + const focused = useFocusedPane(); + const [help, setHelp] = useState(false); + const bindings: KeyBinding[] = []; + if (quitKeys !== false) { + bindings.push({ + keys: quitKeys, + description: 'Quit', + group: 'Application', + run: () => (onQuit ?? exit)(), + }); + } + if (helpKey !== false) { + bindings.push({ + keys: helpKey, + description: 'Help', + group: 'Application', + run: () => setHelp(true), + }); + } + useKeybindings(bindings); + const label = typeof statusLabel === 'function' ? statusLabel(focused) : statusLabel; + return ( + + + {statusRight} + + {hints ? : null} + + } + > + {children} + {help ? ( + + setHelp(false)} /> + + ) : null} + + ); +}; + +/** + * A complete application frame: keybindings, pane focus, a status bar, key + * hints, a keyboard reference on `?`, and quitting on `q` or `Ctrl+C`. + * Children are usually a `Split` of `Pane`s. + */ +export const Shell = ({initialFocus, interactive = true, ...props}: ShellProps) => ( + + + + + + + +); diff --git a/src/shell/index.ts b/src/shell/index.ts new file mode 100644 index 0000000..67c7ffb --- /dev/null +++ b/src/shell/index.ts @@ -0,0 +1 @@ +export {Shell, type ShellProps} from './Shell.js'; diff --git a/tests/shell.test.tsx b/tests/shell.test.tsx new file mode 100644 index 0000000..3b13506 --- /dev/null +++ b/tests/shell.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import {Text} from 'ink'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import {Pane, Shell, Split} from '../src/index.js'; +import {tick} from './helpers.js'; + +const panes = ( + + + left body + + + right body + + +); + +describe('Shell', () => { + it('assembles the frame, panes, status bar, and hints', async () => { + const view = render( + + {panes} + , + ); + await tick(); + const lines = (view.lastFrame() ?? '').split('\n'); + expect(lines).toHaveLength(12); + expect(lines[0]).toContain('Inventory'); + expect(lines.at(-2)).toMatch(/^ RIGHT/); + expect(lines.at(-1)).toContain('Tab next pane'); + expect(lines.at(-1)).toContain('q / Ctrl+C quit'); + }); + + it('calls onQuit on a custom quit key', async () => { + const onQuit = vi.fn(); + const view = render( + + {panes} + , + ); + await tick(); + view.stdin.write('q'); + await tick(); + expect(onQuit).not.toHaveBeenCalled(); + view.stdin.write('x'); + await tick(); + expect(onQuit).toHaveBeenCalledOnce(); + }); + + it('derives the status label from the focused pane', async () => { + const view = render( + (pane === 'left' ? 'Browsing' : 'Reading')} + > + {panes} + , + ); + await tick(); + expect(view.lastFrame()).toContain(' Browsing'); + view.stdin.write('\t'); + await tick(); + expect(view.lastFrame()).toContain(' Reading'); + }); + + it('can leave out help and hints', async () => { + const view = render( + + {panes} + , + ); + await tick(); + view.stdin.write('?'); + await tick(); + expect(view.lastFrame()).not.toContain('Keyboard'); + expect(view.lastFrame()).not.toContain('Tab next pane'); + }); +});