diff --git a/CHANGELOG.md b/CHANGELOG.md index 7728926..7e5984c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,3 +4,6 @@ - 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`. +- 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 86ccc70..5bd9dcf 100644 --- a/README.md +++ b/README.md @@ -17,38 +17,78 @@ 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). -## Keybindings +## Panes -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. +`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 -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 } />; +See [the pane guide](./docs/panes.md) for sizes, focus order, and keys that belong to one pane. + +## Status bar + +Components publish short segments to the status bar, and any component can show a notice that briefly replaces them. + +```tsx +const HostSummary = ({hosts}: {hosts: Host[]}) => { + const notify = useNotify(); + useStatusSegment({text: `${hosts.length} hosts`, side: 'right'}); + useKeybindings([ + {keys: 'r', description: 'Refresh', run: () => notify('Refreshed', {tone: 'success'})}, + ]); + return null; }; +``` -await runShell( - - - , -); +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, so the same key can mean different things in different panes. + +```tsx +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 67a7132..0cdfc85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,5 +5,8 @@ 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). +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). -Layout, focus, and the status bar are added as separate modules so an application can use only the parts it needs. +Each provider can be used on its own; an application that only needs keybindings does not have to adopt panes. diff --git a/docs/keyboard.md b/docs/keyboard.md index d425508..8b56e91 100644 --- a/docs/keyboard.md +++ b/docs/keyboard.md @@ -49,4 +49,4 @@ useKeybindings([{keys: 'enter', run: submit}], { ## 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. +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. Set `hint: false` on a binding to keep it in help but out of the one-line hints. diff --git a/docs/panes.md b/docs/panes.md new file mode 100644 index 0000000..3f770ad --- /dev/null +++ b/docs/panes.md @@ -0,0 +1,54 @@ +# Panes + +A layout is a tree of `Split` elements with `Pane` leaves. A split divides its space between its children along one axis; each child says how much it wants with `size`. + +```tsx + + + + … + + + + … + + + … + + + + +``` + +## Sizes + +| Size | Meaning | +| ------- | ---------------------------------------------- | +| `28` | Exactly 28 cells, including the border | +| `'30%'` | A percentage of the split | +| `'2fr'` | Two shares of the space left after fixed sizes | +| omitted | One share (`'1fr'`) | + +`minSize` keeps a shared or percentage pane from shrinking below a number of cells. The outermost split fills the space it is given, normally the body of a `ShellFrame`. + +## Focus + +`PaneFocusProvider` tracks which pane has focus and binds `Tab` and `Shift+Tab` to move it. Panes are visited in `order`, then in the order they mounted. Pass `focusable={false}` to skip a pane, `initialFocus` to choose the first one, or `keys={false}` to bind focus movement yourself through `usePaneFocus()`. + +The focused pane draws its border and title in the theme's focus color. Components inside a pane read its state with `usePane()`: + +```tsx +const pane = usePane(); +pane.focused; // true while this pane has focus +pane.focus(); // take focus +``` + +## Pane keys + +`usePaneKeybindings` registers bindings on the pane layer that are active only while the enclosing pane has focus. The same key can mean different things in different panes, and help lists only the keys that the focused pane will receive. + +```tsx +usePaneKeybindings([ + {keys: ['j', 'down'], description: 'Next host', run: () => select(selected + 1)}, +]); +``` diff --git a/docs/status-bar.md b/docs/status-bar.md new file mode 100644 index 0000000..c01fc81 --- /dev/null +++ b/docs/status-bar.md @@ -0,0 +1,49 @@ +# Status bar + +`StatusBar` is one row at the bottom of the screen. Components anywhere in the application publish short segments to it, and any component can show a notice that temporarily replaces the left side. + +```tsx + + }> + … + + +``` + +## Segments + +`useStatusSegment` shows a segment for as long as the component is mounted, and updates it when its text or tone changes. Pass `undefined` to hide it without unmounting. + +```tsx +useStatusSegment({text: `${host.name} ${host.status}`, tone: 'warning'}); +useStatusSegment({text: `${hosts.length} hosts`, side: 'right'}); +``` + +| Field | Meaning | +| ------- | --------------------------------------------------------------------- | +| `text` | The segment's text | +| `side` | `left` (default) or `right` | +| `tone` | `neutral`, `info`, `success`, `warning`, or `danger` | +| `order` | Lower values sit closer to the edge of the bar; ties keep mount order | + +## Notices + +`useNotify` returns a function that shows a message in place of the left-hand segments. A notice disappears after four seconds by default; a newer notice replaces it immediately. Pass `durationMs: 0` to keep it until `dismiss()` is called on the store. + +```tsx +const notify = useNotify(); +notify('Saved', {tone: 'success'}); +notify('Connection lost', {tone: 'danger', durationMs: 0}); +``` + +## Outside React + +Create a `StatusStore` and pass it to `StatusProvider` to publish status from code that is not a component, such as a polling loop: + +```ts +const status = new StatusStore(); +setInterval( + () => status.set('poll', {text: `checked ${new Date().toLocaleTimeString()}`, side: 'right'}), + 5000, +); +``` diff --git a/src/demo/DemoApp.tsx b/src/demo/DemoApp.tsx index ab8fa9b..f811458 100644 --- a/src/demo/DemoApp.tsx +++ b/src/demo/DemoApp.tsx @@ -1,9 +1,13 @@ 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 {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'; +import type {DemoHost} from './hosts.js'; export interface DemoAppProps { theme?: ShellTheme; @@ -13,44 +17,136 @@ export interface DemoAppProps { interactive?: boolean; } -const DemoScreen = ({theme, width, height}: Omit) => { - const {exit} = useApp(); - const [help, setHelp] = useState(false); - const [count, setCount] = useState(0); +const statusColor = (host: DemoHost, theme: ShellTheme): string => + host.status === 'healthy' + ? theme.success + : host.status === 'degraded' + ? theme.warning + : theme.danger; + +const HostList = ({ + selected, + onSelect, + theme, +}: { + selected: number; + onSelect: (index: number) => void; + theme: ShellTheme; +}) => { + const pane = usePane(); + const last = demoHosts.length - 1; + usePaneKeybindings([ + { + keys: ['j', 'down'], + description: 'Next host', + group: 'Hosts', + run: () => onSelect(Math.min(last, selected + 1)), + }, + { + keys: ['k', 'up'], + description: 'Previous host', + group: 'Hosts', + hint: false, + run: () => onSelect(Math.max(0, selected - 1)), + }, + ]); + return ( + <> + {demoHosts.map((host, index) => { + const current = index === selected; + return ( + + + {current ? '› ' : ' '} + + ● + {host.name} + + ); + })} + + ); +}; + +const HostDetail = ({host, theme}: {host: DemoHost; theme: ShellTheme}) => ( + <> + + {host.name} + + {host.status} + + + region {host.region} + + + cpu {host.cpu}% + + + uptime {host.uptime} + + + +); + +const DemoStatus = ({host}: {host: DemoHost | undefined}) => { + const notify = useNotify(); + const down = demoHosts.filter((candidate) => candidate.status === 'down').length; + useStatusSegment( + host === undefined + ? undefined + : { + text: `${host.name} ${host.status}`, + tone: + host.status === 'healthy' + ? 'success' + : host.status === 'degraded' + ? 'warning' + : 'danger', + }, + ); + useStatusSegment({text: `${demoHosts.length} hosts`, side: 'right', order: 1}); + useStatusSegment({text: `${down} down`, side: 'right', tone: down > 0 ? 'danger' : 'neutral'}); 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)}, + { + keys: 'r', + description: 'Refresh', + group: 'Application', + run: () => notify(`Refreshed ${demoHosts.length} hosts`, {tone: 'success'}), + }, ]); - const colors = theme ?? relayTheme; + return null; +}; + +export const DemoApp = ({theme = relayTheme, width, height, interactive = true}: DemoAppProps) => { + const [selected, setSelected] = useState(0); + const host = demoHosts[selected] ?? demoHosts[0]; return ( - } > - - - - The frame fills the terminal and follows it when the window is resized. - - Space pressed {count} times. - - - {help ? ( - - setHelp(false)} /> - - ) : null} - + + + + + + + + {host === undefined ? null : } + + + {(host?.events ?? []).map((event) => ( + + {event} + + ))} + + + + ); }; - -export const DemoApp = ({interactive = true, ...props}: DemoAppProps) => ( - - - -); diff --git a/src/demo/hosts.ts b/src/demo/hosts.ts new file mode 100644 index 0000000..fa00302 --- /dev/null +++ b/src/demo/hosts.ts @@ -0,0 +1,52 @@ +export interface DemoHost { + name: string; + region: string; + status: 'healthy' | 'degraded' | 'down'; + cpu: number; + uptime: string; + events: readonly string[]; +} + +/** Fixed sample data so the demonstration looks the same on every run. */ +export const demoHosts: readonly DemoHost[] = [ + { + name: 'api-1', + region: 'eu-west', + status: 'healthy', + cpu: 23, + uptime: '41d 6h', + events: ['09:12 deploy 2.14.0 finished', '09:10 deploy 2.14.0 started'], + }, + { + name: 'api-2', + region: 'eu-west', + status: 'degraded', + cpu: 91, + uptime: '41d 6h', + events: ['09:31 cpu above 90% for 5m', '09:12 deploy 2.14.0 finished'], + }, + { + name: 'worker-1', + region: 'us-east', + status: 'healthy', + cpu: 47, + uptime: '12d 2h', + events: ['08:55 queue drained', '08:40 queue depth 1,204'], + }, + { + name: 'worker-2', + region: 'us-east', + status: 'down', + cpu: 0, + uptime: '—', + events: ['09:27 health check failed 3 times', '09:26 connection refused on :8080'], + }, + { + name: 'cache-1', + region: 'eu-west', + status: 'healthy', + cpu: 8, + uptime: '90d 1h', + events: ['07:00 snapshot written'], + }, +]; diff --git a/src/index.ts b/src/index.ts index e7c8750..1ea7903 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,8 @@ export * from './frame/index.js'; 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/keys/KeyHints.tsx b/src/keys/KeyHints.tsx index 6c07726..292e487 100644 --- a/src/keys/KeyHints.tsx +++ b/src/keys/KeyHints.tsx @@ -4,14 +4,16 @@ import type {ShellTheme} from '../theme.js'; import {useKeyHints} from './react.js'; export interface KeyHintsProps { - /** Show at most this many hints, highest layer first. */ + /** Show at most this many hints, highest layer first. Bindings with `hint: false` are skipped. */ 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); + const hints = useKeyHints() + .filter((hint) => hint.hint) + .slice(0, limit); return ( {hints.map((hint, index) => ( diff --git a/src/keys/registry.ts b/src/keys/registry.ts index 83f75ec..ff6e561 100644 --- a/src/keys/registry.ts +++ b/src/keys/registry.ts @@ -19,6 +19,8 @@ export interface KeyBinding { description?: string; /** Heading the binding is listed under in help. */ group?: string; + /** Set to `false` to list the binding in help but not in one-line hints. */ + hint?: boolean; /** Return `false` to let the key continue to lower layers. */ run: () => unknown; } @@ -39,6 +41,7 @@ export interface ResolvedBinding { labels: readonly string[]; description?: string; group?: string; + hint: boolean; run: () => unknown; } @@ -46,6 +49,7 @@ export interface ListedBinding { keys: string; description: string; group?: string; + hint: boolean; layer: number; } @@ -64,6 +68,7 @@ export const resolveBinding = (binding: KeyBinding): ResolvedBinding => { chords, labels: chords.map(formatChord), run: binding.run, + hint: binding.hint ?? true, ...(binding.description === undefined ? {} : {description: binding.description}), ...(binding.group === undefined ? {} : {group: binding.group}), }; @@ -158,6 +163,7 @@ export class KeyRegistry { listed.push({ keys: labels.join(' / '), description: binding.description, + hint: binding.hint, layer: entry.layer, ...(binding.group === undefined ? {} : {group: binding.group}), }); diff --git a/src/panes/Pane.tsx b/src/panes/Pane.tsx new file mode 100644 index 0000000..559d434 --- /dev/null +++ b/src/panes/Pane.tsx @@ -0,0 +1,103 @@ +import {createContext, useContext} from 'react'; +import type {ReactNode} from 'react'; +import {Box, Text} from 'ink'; +import {keyLayers, useKeybindings} from '../keys/index.js'; +import type {KeyBinding, UseKeybindingsOptions} from '../keys/index.js'; +import {relayTheme} from '../theme.js'; +import type {ShellTheme} from '../theme.js'; +import {usePaneRegistration} from './focus.js'; +import {sizeStyle} from './size.js'; +import type {PaneSize} from './size.js'; +import {useSplitDirection} from './Split.js'; + +export interface PaneState { + id: string; + title?: string; + focused: boolean; + focus: () => void; +} + +const PaneContext = createContext(undefined); + +/** The pane a component is rendered in, and whether that pane has focus. */ +export const usePane = (): PaneState => { + const pane = useContext(PaneContext); + if (pane === undefined) throw new Error('usePane must be used inside .'); + return pane; +}; + +/** Bindings that are active only while the enclosing pane has focus. */ +export const usePaneKeybindings = ( + bindings: readonly KeyBinding[], + options: Omit = {}, +): void => { + const pane = usePane(); + useKeybindings(bindings, { + ...options, + layer: keyLayers.pane, + active: pane.focused && (options.active ?? true), + }); +}; + +export interface PaneProps { + /** Stable identifier used for focus. */ + id: string; + title?: string; + /** Short text shown at the right of the title row, such as a count. */ + detail?: string; + size?: PaneSize; + minSize?: number; + /** Panes that cannot take focus are skipped by `Tab`. */ + focusable?: boolean; + /** Position in the focus order. Panes with equal order follow mount order. */ + order?: number; + border?: boolean; + theme?: ShellTheme; + children?: ReactNode; +} + +/** A bordered region of a split that can take focus and own keybindings. */ +export const Pane = ({ + id, + title, + detail, + size, + minSize, + focusable = true, + order, + border = true, + theme = relayTheme, + children, +}: PaneProps) => { + const direction = useSplitDirection(); + const {focused, focus} = usePaneRegistration(id, { + focusable, + ...(order === undefined ? {} : {order}), + }); + const style = + direction === undefined ? {flexGrow: 1, flexShrink: 1} : sizeStyle(size, direction, minSize); + const state: PaneState = {id, focused, focus, ...(title === undefined ? {} : {title})}; + return ( + + {title === undefined && detail === undefined ? null : ( + + + {title ?? ''} + + {detail === undefined ? null : {detail}} + + )} + + {children} + + + ); +}; diff --git a/src/panes/Split.tsx b/src/panes/Split.tsx new file mode 100644 index 0000000..af0c930 --- /dev/null +++ b/src/panes/Split.tsx @@ -0,0 +1,33 @@ +import {createContext, useContext} from 'react'; +import type {ReactNode} from 'react'; +import {Box} from 'ink'; +import {sizeStyle} from './size.js'; +import type {PaneSize, SplitDirection} from './size.js'; + +const SplitContext = createContext(undefined); + +/** The direction of the nearest enclosing split, if any. */ +export const useSplitDirection = (): SplitDirection | undefined => useContext(SplitContext); + +export interface SplitProps { + /** `row` places children side by side; `column` stacks them. */ + direction?: SplitDirection; + /** This split's own size when it is nested inside another split. */ + size?: PaneSize; + minSize?: number; + /** Empty cells between children. */ + gap?: number; + children?: ReactNode; +} + +/** Divides its space between panes or nested splits according to their `size`. */ +export const Split = ({direction = 'row', size, minSize, gap = 0, children}: SplitProps) => { + const parent = useSplitDirection(); + const style = + parent === undefined ? {flexGrow: 1, flexShrink: 1} : sizeStyle(size, parent, minSize); + return ( + + {children} + + ); +}; diff --git a/src/panes/focus.tsx b/src/panes/focus.tsx new file mode 100644 index 0000000..744732d --- /dev/null +++ b/src/panes/focus.tsx @@ -0,0 +1,169 @@ +import {createContext, useContext, useEffect, useMemo, useState, useSyncExternalStore} from 'react'; +import type {ReactNode} from 'react'; +import {useKeybindings} from '../keys/index.js'; + +interface FocusEntry { + id: string; + order: number; + sequence: number; +} + +/** + * Tracks which panes can take focus and which one has it. Panes are visited + * in `order`, then in the order they mounted. + */ +export class PaneFocus { + #entries = new Map(); + #sequence = 0; + #focused: string | undefined; + #preferred: string | undefined; + #listeners = new Set<() => void>(); + + constructor(initial?: string) { + this.#preferred = initial; + } + + get focused(): string | undefined { + return this.#focused; + } + + /** Pane ids in focus order. */ + get ids(): readonly string[] { + return [...this.#entries.values()] + .sort((left, right) => left.order - right.order || left.sequence - right.sequence) + .map((entry) => entry.id); + } + + register(id: string, order = 0): () => void { + if (this.#entries.has(id)) throw new Error(`A pane with id "${id}" is already mounted.`); + this.#sequence += 1; + this.#entries.set(id, {id, order, sequence: this.#sequence}); + if (id === this.#preferred) { + this.#preferred = undefined; + this.#focused = id; + } else if (this.#focused === undefined) { + this.#focused = id; + } + this.#emit(); + return () => { + const ids = this.ids; + this.#entries.delete(id); + if (this.#focused === id) { + const index = ids.indexOf(id); + const remaining = this.ids; + this.#focused = remaining[Math.min(index, remaining.length - 1)]; + } + this.#emit(); + }; + } + + /** Focus a pane. A pane that has not mounted yet takes focus when it does. */ + focus(id: string): void { + if (!this.#entries.has(id)) { + this.#preferred = id; + return; + } + this.#preferred = undefined; + if (this.#focused === id) return; + this.#focused = id; + this.#emit(); + } + + next(): void { + this.#move(1); + } + + previous(): void { + this.#move(-1); + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + #move(step: number): void { + const ids = this.ids; + if (ids.length === 0) return; + const index = this.#focused === undefined ? -1 : ids.indexOf(this.#focused); + const target = ids[(index + step + ids.length) % ids.length]; + if (target !== undefined) this.focus(target); + } + + #emit(): void { + for (const listener of this.#listeners) listener(); + } +} + +const PaneFocusContext = createContext(undefined); + +export interface PaneFocusProviderProps { + /** The pane focused first. Defaults to the first pane that mounts. */ + initialFocus?: string; + /** Keys that move focus forward and backward. Pass `false` to bind them yourself. */ + keys?: {next: string | readonly string[]; previous: string | readonly string[]} | false; + /** Called whenever another pane takes focus. */ + onFocusChange?: (id: string | undefined) => void; + children?: ReactNode; +} + +const defaultFocusKeys = {next: 'tab', previous: 'shift+tab'}; + +/** Owns pane focus and binds `Tab` and `Shift+Tab` to move it. */ +export const PaneFocusProvider = ({ + initialFocus, + keys = defaultFocusKeys, + onFocusChange, + children, +}: PaneFocusProviderProps) => { + const [focus] = useState(() => new PaneFocus(initialFocus)); + const focused = useSyncExternalStore( + (listener) => focus.subscribe(listener), + () => focus.focused, + ); + useEffect(() => { + onFocusChange?.(focused); + }, [focused, onFocusChange]); + useKeybindings( + keys === false + ? [] + : [ + {keys: keys.next, description: 'Next pane', group: 'Panes', run: () => focus.next()}, + { + keys: keys.previous, + description: 'Previous pane', + group: 'Panes', + hint: false, + run: () => focus.previous(), + }, + ], + ); + return {children}; +}; + +export const usePaneFocus = (): PaneFocus => { + const focus = useContext(PaneFocusContext); + if (focus === undefined) throw new Error('Panes must be used inside .'); + return focus; +}; + +/** The id of the focused pane, updated as focus moves. */ +export const useFocusedPane = (): string | undefined => { + const focus = usePaneFocus(); + return useSyncExternalStore( + (listener) => focus.subscribe(listener), + () => focus.focused, + ); +}; + +/** Register a focusable pane and report whether it has focus. */ +export const usePaneRegistration = (id: string, options: {order?: number; focusable?: boolean}) => { + const focus = usePaneFocus(); + const {order = 0, focusable = true} = options; + useEffect( + () => (focusable ? focus.register(id, order) : undefined), + [focus, id, order, focusable], + ); + const focused = useFocusedPane() === id; + return useMemo(() => ({focused, focus: () => focus.focus(id)}), [focus, focused, id]); +}; diff --git a/src/panes/index.ts b/src/panes/index.ts new file mode 100644 index 0000000..c34d628 --- /dev/null +++ b/src/panes/index.ts @@ -0,0 +1,11 @@ +export { + PaneFocus, + PaneFocusProvider, + useFocusedPane, + usePaneFocus, + usePaneRegistration, + type PaneFocusProviderProps, +} from './focus.js'; +export {Pane, usePane, usePaneKeybindings, type PaneProps, type PaneState} from './Pane.js'; +export {sizeStyle, type PaneSize, type SizeStyle, type SplitDirection} from './size.js'; +export {Split, useSplitDirection, type SplitProps} from './Split.js'; diff --git a/src/panes/size.ts b/src/panes/size.ts new file mode 100644 index 0000000..3d0decc --- /dev/null +++ b/src/panes/size.ts @@ -0,0 +1,42 @@ +/** + * How much of a split a pane or nested split takes along the split's axis: + * a fixed number of cells, a percentage, or a share of the remaining space. + */ +export type PaneSize = number | `${number}%` | `${number}fr`; + +export type SplitDirection = 'row' | 'column'; + +export interface SizeStyle { + width?: number | string; + height?: number | string; + flexGrow: number; + flexShrink: number; + flexBasis?: number | string; + minWidth?: number; + minHeight?: number; +} + +const fraction = /^(\d+(?:\.\d+)?)fr$/; +const percent = /^(\d+(?:\.\d+)?)%$/; + +/** Translate a pane size into flexbox style for a child of a split. */ +export const sizeStyle = ( + size: PaneSize | undefined, + direction: SplitDirection, + minimum?: number, +): SizeStyle => { + const axis = direction === 'row' ? 'width' : 'height'; + const minAxis = direction === 'row' ? 'minWidth' : 'minHeight'; + const min = minimum === undefined ? {} : {[minAxis]: minimum}; + if (typeof size === 'number') { + if (!Number.isFinite(size) || size < 0) throw new Error(`Invalid pane size: ${size}`); + return {[axis]: Math.floor(size), flexGrow: 0, flexShrink: 0, ...min}; + } + const value = size ?? '1fr'; + const share = fraction.exec(value); + if (share?.[1] !== undefined) { + return {flexGrow: Number(share[1]), flexShrink: 1, flexBasis: 0, ...min}; + } + if (percent.test(value)) return {[axis]: value, flexGrow: 0, flexShrink: 1, ...min}; + throw new Error(`Invalid pane size: ${value}`); +}; 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/src/status/StatusBar.tsx b/src/status/StatusBar.tsx new file mode 100644 index 0000000..68f134d --- /dev/null +++ b/src/status/StatusBar.tsx @@ -0,0 +1,75 @@ +import type {ReactNode} from 'react'; +import {Box, Text} from 'ink'; +import {relayTheme} from '../theme.js'; +import type {ShellTheme} from '../theme.js'; +import {useStatusSnapshot} from './react.js'; +import type {StatusSegment, StatusTone} from './store.js'; + +export interface StatusBarProps { + /** A short label at the far left, such as the current view or mode. */ + label?: string; + /** Extra content after the right-hand segments, such as a clock. */ + children?: ReactNode; + theme?: ShellTheme; +} + +export const toneColor = (tone: StatusTone | undefined, theme: ShellTheme): string => { + switch (tone) { + case 'info': + return theme.focus; + case 'success': + return theme.success; + case 'warning': + return theme.warning; + case 'danger': + return theme.danger; + default: + return theme.muted; + } +}; + +const Segments = ({segments, theme}: {segments: readonly StatusSegment[]; theme: ShellTheme}) => ( + <> + {segments.map((segment, index) => ( + + {index === 0 ? '' : · } + {segment.text} + + ))} + +); + +/** + * One row with a label, the segments components publish, and the latest + * notice, which replaces the left-hand segments until it expires. + */ +export const StatusBar = ({label, children, theme = relayTheme}: StatusBarProps) => { + const {left, right, notice} = useStatusSnapshot(); + return ( + + + + {label === undefined ? null : ( + + {` ${label} `} + + )} + + {notice === undefined ? ( + + ) : ( + {notice.message} + )} + + + + + + {children === undefined ? null : · } + {children} + + + + + ); +}; diff --git a/src/status/index.ts b/src/status/index.ts new file mode 100644 index 0000000..3ebc876 --- /dev/null +++ b/src/status/index.ts @@ -0,0 +1,17 @@ +export {StatusBar, toneColor, type StatusBarProps} from './StatusBar.js'; +export { + StatusProvider, + useNotify, + useStatusSegment, + useStatusSnapshot, + useStatusStore, + type StatusProviderProps, +} from './react.js'; +export { + StatusStore, + type NotifyOptions, + type StatusNotice, + type StatusSegment, + type StatusSnapshot, + type StatusTone, +} from './store.js'; diff --git a/src/status/react.tsx b/src/status/react.tsx new file mode 100644 index 0000000..5115cdb --- /dev/null +++ b/src/status/react.tsx @@ -0,0 +1,49 @@ +import {createContext, useContext, useEffect, useId, useState, useSyncExternalStore} from 'react'; +import type {ReactNode} from 'react'; +import {StatusStore} from './store.js'; +import type {NotifyOptions, StatusSegment, StatusSnapshot} from './store.js'; + +const StatusContext = createContext(undefined); + +export interface StatusProviderProps { + /** Supply a store to publish status from outside React. */ + store?: StatusStore; + children?: ReactNode; +} + +export const StatusProvider = ({store, children}: StatusProviderProps) => { + const [owned] = useState(() => store ?? new StatusStore()); + useEffect(() => () => owned.dispose(), [owned]); + return {children}; +}; + +export const useStatusStore = (): StatusStore => { + const store = useContext(StatusContext); + if (store === undefined) throw new Error('Status must be used inside .'); + return store; +}; + +/** Show a segment in the status bar for as long as the component is mounted. */ +export const useStatusSegment = (segment: StatusSegment | undefined): void => { + const store = useStatusStore(); + const id = useId(); + useEffect(() => () => store.remove(id), [store, id]); + useEffect(() => { + if (segment === undefined) store.remove(id); + else store.set(id, segment); + }); +}; + +/** Show a short message in the status bar, such as "Saved" or "Connection lost". */ +export const useNotify = (): ((message: string, options?: NotifyOptions) => void) => { + const store = useStatusStore(); + return (message, options) => store.notify(message, options); +}; + +export const useStatusSnapshot = (): StatusSnapshot => { + const store = useStatusStore(); + return useSyncExternalStore( + (listener) => store.subscribe(listener), + () => store.snapshot(), + ); +}; diff --git a/src/status/store.ts b/src/status/store.ts new file mode 100644 index 0000000..b820580 --- /dev/null +++ b/src/status/store.ts @@ -0,0 +1,115 @@ +export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + +export interface StatusSegment { + text: string; + /** Which end of the bar the segment sits at. Defaults to `left`. */ + side?: 'left' | 'right'; + tone?: StatusTone; + /** Lower numbers sit closer to the bar's edge. Equal values keep mount order. */ + order?: number; +} + +export interface StatusNotice { + message: string; + tone: StatusTone; +} + +export interface NotifyOptions { + tone?: StatusTone; + /** How long the notice stays visible. `0` keeps it until the next notice or `dismiss()`. */ + durationMs?: number; +} + +export interface StatusSnapshot { + left: readonly StatusSegment[]; + right: readonly StatusSegment[]; + notice?: StatusNotice; +} + +interface StoredSegment { + segment: StatusSegment; + sequence: number; +} + +const sameSegment = (left: StatusSegment, right: StatusSegment): boolean => + left.text === right.text && + left.side === right.side && + left.tone === right.tone && + left.order === right.order; + +/** + * Collects the segments components publish to the status bar and the one + * transient notice that temporarily replaces the left side. + */ +export class StatusStore { + #segments = new Map(); + #sequence = 0; + #notice: StatusNotice | undefined; + #timer: ReturnType | undefined; + #listeners = new Set<() => void>(); + #snapshot: StatusSnapshot = {left: [], right: []}; + + set(id: string, segment: StatusSegment): void { + const existing = this.#segments.get(id); + if (existing !== undefined && sameSegment(existing.segment, segment)) return; + if (existing === undefined) this.#sequence += 1; + this.#segments.set(id, {segment, sequence: existing?.sequence ?? this.#sequence}); + this.#changed(); + } + + remove(id: string): void { + if (this.#segments.delete(id)) this.#changed(); + } + + notify(message: string, options: NotifyOptions = {}): void { + const {tone = 'info', durationMs = 4000} = options; + this.#clearTimer(); + this.#notice = {message, tone}; + if (durationMs > 0) { + this.#timer = setTimeout(() => this.dismiss(), durationMs); + this.#timer.unref(); + } + this.#changed(); + } + + dismiss(): void { + this.#clearTimer(); + if (this.#notice === undefined) return; + this.#notice = undefined; + this.#changed(); + } + + snapshot(): StatusSnapshot { + return this.#snapshot; + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + /** Cancel a pending notice timer, for example when the application exits. */ + dispose(): void { + this.#clearTimer(); + } + + #clearTimer(): void { + if (this.#timer !== undefined) clearTimeout(this.#timer); + this.#timer = undefined; + } + + #changed(): void { + const sorted = [...this.#segments.values()].sort( + (left, right) => + (left.segment.order ?? 0) - (right.segment.order ?? 0) || left.sequence - right.sequence, + ); + const segments = sorted.map((stored) => stored.segment); + this.#snapshot = { + left: segments.filter((segment) => segment.side !== 'right'), + // The right side reads from the edge inwards, so the lowest order is last. + right: segments.filter((segment) => segment.side === 'right').reverse(), + ...(this.#notice === undefined ? {} : {notice: this.#notice}), + }; + for (const listener of this.#listeners) listener(); + } +} diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx index fac694e..736c2f3 100644 --- a/tests/demo.test.tsx +++ b/tests/demo.test.tsx @@ -6,12 +6,38 @@ import {tick} from './helpers.js'; describe('demonstration', () => { it('opens the keyboard reference over the frame', async () => { - const view = render(); + 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'); }); + + it('moves through hosts only while the host list has focus', async () => { + const view = render(); + await tick(); + view.stdin.write('j'); + await tick(); + expect(view.lastFrame()).toContain('› ● api-2'); + view.stdin.write('\t'); + await tick(); + view.stdin.write('j'); + await tick(); + expect(view.lastFrame()).toContain('› ● api-2'); + expect(view.lastFrame()).not.toContain('next host'); + }); + + it('shows the focused pane and a refresh notice in the status bar', async () => { + const view = render(); + await tick(); + expect(view.lastFrame()).toContain(' HOSTS api-1 healthy'); + expect(view.lastFrame()).toContain('5 hosts · 1 down'); + view.stdin.write(' '); + await tick(); + expect(view.lastFrame()).toContain(' DETAIL '); + view.stdin.write('r'); + await tick(); + expect(view.lastFrame()).toContain('Refreshed 5 hosts'); + }); }); diff --git a/tests/panes.test.tsx b/tests/panes.test.tsx new file mode 100644 index 0000000..b7ea956 --- /dev/null +++ b/tests/panes.test.tsx @@ -0,0 +1,152 @@ +import React, {useState} from 'react'; +import {Text} from 'ink'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import {KeymapProvider} from '../src/keys/index.js'; +import { + Pane, + PaneFocus, + PaneFocusProvider, + Split, + sizeStyle, + usePane, + usePaneKeybindings, +} from '../src/panes/index.js'; +import type {PaneFocusProviderProps} from '../src/panes/index.js'; +import {tick} from './helpers.js'; + +describe('pane sizes', () => { + it('maps cells, percentages, and shares onto the split axis', () => { + expect(sizeStyle(20, 'row')).toMatchObject({width: 20, flexGrow: 0, flexShrink: 0}); + expect(sizeStyle('30%', 'column')).toMatchObject({height: '30%'}); + expect(sizeStyle('2fr', 'row', 10)).toMatchObject({flexGrow: 2, flexBasis: 0, minWidth: 10}); + expect(sizeStyle(undefined, 'row')).toMatchObject({flexGrow: 1}); + }); + + it('rejects sizes it cannot place', () => { + expect(() => sizeStyle(-1, 'row')).toThrow('Invalid pane size'); + expect(() => sizeStyle('wide' as never, 'row')).toThrow('Invalid pane size'); + }); +}); + +describe('PaneFocus', () => { + it('cycles in order and wraps around', () => { + const focus = new PaneFocus(); + focus.register('b', 1); + focus.register('a', 0); + focus.register('c', 1); + expect(focus.ids).toEqual(['a', 'b', 'c']); + expect(focus.focused).toBe('b'); + focus.next(); + expect(focus.focused).toBe('c'); + focus.next(); + expect(focus.focused).toBe('a'); + focus.previous(); + expect(focus.focused).toBe('c'); + }); + + it('honors an initial pane that mounts after others', () => { + const focus = new PaneFocus('log'); + focus.register('list'); + focus.register('log'); + expect(focus.focused).toBe('log'); + }); + + it('moves focus to a neighbour when the focused pane unmounts', () => { + const focus = new PaneFocus(); + focus.register('a'); + const removeB = focus.register('b'); + focus.register('c'); + focus.focus('b'); + removeB(); + expect(focus.focused).toBe('c'); + }); + + it('refuses duplicate ids', () => { + const focus = new PaneFocus(); + focus.register('a'); + expect(() => focus.register('a')).toThrow('already mounted'); + }); +}); + +const Label = () => { + const pane = usePane(); + return {pane.focused ? `${pane.id} has focus` : `${pane.id} idle`}; +}; + +const Layout = (props: PaneFocusProviderProps) => ( + + + + + + + + + + + + + + +); + +describe('panes', () => { + it('lays out nested splits within the frame', async () => { + const view = render(); + await tick(); + const lines = (view.lastFrame() ?? '').split('\n'); + expect(lines[0]).toMatch(/^╭─{22}╮╭/); + expect(lines.some((line) => line.includes('Hosts') && line.includes('Detail'))).toBe(true); + }); + + it('moves focus with Tab and Shift+Tab', async () => { + const onFocusChange = vi.fn(); + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('list has focus'); + view.stdin.write('\t'); + await tick(); + expect(view.lastFrame()).toContain('detail has focus'); + expect(view.lastFrame()).toContain('list idle'); + view.stdin.write('\u001B[Z'); + await tick(); + expect(view.lastFrame()).toContain('list has focus'); + expect(onFocusChange).toHaveBeenLastCalledWith('list'); + }); + + it('sends pane keys only to the focused pane', async () => { + const Counter = () => { + const pane = usePane(); + const [count, setCount] = useState(0); + usePaneKeybindings([{keys: 'j', description: 'Down', run: () => setCount(count + 1)}]); + return ( + + {pane.id}={count} + + ); + }; + const view = render( + + + + + + + + + + + + , + ); + await tick(); + view.stdin.write('j'); + await tick(); + expect(view.lastFrame()).toContain('left=0'); + expect(view.lastFrame()).toContain('right=1'); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts index caeb830..9a6a2e7 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -102,6 +102,12 @@ describe('KeyRegistry', () => { expect(registry.list().map((hint) => hint.description)).toEqual(['Confirm']); }); + it('keeps bindings marked hint: false in the list for help', () => { + const registry = new KeyRegistry(); + registry.register([{keys: 'k', description: 'Up', hint: false, run: vi.fn()}]); + expect(registry.list()).toEqual([{keys: 'k', description: 'Up', hint: false, layer: 0}]); + }); + it('notifies subscribers only when the listed bindings change', () => { const registry = new KeyRegistry(); const listener = vi.fn(); 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'); + }); +}); diff --git a/tests/status.test.tsx b/tests/status.test.tsx new file mode 100644 index 0000000..b951f7e --- /dev/null +++ b/tests/status.test.tsx @@ -0,0 +1,120 @@ +import React, {useState} from 'react'; +import {Text} from 'ink'; +import {render} from 'ink-testing-library'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {KeymapProvider, useKeybindings} from '../src/keys/index.js'; +import { + StatusBar, + StatusProvider, + StatusStore, + useNotify, + useStatusSegment, +} from '../src/status/index.js'; +import {tick} from './helpers.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('StatusStore', () => { + it('orders segments by side, order, and mount order', () => { + const store = new StatusStore(); + store.set('a', {text: 'first'}); + store.set('b', {text: 'edge', order: -1}); + store.set('c', {text: 'right edge', side: 'right'}); + store.set('d', {text: 'right inner', side: 'right', order: 1}); + const snapshot = store.snapshot(); + expect(snapshot.left.map((segment) => segment.text)).toEqual(['edge', 'first']); + expect(snapshot.right.map((segment) => segment.text)).toEqual(['right inner', 'right edge']); + }); + + it('skips notifications when a segment is unchanged', () => { + const store = new StatusStore(); + const listener = vi.fn(); + store.subscribe(listener); + store.set('a', {text: 'same'}); + store.set('a', {text: 'same'}); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('expires notices and lets a new notice replace an old one', () => { + vi.useFakeTimers(); + const store = new StatusStore(); + store.notify('Saved', {durationMs: 1000}); + vi.advanceTimersByTime(600); + store.notify('Saved again', {tone: 'success', durationMs: 1000}); + vi.advanceTimersByTime(600); + expect(store.snapshot().notice).toEqual({message: 'Saved again', tone: 'success'}); + vi.advanceTimersByTime(500); + expect(store.snapshot().notice).toBeUndefined(); + }); + + it('keeps a notice with no duration until it is dismissed', () => { + vi.useFakeTimers(); + const store = new StatusStore(); + store.notify('Offline', {tone: 'danger', durationMs: 0}); + vi.advanceTimersByTime(60_000); + expect(store.snapshot().notice?.message).toBe('Offline'); + store.dismiss(); + expect(store.snapshot().notice).toBeUndefined(); + }); +}); + +describe('StatusBar', () => { + it('shows published segments and removes them on unmount', async () => { + const Publisher = ({text}: {text: string}) => { + useStatusSegment({text}); + return null; + }; + const view = render( + + + + 12:00 + + , + ); + await tick(); + expect(view.lastFrame()).toContain(' HOSTS 5 hosts'); + expect(view.lastFrame()).toContain('12:00'); + view.rerender( + + + , + ); + await tick(); + expect(view.lastFrame()).not.toContain('5 hosts'); + }); + + it('replaces the left side with a notice', async () => { + const Saver = () => { + const notify = useNotify(); + const [saves, setSaves] = useState(0); + useStatusSegment({text: `${saves} saves`}); + useKeybindings([ + { + keys: 'ctrl+s', + run: () => { + setSaves(saves + 1); + notify('Saved', {tone: 'success'}); + }, + }, + ]); + return null; + }; + const view = render( + + + + + + , + ); + await tick(); + expect(view.lastFrame()).toContain('0 saves'); + view.stdin.write('\u0013'); + await tick(); + expect(view.lastFrame()).toContain('Saved'); + expect(view.lastFrame()).not.toContain('1 saves'); + }); +});