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 sequence | KeyBinding inside KeyBindings |
| Inspect raw keys inside a custom control and decide whether to consume them | KeyDetector |
| Read what is currently held during a game or simulation tick | Keyboard.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.
Keys handled by the host
Section titled “Keys handled by the host”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:
| Key | What Fleury does | To use it in your app |
|---|---|---|
Ctrl+C | In 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+Z | In 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, F12 | Open 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, p | Go 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.
Scoped keyboard bindings
Section titled “Scoped keyboard bindings”Declare a binding
Section titled “Declare a binding”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.
Scope bindings
Section titled “Scope bindings”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.
Aliases, sequences, and repeat
Section titled “Aliases, sequences, and repeat”A command can accept aliases, use more than one key, or opt into auto-repeat. This example combines the common forms:
Click the running example, then try these commands:
- Move with
j/↓andk/↑. Holding a movement key repeats because that binding setsincludeRepeats: true. - Press
ggto jump to the top. - Press
Ctrl+Sto bookmark the selected row. - Press
Space, thenc, 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.
Shortcut discovery
Section titled “Shortcut discovery”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.
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.
Text input precedence
Section titled “Text input precedence”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.
Advanced patterns
Section titled “Advanced patterns”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.
Custom widget handling
Section titled “Custom widget handling”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:
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 anawait, the routing decision has already been made. - Detectors receive repeats. Check
event.typeif 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.
Real-time held keys
Section titled “Real-time held keys”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:
See the full source and showcase walkthrough.
Keyboard layouts
Section titled “Keyboard layouts”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.
Holds, bubbling, and capture
Section titled “Holds, bubbling, and capture”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.
Terminal support
Section titled “Terminal support”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.
Next steps
Section titled “Next steps”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.
- Focus management explains focus targets, Tab order, directional traversal, dialog focus traps, and focus restoration.
- Commands shows one action shared by buttons, shortcuts, palettes, and semantics.
- Terminal capabilities explains Fleury’s general detection, fallback, and diagnostics model.
- Testing shows how to drive key events through a widget test.
- API references:
KeyBindings,KeyDetector,KeyHintBar, andWhichKey.