Skip to Content
Handle input and focus

Handle input and focus

Noir gives each input event one ordered route, and the first handler that consumes the event wins. Most input problems come from putting a handler earlier in that route than the control that was meant to receive it.

Each recipe below solves one task and names the failure it prevents.

Give editable text an owner

Use a TextEditingController when code must read or change the text or the selection. The state that creates it disposes it. Pass either controller or value to a TextInput or TextArea, never both.

TextInput( controller: _controller, autofocus: true, placeholder: 'package name', onSubmit: _apply, )

TextInput.onSubmit runs on Enter, and TextInput.maxLength defaults to 100; raise it when a field must accept a longer line. TextArea.maxLength is unbounded unless you set it. By default TextArea uses Enter for a newline and reports Ctrl+Enter when the terminal distinguishes it. With submitOnEnter: true, Enter submits and a distinguishable Ctrl+J inserts a newline. softWrap: true wraps by grapheme and terminal-cell width; pair it with maxHeight to grow from height to a bounded maximum before scrolling.

Focus-aware controls create an internal node when you do not supply one. Pass your own FocusNode when a parent must request focus, observe it, or coordinate several fields. See State, identity, and ownership for controller and node ownership together, and TextInput for the full field reference.

If typing does nothing: an ancestor Focus with autofocus: true took primary focus from the field. Give the field the autofocus instead.

Map a key combination to a command

Shortcuts turns a key combination into an Intent. Actions decides what that intent does at the nearest matching point in the focused subtree:

final class SaveIntent extends Intent { const SaveIntent(); } class SaveShortcut extends StatelessWidget { const SaveShortcut({required this.onSave, required this.child, super.key}); final VoidCallback onSave; final Widget child; @override Widget build(BuildContext context) { return Shortcuts( shortcuts: const { SingleActivator(LogicalKeyboardKey.keyS, control: true): SaveIntent(), }, child: Actions( actions: { SaveIntent: CallbackAction<SaveIntent>((intent, context) { onSave(); return KeyEventResult.handled; }), }, child: child, ), ); } }

This keeps the key combination separate from the command. A Button, a menu item, and a shortcut can all call the same onSave callback.

If a field stops receiving a character: an app-level or ancestor binding consumed it first. Do not bind a bare q when the reader might be typing. Prefer a modified combination such as Ctrl+Q, or scope the command to a subtree with Shortcuts and Actions.

Own a raw key inside one region

Use Focus or FocusScope for keys a focused region owns itself. Check the press state and return ignored for everything else:

class DismissOnEscape extends StatelessWidget { const DismissOnEscape({ required this.onDismiss, required this.child, super.key, }); final VoidCallback onDismiss; final Widget child; @override Widget build(BuildContext context) { return Focus( onKeyEvent: (_, event) { if (!event.isPress || event.logicalKey != LogicalKeyboardKey.escape) { return KeyEventResult.ignored; } onDismiss(); return KeyEventResult.handled; }, child: child, ); } }

Tab traversal already works. Intercept Tab only when the normal traversal policy is not the behavior you want.

Quit from an application-level key

TuiApp.onKey runs before the focused control. It is a void handler: call event.consume() to stop the rest of the pipeline, or return without consuming to let later stages run.

final app = runTuiApp(const EditorApp(), enableMouse: true); app.onKey((event) { if (!event.isPress || event.logicalKey != LogicalKeyboardKey.keyQ || !event.isControlPressed) { return; } event.consume(); app.dispose(); });

Once the tree is mounted, prefer TuiApp.exit(context) from a widget so Noir restores the terminal resources it owns.

Use local pointer coordinates

PointerListener receives an event after render-tree hit testing chooses its subtree. event.localPosition is already measured from that widget’s top-left cell:

PointerListener( onPointerDown: (event) { if (event.button == MouseButton.left) { onSelect(event.localPosition); } }, child: child, )

Do not reconstruct the widget’s absolute origin. The local coordinate stays correct when the widget moves inside a scrollable region or an overlay.

If no pointer event arrives: mouse reporting is opt-in. Enable it once with runTuiApp(app, enableMouse: true). Noir includes movement reporting by default; app.enableMouse(enableMovement: false) opts into click and drag reporting only. For versions that default to click and drag reporting, request hover with app.enableMouse(enableMovement: true). A terminal that does not report mouse input cannot be made to; see Platform support.

Show a hand over an action

Mouse pointer shapes are available in Noir 0.0.1. See Installation for package setup. The pointer is the desktop mouse shape, separate from the text caret.

With mouse reporting enabled, buttons, checkboxes, switches, sliders, valid Select and selectable ListView rows, and TabSelect tab cells request a hand. Text fields request an I-beam. Blank list rows and disabled controls keep the basic pointer.

For a custom action, add mouseCursor to its existing listener:

PointerListener( mouseCursor: MouseCursor.pointer, onPointerDown: handlePointerDown, child: child, )

MouseCursor.basic requests an arrow; MouseCursor.text requests an I-beam. The deepest annotated hit target wins. Leaving mouseCursor null defers to an ancestor; setting basic explicitly overrides it. The annotation can also be used without callbacks around a child that already handles input.

Noir resolves the shape again after layout, so removing or disabling a control under a stationary mouse updates the pointer. The terminal must support the request: see mouse pointer support.

Keep a scrollable region’s keys to itself

ScrollBox wraps one overflowing child. ListView builds only visible rows, and DataTable uses a ListView body under its aligned header. Give a scrollable region focus when its arrow, PageUp, PageDown, Home, and End keys should not move another region. Set canRequestFocus: false for a read-only embedded viewport that must stay outside an enclosing modal or form traversal.

For chronological output, construct its controller with followTail: true. It follows layout extent changes while at the end, detaches when scrolled above the end, and reattaches when returned to the maximum extent.

Use stable ValueKeys to keep row identity when items move or disappear.

The route an event takes

  1. App handlers

    Callbacks registered with TuiApp.onKey

  2. Shortcuts and ActionsA key combination becomes a semantic Intent
  3. Text insertionPrintable input reaches a focused TextInput or TextArea
  4. Focus handlers

    The focused node runs first, then the event bubbles through ancestors

  5. Focus traversalUnconsumed Tab or Shift+Tab moves through the active scope
  6. Terminal fallback

    Unconsumed Ctrl+C disposes the app and exits with interrupt status

The Widget catalog lists the built-in interactive controls, and the repository’s focus form example  is a complete two-field flow.

Last updated on