Skip to content

Key handling

Keyboard support is built into Fleury’s widgets. A ListView already responds to the arrow keys, a TextInput already edits text, and buttons already react to their activation keys.

Use an AppCommand when an application action such as save, refresh, or deploy should also be reachable from a button, command palette, semantic agent, or test. Its shortcut is part of the same declaration; see Commands.

Use KeyBindings when the behavior is specifically keyboard-shaped: movement keys, a multi-key editor gesture, or keys that only mean something in one pane. Fleury routes each key through the focused part of the tree (with nothing focused, through every KeyBindings outside an open dialog), chooses the nearest matching binding, and shows its label in shortcut hints.

The keyboard-specific tools are:

You need to…Use
Run a command for a known key or sequenceKeyBinding inside KeyBindings
Inspect raw keys inside a custom control and decide whether to consume themKeyDetector
Read what is currently held during a game or simulation tickKeyboard.snapshot

This guide starts with scoped bindings, then covers lower-level key handling. To control where keys go—focusable widgets, Tab order, and traversal—see Focus management.

A few keys have behavior of their own in Fleury’s runtime. Ctrl+C and Ctrl+Z act only when nothing in your app handles them, so a binding can claim them. The debug shell takes its keys before your bindings see them:

KeyWhat Fleury doesTo use it in your app
Ctrl+CIn a terminal, ends the app when nothing handles it: runApp returns AppExit.signal(AppSignal.interrupt). With text selected, it copies instead. In a browser, it’s an ordinary key.Bind it. A binding that handles it keeps the app running; see Shutdown and signals.
Ctrl+ZIn a terminal on macOS or Linux, suspends the app when nothing handles it, until fg resumes it, but only when a job-control shell, such as your interactive shell, started the app. When a terminal profile, a tmux pane, or ssh -t host app runs the app directly, nothing could resume it, so the press is an ordinary key (with a few exceptions). A focused text field undoes instead. While the debug panel is expanded over your app, the press skips the hidden app; while its Logs search is open, the search takes it.Bind it, or turn suspension off with PosixTerminalDriver(suspendOnCtrlZ: false). To suspend from another key, such as when a text field always has focus, call the terminal session’s suspend() from its binding.
Ctrl+G, F12Open the debug shell and its Logs tab whenever debug tooling is on, even with the panel closed. Your bindings never see them.Turn the debug shell off with runApp(const MyApp(), debug: const DebugConfig(enabled: false)). It’s already off in compiled builds.
Tab, Shift+Tab, ←, →, f, F11, pGo to the debug panel while it’s open. Some of its tabs take more: Page Up and Page Down outside Logs, ↑, ↓, and Home on Tree, and /, s, and a search in progress on Logs. F5 restarts when the panel offers it, and Esc clears a Logs search or docks an expanded panel.Close the panel with Ctrl+G.

A text field that always has focus, like a chat composer’s or a REPL prompt’s, undoes on every Ctrl+Z, so Ctrl+Z never suspends that app. Give it a suspend key of its own that calls suspend() on the terminal session:

final session = context.scope<TerminalSession>();
return KeyBindings(
bindings: [
KeyBinding(
.ctrl.t,
label: 'Suspend',
enabled: session.supportsSuspend,
onTrigger: (_) => session.suspend(),
),
],
child: TextArea(controller: draft, autofocus: true),
);

suspend() does exactly what an unhandled Ctrl+Z does, and its future completes once fg brings the app back. When no job-control shell started the app, or under fleury serve, fleury shell, or Windows, there’s no shell job to suspend: supportsSuspend is false, so the binding stays disabled. A few launchers look like a shell’s job without being one; see Terminal modes. A browser app has no terminal session at all; code that also runs there reads context.scope<TerminalSession?>().

The Ctrl+Z switch belongs to the terminal driver, so you pass the driver yourself on macOS and Linux: runApp(app, driver: PosixTerminalDriver(suspendOnCtrlZ: false)). It turns off only the suspension of an unhandled press: suspend() still works, so an app that binds Ctrl+Z can close what it must and then suspend. An app that passes driver: replaces the driver runApp would have chosen, and gives up what came with it: the app no longer attaches to fleury serve, fleury shell, or fleury_mcp, stray output isn’t captured, and the debug shell offers no hot restart. Under a plain dart run, it also runs without hot reload.

Your terminal, multiplexer, operating system, or editor can also take a key before Fleury sees it: tmux’s prefix (Ctrl+B by default), F5 in VS Code’s integrated terminal, or F11, which many terminals, editors, and desktops reserve for full screen or for showing the desktop. Offer another way to trigger essential commands.

Wrap the part of the application that owns the keyboard behavior in KeyBindings, then add a KeyBinding for each key:

