Interact with Components
Components respond to input — the keyboard, the mouse, and the terminal itself.
Overview
Every key, click, scroll and paste reaches you as an event, and you attach a handler for the ones you care about through a component's bindings() or a node's mouse setters. This guide covers those handlers — keyboard, mouse, and the system events the terminal raises.
Keyboard interaction
Keyboard input is routed to the focused component, so responding to keys is two steps: take focus, then declare what the keys do.
Receive keys
A component that wants keys sets $focusable and declares its keys in bindings(). Each Binding pairs a Keypress with a handler:
use Phui\Attributes\State;
use Phui\Bindings\Binding;
use Phui\Events\Keyboard\Keypress;
protected bool $focusable = true;
#[State]
public int $count = 0;
public function bindings(): array
{
return [
Binding::keypress(Keypress::ARROW_UP, fn () => $this->count++),
Binding::keypress(Keypress::ARROW_DOWN, fn () => $this->count--),
];
}Each handler writes to the #[State] property $count, and writing to state rebuilds the component, so the key press shows up on screen.
A handler consumes its key by default; return false from it to let the event keep bubbling to the components above.
Focus only ever lands on a component with $focusable set. When several are focusable Phui seeds the first one in the tree. When a layer has none, focus settles on its base — the component you handed Screen::mount(), or a modal's root — so that component's bindings still fire and the app stays responsive. The base is a fallback of last resort, though: it stays unfocusable, Tab skips it, and the moment a real focusable mounts, focus moves there and never settles back. Don't lean on it — if a component's bindings must keep working once other focusables appear, give it $focusable and take focus explicitly.
Let the parent decide
A component that sets $focusable is normally a tab stop, but not every instance should be — a checkbox that reports state the parent owns, or one of several lists where only one is meant to answer the arrow keys. focusable(false) on the mount node settles it from outside:
Checkbox::mount()
->props(label: 'Ready', checked: $this->ready)
->focusable(false);Tab now passes over the checkbox and it never receives a key. Focus governs the keyboard only: mouse input is positional, so clicks and the wheel still reach it. The parent's answer always wins, so this works on any component whatever it decides for itself.
Take focus when it mounts
$focusable makes a component able to hold focus, but something still has to hand it focus. A component can ask for focus the moment it appears, from a lifecycle hook — a method Phui calls at a set point in the component's life. The one to use is onMount(), which runs once, just after the component is first added to the screen:
public function onMount(): void
{
$this->focus->request();
}request() grants focus only if nothing else already holds it, so a freshly mounted component becomes keyboard-ready without stealing focus from one the user is in the middle of using. Reach for take() instead when you do want to grab it outright.
Show which component is focused
Only the focused component receives keys, so it's usually a good idea to show the user which one that is. Phui adds no focus styling of its own — read held() in build() and draw the state yourself:
Container::make($children)
->border()
->borderColour(line: $this->focus->held() ? '#a3be8c' : '#45475a');Scroll programmatically
A key handler often needs to move the rendered element — scroll a selection into view as the arrow keys change it. But build() returns fresh nodes each rebuild, so you can't hold onto one to control it. A ref is the handle to the rendered element instead. Declare it with #[Ref] and attach it to a node with ref():
use Phui\Attributes\Ref;
use Phui\Refs\ElementRef;
#[Ref]
public ElementRef $list;Container::make($children)
->scrollY()
->ref($this->list);The ref then drives the live element through its scroll API — scroll to an offset, by a delta, or bring a child index into view:
$this->list->scroll()->intoView($index);By default intoView() scrolls just far enough to reveal the row. Pass an AlignmentType to park it somewhere fixed — Center keeps the selection mid-viewport as you move through a long list:
$this->list->scroll()->intoView($index, AlignmentType::Center);The wheel already scrolls a scrollY container on its own — the ref is for driving the scroll yourself.
A ref only binds once its element has rendered, so isBound() is false before the first frame. A handler always runs after that, so a ref you drive from a key or mouse handler is safe to use — guard with isBound() only if you reach for it earlier, such as in onMount().
Show the available keys
To render a keybind bar you don't have to track which bindings are live — Phui resolves them for you. $this->keymaps->active() returns the Keymap a keypress would resolve against right now: the focused component, its ancestors, then the layer's globals, with shadowed keys already removed.
Read it in build() and render its entries — each Binding carries a label() for its keys and a description:
use Phui\Bindings\Binding;
$bar = Node::each(
$this->keymaps->active()->entries,
fn (Binding $b) => Text::make($b->label().' '.$b->description),
fn (Binding $b, int $i) => 'bind-'.$i,
);Reading the keymap during build() subscribes the component, so the bar rebuilds itself whenever focus moves or the available keys change.
Override a child's bindings
A parent can change the keys of a component it mounts, without the child knowing. Pass replacements to bindings() on the mounted child; they merge with the child's own by key — a binding on the same key replaces the child's default, a new key is added, and Binding::none() drops one of the child's defaults:
Editor::mount()->bindings([
Binding::keypress(Keypress::ENTER, fn () => $this->save()),
Binding::none(Keypress::ESCAPE),
]);Here the parent repoints Enter at its own handler and removes the editor's Escape binding, leaving the rest untouched. A binding passed this way is authored by the parent, so its handler runs on the parent and changes the parent's state.
Handle a key app-wide
A key event starts at the focused component and bubbles up through its ancestors until a binding consumes it. A binding marked global is the exception — it fires wherever focus sits, once the key bubbles out unhandled, so it's how you declare an app-wide hotkey that works from anywhere:
Binding::keypress(
Keypress::q,
fn () => $this->quit(),
description: 'Quit',
global: true,
);Focus a component with a hotkey
To jump focus straight to a component from anywhere, declare a Binding::focus() hotkey on it. It takes no handler — pressing the key focuses the component that owns the binding, and it's global by default:
public function bindings(): array
{
return [
Binding::focus(Keypress::F2, description: 'Focus the log'),
];
}Mouse interaction
Mouse events attach to the node under the cursor, not the focused component, so no focus is needed to receive them.
Handle a press
onMouseDown() runs a handler when the mouse button presses down on its node, and onMouseUp() when it releases. Each fires once per press:
Text::make('Submit')->onMouseDown(fn () => $this->submit());The handler fires for every button, so to respond to the left button only, check the MouseClickEvent it receives — $event->button is one of LEFT, RIGHT or MIDDLE:
use Phui\Events\Mouse\MouseClickEvent;
Text::make('Submit')->onMouseDown(function (MouseClickEvent $event) {
if ($event->button === MouseClickEvent::LEFT) {
$this->submit();
}
});Track movement and drags
onMouseMove() runs as the pointer moves over a node. How much movement the terminal reports is set by the tracking mode on mouse(), which defaults to MouseTracking::DRAG — movement only while a button is held. So under the default, an onMouseMove handler is a drag handler: it fires as the user drags across the node, and onMouseDown and onMouseUp mark where the drag began and ended.
Each MouseMoveEvent carries the previous position; getDelta() returns the movement since the last one:
use Phui\Events\Mouse\MouseMoveEvent;
Container::make($children)->onMouseMove(function (MouseMoveEvent $event) {
[$dx, $dy] = $event->getDelta();
$this->pan($dx, $dy);
});Switch the mode to MouseTracking::MOTION to track hover (movement with no button down), or MouseTracking::CLICK to report presses only and no movement at all. MOTION reports every pointer move, which is a steady stream of events to parse and dispatch — enable it only when you need hover, and prefer the default DRAG otherwise.
Handle the wheel
onScroll() runs on the scroll wheel over a node, receiving a MouseScrollEvent. A scrollY container already scrolls itself — reach for onScroll() when you want the wheel to do something else.
Handle input at the component level
To catch a mouse event anywhere within a component rather than on one specific node, declare it in bindings() with Binding::mouseDown() — and its Binding::mouseUp() and Binding::scroll() siblings:
public function bindings(): array
{
return [
Binding::mouseDown(fn (MouseClickEvent $event) => $this->select()),
];
}Unlike keyboard bindings these aren't routed by focus: they fire for an event over any of the component's own elements and bubble up to ancestor components, so a parent can catch a click a child left unhandled. Return false from the handler to let it keep bubbling.
Clicking sets focus
Pressing inside a component moves focus to the nearest focusable component the cursor lands in, so a mouse user focuses a component simply by clicking it — and its keyboard bindings take over from there, no onMount() request needed.
System events
Beyond the user, the terminal itself raises system events — a resize, the window gaining or losing focus, a colour-depth report. Phui consumes these for you: on a resize it relayouts and re-runs any size-dependent build, so you rarely handle them directly.
When you need the current size, read it in build() from $this->system:
$size = $this->system->screenSize();Window-focus reporting is opt-in through focusTracking() on the screen, and the full list of system events is in the events reference.