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
- App handlers
Callbacks registered with
TuiApp.onKey - Shortcuts and ActionsA key combination becomes a semantic Intent
- Text insertionPrintable input reaches a focused TextInput or TextArea
- Focus handlers
The focused node runs first, then the event bubbles through ancestors
- Focus traversalUnconsumed Tab or Shift+Tab moves through the active scope
- 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.