KeyBindings(
bindings: [
KeyBinding(.j, label: 'Next', onTrigger: (_) => selectNext()),
KeyBinding(.k, label: 'Previous', onTrigger: (_) => selectPrevious()),
],
child: const InboxPane(),
);

The gesture uses KeySequence dot shorthand. In a context where Dart cannot infer the type, write it in full: KeySequence.j, KeySequence.ctrl.s.

The label is part of the declaration because Fleury shows it in discoverability surfaces such as KeyHintBar.

A binding fires once per press by default: holding the key does not fire it again, as long as the input marks repeats. Browsers and terminals using the full Kitty keyboard protocol mark every key. Inside tmux, Screen, or Zellij, Fleury uses a reduced protocol by default that marks only chords, arrows, and function keys, and a classic terminal marks none; there, auto-repeats of other keys arrive as new presses. Set includeRepeats: true for movement that should repeat everywhere.

Put global keyboard behavior near the root:

runApp(
KeyBindings(
bindings: [
KeyBinding(.q, label: 'Quit', onTrigger: (_) => exitApp()),
],
child: const MyApp(),
),
);

Add another KeyBindings around a pane, screen, or control when keys are local to that region. Fleury checks the focused chain deepest-first, so the nearest declaration wins. An inbox pane can bind d to delete a message while a file pane binds d to download, without either scope knowing about the other.

A key that matches nothing continues to the next scope outward. Set modal: true on a scope that must stop unmatched keys, such as a custom overlay; routed dialogs already get this behavior.

A command can accept aliases, use more than one key, or opt into auto-repeat. This example combines the common forms:

You write · editable
class _KeyBindingsTourState extends State<_KeyBindingsTour> {
static const _count = 7;
String _last = 'move with j / k, bookmark with Ctrl+S, clear with Space c';
int _row = 3;
final Set<int> _saved = <int>{};
void _move(int delta) =>
setState(() => _row = (_row + delta).clamp(0, _count - 1));
void _toggleSave() => setState(() {
if (_saved.remove(_row)) {
_last = 'Un-bookmarked item ${_row + 1}';
} else {
_saved.add(_row);
_last = 'Bookmarked item ${_row + 1} ★';
}
});
// The Space leader clears every bookmark in one stroke — a simple,
// obviously-useful action tied to Save.
void _clearSaved() => setState(() {
if (_saved.isEmpty) {
_last = 'No bookmarks to clear';
return;
}
final n = _saved.length;
_saved.clear();
_last = 'Cleared $n bookmark${n == 1 ? '' : 's'}';
});
@override
Widget build(BuildContext context) {
return KeyBindings(
bindings: [
KeyBinding(.ctrl.s, label: 'Bookmark', onTrigger: (_) => _toggleSave()),
KeyBinding(
.j,
aliases: [.down],
label: 'Down',
includeRepeats: true,
onTrigger: (_) => _move(1),
),
KeyBinding(
.k,
aliases: [.up],
label: 'Up',
includeRepeats: true,
onTrigger: (_) => _move(-1),
),
KeyBinding(
.g.g,
label: 'Top',
onTrigger: (_) => setState(() {
_row = 0;
_last = 'Jumped to top';
}),
),
KeyBinding(.space.c, label: 'Clear ★', onTrigger: (_) => _clearSaved()),
],
child: Focus(
autofocus: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(1),
child: Text(_last, style: const CellStyle(bold: true)),
),
Expanded(
child: _ItemRows(count: _count, row: _row, saved: _saved),
),
const KeyHintBar(),
],
),
),
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

Click the running example, then try these commands:

  • Move with j / ↓ and k / ↑. Holding a movement key repeats because that binding sets includeRepeats: true.
  • Press gg to jump to the top.
  • Press Ctrl+S to bookmark the selected row.
  • Press Space, then c, to clear the bookmarks.

Aliases describe several gestures for one command, so they produce one entry in the hint bar. After the first key of a sequence such as gg or Space c, Fleury waits for the next key; Esc cancels. The wait times out only when the keys so far also complete a shorter binding (g alongside gg) or could be text for a focused field.

KeyHintBar shows the bindings that are active for the current focus: every KeyBindings scope from the focused widget up to the root, or up to the nearest modal scope, such as a dialog shown with present, whose keys never reach the app behind it. You do not maintain a second list of shortcut labels:

KeyHintBar()

For multi-key sequences, wrap the application in WhichKey. It reads the pending sequence and shows the possible continuations:

WhichKey(child: app)

The editor below uses both patterns. Click it, press Ctrl+B to switch from the nano keymap to the vim keymap, then press d, g, or Space and pause to see the available continuations.

