Tabs, focus and keys
A keymap is data: key to action name. A pure reducer moves tabs, focus, collapse and scrolling, and the hint line is generated from the keymap, so it can never name a key that does nothing.
controlroom never binds a key to a function. A keymap maps a key to an action name,
the screen's state moves through a reducer on that name, and your onAction sees every
action after the reducer has. Because the keymap is plain data, the hint line is generated
from it, and a plugin can ship one.
Keymaps
const keymap = { left: 'tab.prev', right: 'tab.next', s: 'toggle:status', q: 'quit', 'ctrl+c': 'quit' };Keys are spelled as caique's key decoder spells them: a character (s, q), a named key
(left, right, up, down, pageup, pagedown, home, end, enter, escape, tab), and modifiers joined with +
(ctrl+c, shift+tab). A screen given no keymap uses the registered default:
| key | action |
|---|---|
left, right | tab.prev, tab.next |
tab, shift+tab | focus.next, focus.prev |
up, down | scroll.up, scroll.down |
pageup, pagedown | scroll.pageUp, scroll.pageDown |
home, end | scroll.top, scroll.bottom |
ctrl+c | quit |
ctrl+c is quit in every keymap that does not bind it to something else, so a screen can
always be left. A plugin can register a keymap under a name, including
its own default.
The actions the reducer knows
| action | what it changes |
|---|---|
tab.next, tab.prev | the active tab, wrapping around |
tab:<title> | the active tab, by title; an unknown title changes nothing |
focus.next, focus.prev | the focused pane, among the screen's focus list, wrapping |
toggle:<pane> | collapses the pane, or expands it again |
scroll.up, scroll.down | the focused pane's first row, by one line |
scroll.pageUp, scroll.pageDown | the focused pane's first row, by its height |
scroll.top, scroll.bottom | the focused pane, to its first row or pinned to its last |
resize | every scrolled pane, re-clamped to the new size; the screen sends it on a resize |
Anything else, such as quit or refresh, leaves the state alone and is your program's to
handle in onAction. The reducer is exported as initial(tabs, focus) and
reduce(state, action), pure functions you can run without a screen:
import { hints, initial, reduce } from 'controlroom';
const keymap = { left: 'tab.prev', right: 'tab.next', s: 'toggle:status', q: 'quit', 'ctrl+c': 'quit' };
const labels = { 'tab.prev': 'switch tab', 'tab.next': 'switch tab', 'toggle:status': 'toggle status', quit: 'quit' };
console.log(hints(keymap, labels));
let state = initial(['Status', 'Tail logs'], ['learn', 'tasks']);
for (const action of ['tab.next', 'focus.next', 'toggle:status', 'quit']) {
state = reduce(state, action);
console.log(`${action.padEnd(13)} active=${state.active} focused=${state.panes[state.focused]} collapsed=[${[...state.collapsed].join(', ')}]`);
}←→ switch tab s toggle status q/^C quit
tab.next active=1 focused=learn collapsed=[]
focus.next active=1 focused=tasks collapsed=[]
toggle:status active=1 focused=tasks collapsed=[status]
quit active=1 focused=tasks collapsed=[status]Scrolling
A pane taller than its rectangle scrolls. Its position is reducer state, state.scroll: a
pane with no entry, or with 'end', is pinned to its bottom and shows new lines as they
arrive, like a log tail. Scrolled up, it stays where it was put, and its last row says how
many lines are below (↓ 180). Reaching the last page pins it again.
The scroll actions move the focused pane, so list the pane in the screen's focus. The
reducer needs each pane's height to clamp, which a screen measures for you; outside a screen,
extents(layout, panes, size) is the same measurement:
import { compose, extents, initial, reduce } from 'controlroom';
const log = { component: { name: 'log', static: (s) => s }, state: Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join('\n') };
const size = { columns: 20, rows: 5, t: 0, live: true };
let state = initial([], ['log']);
state = reduce(state, 'scroll.top', extents('log', { log }, size));
// Every row is padded to the frame's width; trimmed here to print.
console.log(compose('log', { log }, size, state).map((row) => row.trimEnd()).join('\n'));line 1
line 2
line 3
line 4
↓ 195Off a terminal nothing scrolls: a pipe, CI, a screen reader or an agent cannot press a key, so each pane prints every line, whatever the state. A screen that closes inline settles every line of every pane into the scrollback, for the same reason.
The hint line
hints(keymap, labels) prints one entry per label, with every key bound to it. Arrows run
together (←→); any other set of keys is joined by a slash (q/^C). An action with no label
is bound but not advertised. Since the line is derived from the keymap, it cannot name a key
that is not bound, and a labelled action cannot be missing from it.
Put the hint line in a pane of its own with liveOnly: true: a key hint is for someone who can
press the key, so a pipe and an agent never see it.
A tab bar
A tab bar is a pane over flagstaff's tabBar(), given the titles and the active index. Keep it
in step from onAction, which runs after the reducer has moved:
onAction(action, screen) {
screen.update('tabs', { tabs: TABS, active: screen.state.active });
}Mark it liveOnly too. Off a terminal there are no tabs to switch: every pane prints, under its
label.
Layout
Rows and columns of panes, sized in cells, by fraction with a minimum, or to fit their content — integer arithmetic, no flexbox, and what happens when the terminal is too small.
The input line
caique's line editor hosted in a pane: keys go to it while it has focus and fall through to the keymap when it does not use them; in a pipe, each stdin line is an entry and nothing ever waits.