Skip to main content

Key bindings

Navigation, edit, accept and cancel keys are all configurable. A widget never asks for a fixed key - it asks for a semantic action (MoveUp, Accept, Toggle - the full set is listed below), and a key map binds each action to one or more keys. Key bindings describe the terminal, not the questionnaire, so you set them on the Tui facade with ->keys(...), mirroring ->theme(...):

$tui = (new Tui($form))->keys('vim'); // built-in vim navigation (h/j/k/l)

Two presets ship: default (every binding it declares is listed below) and vim, which adds h/j/k/l alongside the arrow keys - but only where a letter isn't typed input, so text and filter fields keep the arrows.

Actions

The actions are the fixed set of intents the widgets understand - the bindings behind them are configurable, the intents are not. Every Action case:

ActionMeaning
MoveUp / MoveDown / MoveLeft / MoveRightMove the cursor or caret
AcceptCommit the editor's value
CancelClose the editor without committing
ActivateOpen the focused field or panel (panel browser)
BackGo up one panel level
QuitLeave the TUI
ScrollUp / ScrollDownScroll the panel without moving the cursor
HelpOpen the help overlay
DeleteBackDelete the character before the caret
InsertSpaceType a space
NewLineInsert a newline (textarea)
ExternalEditHand off to the external editor (textarea)
CompleteAccept the ghost-text completion (text)
Increment / DecrementStep a bounded number
ToggleCheck an option, or flip a two-state switch
SelectAll / SelectNoneCheck or clear every visible option
GrabPick up or drop the held item (reorder)
Yes / NoSet a confirm directly
RevealCycle the password display, or show hidden file-picker entries

The named keys a binding can use - every KeyName case: Up, Down, Left, Right, Enter, Escape, Interrupt (Ctrl-C), Space, Backspace, Delete, Tab, Home, End, PageUp, PageDown, MouseWheelUp, MouseWheelDown. Anything printable is bound as the character itself ('q', '?').

The default bindings

What default actually declares, scope by scope. Every widget inherits the base layer and overrides only what differs:

ScopeKeysAction
base (every widget) / / / MoveUp / MoveDown / MoveLeft / MoveRight
baseEnterAccept
baseEscCancel
baseBackspaceDeleteBack
baseSpaceInsertSpace
navigationEnterActivate
navigationEscBack
navigationqQuit
navigation?Help
navigationmouse wheelScrollUp / ScrollDown
number / Increment / Decrement
textTabComplete
textareaEnterNewLine
textareaTabAccept
textareaCtrl-EExternalEdit
confirmy/Y and n/NYes and No
confirm, toggle / / Space / / Toggle
passwordTabReveal
pauseEnter / SpaceAccept
select (multiple), search (multiple)SpaceToggle
select (multiple), search (multiple) / SelectAll / SelectNone
file picker (single and multiple)TabReveal
file picker (multiple)SpaceToggle
reorderSpaceGrab

vim inherits all of it and adds letters where typing can't swallow them: the panel browser and the single-choice select gain k/j for up/down, and the calendar gains k/j/h/l for week and day movement - each alongside the arrow keys, never replacing them.

Not everything on the keyboard routes through the map: a few widget keys are fixed. The calendar's month and edge jumps (PageUp/PageDown, Home/End) have no action behind them and always keep their keys.

Per-widget-type overrides

Bindings are layered by scope: a base layer shared by every widget, a navigation layer for the panel browser, and one layer per widget type that overrides the base only where it differs (Enter inserts a newline in a textarea, Space toggles a checkbox option). To retune individual bindings, pass overrides on top of a preset - each names a scope, an action and its keys. Later bindings win, so re-declaring a scope-and-action pair replaces the preset's keys for it:

use DrevOps\Tui\Model\FieldType;
use DrevOps\Tui\Input\Action;
use DrevOps\Tui\Input\Binding;
use DrevOps\Tui\Input\KeyName;
use DrevOps\Tui\Input\Scope;

$tui = (new Tui($form))->keys('default', [
// Quit with x as well as q.
new Binding(Scope::navigation(), Action::Quit, 'x'),
// In the single-choice list, Tab accepts too.
new Binding(Scope::field(FieldType::Select), Action::Accept, KeyName::Tab, KeyName::Enter),
]);

A binding's keys take three forms: a KeyName case for a named key, a single-character string for a printable one, or a Key for anything else - Key::char("\x05") is how the default preset binds Ctrl-E. A scope takes three forms too: Scope::base() (shared by every widget), Scope::navigation() (the panel browser), and Scope::field(FieldType::X) - with Scope::field(FieldType::X, multiple: TRUE) targeting the multiple-collecting variant of a choice or file-picker widget, which carries its own binding set (that's where Space-to-toggle lives).

The panel and editor hints are drawn from the live bindings, so they always reflect the active keys - remap quit to x and the footer says so.

Presets and validation

A preset is a class listing its bindings. Subclass DefaultKeyMap to ship your own, name it directly with ->keys('\App\MyKeyMap'), or register a short alias with KeyMapManager::register('mine', MyKeyMap::class) and then ->keys('mine').

Bindings are validated the moment they're set, so a bad key map is caught at configuration time, not mid-session:

  • a key bound to two different actions in the same scope is a conflict;
  • a printable character bound in the base scope, or in a scope whose widget consumes typed input (text, number, password, textarea, search, suggest, toggle, file picker, and the multiple select), would be un-typeable and is rejected - control characters like Ctrl-E are exempt, since they're command keys, never typed content;
  • an unknown preset name, or a character binding that is not exactly one character, is rejected.

See playground/10-key-bindings-* for the default map, the vim preset and a custom override side by side.