Live · interactive
click & type to interact ⓘ how this demo runs

The buffer does not change when the keymap changes—only the active binding list and the surface presenting it do. See the editor walkthrough for the complete example.

Printable input is offered to focused text widgets before it becomes a command. That is why a root binding can make q quit while a focused TextInput still types the letter q. The field claims the text; only unclaimed input continues through the binding chain. One exception: a key that continues a sequence already under way completes it, so the b of Ctrl+X b runs the binding even while a field has focus.

Use bindings for commands rather than manually checking characters. You then get the expected “q quits unless the user is typing” behavior without adding focus checks to every handler.

The rest of this guide is for reusable controls, real-time input, and commands whose lifecycle is more involved than a single press. Most application shortcuts do not need these APIs.

KeyDetector is the low-level hook for behavior internal to a widget: a custom scroll region, canvas, or terminal pane. Keys travel from the focused widget up through its ancestors, so place the detector around the focusable part of the control: it sees keys while something inside it has focus.

A detector passes events onward by default. Call consume() only when the widget actually handled the key. This lets a scrollable control own ↓ while it can move and hand the same key to an ancestor when it reaches the bottom:

key_detector.dart
You write · editable
class _KeyDetectorTourState extends State<_KeyDetectorTour> {
static const _count = 3;
int _cursor = 0;
int _paneHandled = 0;
int _appHandled = 0;
String _lastKey = '—';
String _paneResult = 'waiting';
String _appResult = 'waiting';
bool _lastBubbled = false;
void _handleAtApp(String key) {
setState(() {
_lastKey = key;
_paneResult = 'PASSED · at edge';
_appResult = 'HANDLED';
_appHandled++;
_lastBubbled = true;
});
}
void _handleInPane(KeyEvent event, String key, int nextCursor) {
setState(() {
_cursor = nextCursor;
_lastKey = key;
_paneResult = 'HANDLED · moved';
_appResult = '— not reached';
_paneHandled++;
_lastBubbled = false;
});
event.consume();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return KeyBindings(
// The ancestor. It only ever hears an arrow the pane declined to
// consume — i.e. one that fell off the pane's edge.
bindings: [
KeyBinding(
KeyCode.arrowDown,
label: 'App ↓',
onTrigger: (_) => _handleAtApp('↓'),
),
KeyBinding(
KeyCode.arrowUp,
label: 'App ↑',
onTrigger: (_) => _handleAtApp('↑'),
),
],
child: KeyDetector(
onKey: (e) {
if (e.code == KeyCode.arrowDown && _cursor < _count - 1) {
_handleInPane(e, '↓', _cursor + 1);
} else if (e.code == KeyCode.arrowUp && _cursor > 0) {
_handleInPane(e, '↑', _cursor - 1);
}
// At an edge: do nothing → the arrow continues to the ancestor.
},
child: Focus(
autofocus: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 1),
child: Text('CLICK · THEN ↓ ↓ ↓', style: CellStyle(bold: true)),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 1),
child: Text(
'first 2 move; #3 bubbles',
style: CellStyle(dim: true),
),
),
const SizedBox(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text('last key $_lastKey'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text(
'PANE $_paneResult',
style: CellStyle(
bold: _paneResult != 'waiting',
foreground: _lastBubbled
? theme.colorScheme.warning
: _paneResult == 'waiting'
? null
: theme.colorScheme.primary,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text(
'APP $_appResult',
style: CellStyle(
bold: _lastBubbled,
dim: !_lastBubbled,
foreground: _lastBubbled ? theme.colorScheme.warning : null,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text(
'pane $_paneHandled · app $_appHandled',
style: const CellStyle(dim: true),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 1),
child: Text('── inner pane ──'),
),
for (var i = 0; i < _count; i++)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Text(
'${i == _cursor ? '▸' : ' '} line ${i + 1}',
style: i == _cursor
? CellStyle(
foreground: theme.colorScheme.primary,
bold: true,
)
: const CellStyle(),
),
),
],
),
),
),
);
}
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

Click the example and press ↓ three times. Its event trace makes the routing visible: the pane handles the first two presses and moves its cursor, so the app is not reached. On the third press, the pane is at its edge and passes the key; the ancestor app binding handles it. Press ↑ to watch the same path in reverse.

Two rules matter when implementing a detector:

  • Call consume() synchronously. After an await, the routing decision has already been made.
  • Detectors receive repeats. Check event.type if the widget only handles fresh presses.

Prefer KeyBindings in ordinary application code: declarations can drive shortcut hints and inspection tools; an arbitrary detector callback cannot.

A real-time loop usually wants the current keyboard state once per frame rather than one callback per keypress. Cache the keyboard handle and take one immutable snapshot per tick:

