controlroom
Guides

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:

keyaction
left, righttab.prev, tab.next
tab, shift+tabfocus.next, focus.prev
up, downscroll.up, scroll.down
pageup, pagedownscroll.pageUp, scroll.pageDown
home, endscroll.top, scroll.bottom
ctrl+cquit

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

actionwhat it changes
tab.next, tab.prevthe active tab, wrapping around
tab:<title>the active tab, by title; an unknown title changes nothing
focus.next, focus.prevthe focused pane, among the screen's focus list, wrapping
toggle:<pane>collapses the pane, or expands it again
scroll.up, scroll.downthe focused pane's first row, by one line
scroll.pageUp, scroll.pageDownthe focused pane's first row, by its height
scroll.top, scroll.bottomthe focused pane, to its first row or pinned to its last
resizeevery 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:

tabs.mjs
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(', ')}]`);
}
node tabs.mjs
←→ 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:

scroll.mjs
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'));
node scroll.mjs
line 1
line 2
line 3
line 4
↓ 195

Off 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.

On this page