late final _keyboard = Keyboard.of(context);
void tick(Duration elapsed) {
final keys = _keyboard.snapshot;
if (keys.isHeld(KeyPosition.a)) heading -= 0.1;
if (keys.isHeld(KeyPosition.d)) heading += 0.1;
if (keys.isHeld(KeyPosition.w)) accelerate();
if (keys.wasPressed(KeyCode.space)) fire();
}

isHeld reports a level. wasPressed and wasReleased report edges, so a quick press and release between two ticks is not lost. Read snapshot in the loop that consumes it, not in build(); snapshots are intentionally non-reactive.

An edge lives for exactly one tick of the loop that reads it, whatever else the app renders in between — a clock in the status bar or an arriving stream value does not expire it. In an app with no ticker at all there is no such loop, and the snapshot advances once per rendered frame instead.

Held state depends on key-release events, which are not available on every terminal. Read the reactive capability in build and bind a press-driven fallback for each sampled control:

final canHold = Keyboard.of(context).capabilities.supportsHeldState;
KeyBindings(
bindings: [
if (!canHold)
KeyBinding(
KeyPosition.a,
aliases: [KeyCode.arrowLeft],
label: 'Turn left',
includeRepeats: true,
onTrigger: (_) => ship.nudgeLeft(),
),
],
child: game,
);

The fallback binds the same physical position the loop samples. On a press-only terminal, auto-repeat keeps a single control moving, but simultaneous held-key combinations may not be available.

Neon Asteroids combines held movement, tap edges, and press-driven fallbacks:

Live · interactive
click & type to interact ⓘ how this demo runs

See the full source and showcase walkthrough.

Use a KeyPosition when the control is spatial and should stay under the same finger across keyboard layouts. Use a KeyCode when the command is mnemonic and should follow the printed letter:

// Movement wants the physical spot used by W on QWERTY.
if (keys.isHeld(KeyPosition.w)) accelerate();
// "q for quit" wants the letter Q on every layout.
KeyBinding(.q, label: 'Quit', onTrigger: (_) => exitApp());

On AZERTY, the physical position used by QWERTY W is labeled Z. KeyHintBar shows a position binding with the user’s label when the terminal reports the layout.

Use a hold binding when the duration itself is the command, such as push-to-talk or peek-while-held. It needs reliable key releases, so offer a press-driven alternative when the terminal cannot deliver them. Here Space peeks while held, or toggles the peek where holds are unavailable:

final canHold = Keyboard.of(context).capabilities.supportsHeldState;
KeyBindings(
bindings: [
if (canHold)
KeyBinding.hold(
.space,
label: 'Peek (hold)',
onHoldStart: (_) => setPeeking(true),
onHoldEnd: (_) => setPeeking(false),
)
else
KeyBinding(
.space,
label: 'Peek (toggle)',
onTrigger: (_) => setPeeking(!peeking),
),
],
child: content,
);

Capability reads in build are reactive: if startup negotiation upgrades the session, Fleury rebuilds the control and selects the hold gesture.

A matching binding normally consumes its event. A handler can decline after it runs by calling bubble(), which hands the event to an ancestor binding:

KeyBinding(
.escape,
label: 'Close',
onTrigger: (event) {
if (!close()) event.bubble();
},
);

For rebinding controls or prompts that accept any key, await the next event:

final key = await Keyboard.nextKey(context);
if (key == null || key.code == KeyCode.escape) return;
apply(key.position ?? key.code);

The capture takes priority over ordinary bindings and is tied to its BuildContext. If that UI unmounts, the future completes with null rather than continuing to consume input.

Most shortcuts need no terminal-specific code. Fleury automatically negotiates enhanced keyboard input through the Kitty keyboard protocol and falls back to classic press events, so commands such as Ctrl+S, q, arrows, and multi-key sequences use the same declarations on both paths. It confirms which guarantees the terminal, and any multiplexer, actually preserve and exposes only those capabilities to the app. Application code should not inspect terminal names, parse protocol replies, or force a keyboard mode. Bind any chord you need; Keys handled by the host lists the few that Fleury also acts on.

Check a capability only when a feature fundamentally needs information a classic terminal may not deliver, such as key releases for holds and sampled movement. The examples above show the pattern. Terminal capabilities shows how to see what a running session negotiated, and how to override the keyboard mode when diagnosing a terminal.

The default is deliberately small: use an AppCommand when an application action needs a shortcut, and use KeyBindings for behavior that exists only as keyboard interaction. Place either at the scope that owns it and surface active shortcuts with KeyHintBar or WhichKey. Reach for KeyDetector only inside a custom keyboard-aware widget, and for Keyboard.snapshot only when a real-time loop needs held state.