@jsvision/ui / EventLoop
Interface: EventLoop
Defined in: ui/src/event/types.ts:153
The event loop: a host-agnostic engine that owns a render root, routes input and commands, manages focus, commands, and modal windows, and paints exactly one coalesced frame per dispatch tick.
You drive it entirely through dispatch() (decoded input) and the imperative methods below — no terminal is required, which is what makes it testable and embeddable. To connect it to a real terminal, either wire the on*/write* sinks to a host yourself, or use createApplication, which does that wiring for you. Create one with createEventLoop.
Properties
onCaret?
optionalonCaret?: (cell) =>void
Defined in: ui/src/event/types.ts:345
Called right after onFrame at every frame with the focused view's absolute caret cell, or null when nothing is focused or the focused view wants no visible caret. Wire it to move the terminal's hardware cursor. It reads the persisted view origin, so the caret position stays correct even on a partial repaint that skips the focused view. undefined ⇒ no caret output.
Parameters
cell
Point | null
Returns
void
onFrame?
optionalonFrame?: (buffer) =>void
Defined in: ui/src/event/types.ts:338
Called with the composed buffer after every frame (each dispatch tick, resize, and mount) so a host can paint it. Set this to host.render (or your own writer) after the host exists; createApplication wires it for you. While unset, frames are still composed but not pushed — headless tests read renderRoot.buffer() directly.
Parameters
buffer
ScreenBuffer
Returns
void
onResize?
optionalonResize?: (size) =>void
Defined in: ui/src/event/types.ts:352
Called inside resize after the reflow settles the new geometry, so a handler can re-anchor viewport-sized chrome against fresh bounds (the app uses it to re-fit maximized windows and re-anchor the open menu). The loop repaints once more afterward so the adjustment is visible. undefined ⇒ resize only reflows.
Parameters
size
Returns
void
popupHost?
optionalpopupHost?:PopupHost
Defined in: ui/src/event/types.ts:399
The host that anchored dropdown popups (menus, combo boxes, date/color pickers) mount into. createApplication wires it to the app's overlay + focus. undefined ⇒ no host, so opening a dropdown is a safe no-op; a standalone Dialog can supply its own.
readClipboardText?
optionalreadClipboardText?:ClipboardTextReader
Defined in: ui/src/event/types.ts:393
Read raw plain text for an otherwise-unhandled paste command.
Assigning a reader makes paste command-available independently of the app-local clipboard. Clearing it restores normal command enablement. Direct EventLoop.dispatch of a decoded paste event never calls this callback.
renderRoot
readonlyrenderRoot:RenderRoot
Defined in: ui/src/event/types.ts:155
The render root the loop builds and owns — read renderRoot.buffer() to inspect the composed frame.
writeClipboard?
optionalwriteClipboard?: (seq) =>void
Defined in: ui/src/event/types.ts:365
Called with a ready-to-write terminal clipboard sequence when a control copies/cuts text (the loop encodes and sanitizes it for you). This legacy sink remains available for direct terminal integrations. New hosts should prefer writeClipboardText, which receives raw text before host-specific encoding. When both sinks are set, only writeClipboardText is called.
Parameters
seq
string
Returns
void
writeClipboardText?
optionalwriteClipboardText?:ClipboardTextWriter
Defined in: ui/src/event/types.ts:385
Called with raw plain text after copy or cut commits it to the loop's canonical clipboard.
The host owns any required conversion: browser hosts call the Clipboard API, while native terminal hosts encode OSC 52 according to their capability profile. A synchronous throw or rejected promise is isolated and never rolls back the canonical clipboard value.
Example
import { createEventLoop } from '@jsvision/ui';
import { resolveCapabilities } from '@jsvision/core';
const caps = resolveCapabilities({ env: {}, platform: 'linux' }).profile;
const loop = createEventLoop({ width: 40, height: 10 }, { caps });
const writeHostClipboard = (text: string): void => {
// Forward raw text to the host API. Do not encode terminal control sequences here.
void text;
};
loop.writeClipboardText = writeHostClipboard;Methods
commandsVersion()
commandsVersion():
number
Defined in: ui/src/event/types.ts:272
A version counter that changes whenever any command's enablement changes via enableCommand. Read it inside a view's bind to repaint on greying — the shell's status line and menu bar do exactly this so a disabled command greys live with no manual invalidate.
Returns
number
dispatch()
dispatch(
event):void
Defined in: ui/src/event/types.ts:197
Feed one decoded input event (key/mouse/wheel/paste) into the loop; it routes and repaints in one tick.
Parameters
event
Returns
void
dispose()
dispose():
void
Defined in: ui/src/event/types.ts:195
Tear the loop down for a host that detaches a still-live app: stop the out-of-tick painter (as EventLoop.stop), unmount the view tree, clear focus and pointer capture, and release all application command handlers. Every view's onCleanup runs, so timers, subscriptions, and closures from the detached app cannot leak into a later one. Idempotent. run() does not need this — the process exits — but a long-lived host that mounts and unmounts many apps (the browser mountApp) calls it on teardown so nothing survives between apps.
Returns
void
Example
import { createEventLoop } from '@jsvision/ui';
import { resolveCapabilities } from '@jsvision/core';
const caps = resolveCapabilities({ env: {}, platform: 'linux' }).profile;
const loop = createEventLoop({ width: 40, height: 10 }, { caps });
// When a host detaches a still-live app (e.g. the browser mountApp teardown):
loop.dispose(); // detach the tree and release routed state plus application handlersemitCommand()
emitCommand(
command,arg?):void
Defined in: ui/src/event/types.ts:262
Emit a command, routing it to any handler. Dropped silently if the command is disabled.
Parameters
command
string
arg?
unknown
Returns
void
enableCommand()
enableCommand(
command,on):void
Defined in: ui/src/event/types.ts:264
Enable or disable a command. While disabled, emitCommand for it is dropped.
Parameters
command
string
on
boolean
Returns
void
endModal()
endModal<
R>(result):void
Defined in: ui/src/event/types.ts:280
Close the top-most modal, restore the previously focused view, and resolve its execView promise with result.
Type Parameters
R
R
Parameters
result
R
Returns
void
execView()
execView<
R>(view):Promise<R|undefined>
Defined in: ui/src/event/types.ts:278
Open view as a modal: input is captured to its subtree until it closes. The promise resolves with the value passed to endModal, or undefined if the event loop is permanently disposed while the modal is active. await it to run a dialog and read its result.
Type Parameters
R
R
Parameters
view
Returns
Promise<R | undefined>
focusInto()
focusInto(
view):void
Defined in: ui/src/event/types.ts:236
Focus into a container: restore its last-focused child, or focus its first focusable descendant.
Parameters
view
Returns
void
focusNext()
focusNext():
void
Defined in: ui/src/event/types.ts:227
Move focus to the next focusable view in document (tree) order, bounded by the active scope (the open modal's subtree while a modal is up, else the mounted root). Focus descends through nested groups and, at a group's end, crosses into the parent's next focusable sibling, wrapping at the scope — so a dialog built from nested col/row containers is fully traversable and Tab never escapes an open modal. Continuous Tab is pure tree order (a wrap re-enters at the tree start, not the last-visited child); container restore memory applies only to a non-Tab entry (a click, focusView, a window switch, opening/closing a dialog).
Returns
void
Example
// A dialog composed with the layout DSL — Tab walks its nested col/row groups in tree order.
import { createEventLoop, col, row, Input, Button, signal } from '@jsvision/ui';
import { resolveCapabilities } from '@jsvision/core';
const caps = resolveCapabilities({ env: {}, platform: 'linux' }).profile;
const loop = createEventLoop({ width: 40, height: 8 }, { caps });
const name = new Input({ value: signal('') });
const ok = new Button('OK', { onClick: () => loop.emitCommand('ok') });
const cancel = new Button('Cancel', { onClick: () => loop.emitCommand('cancel') });
loop.mount(col(row(name), row(ok, cancel)));
loop.focusNext(); // name
loop.focusNext(); // ok — Tab exits the first row into the button row
loop.focusNext(); // cancel
loop.focusNext(); // wraps back to name
loop.focusPrev(); // cancel — Shift-Tab is the exact inverse of TabfocusPrev()
focusPrev():
void
Defined in: ui/src/event/types.ts:232
Move focus to the previous focusable view — the exact inverse of EventLoop.focusNext (reverse descent lands on a container's last leaf), bounded by and wrapping at the same scope.
Returns
void
focusView()
focusView(
view):void
Defined in: ui/src/event/types.ts:234
Focus exactly view. A no-op if view is not currently focusable.
Parameters
view
Returns
void
getFocused()
getFocused():
View|null
Defined in: ui/src/event/types.ts:238
The currently focused view, or null if nothing is focused.
Returns
View | null
isCommandEnabled()
isCommandEnabled(
command):boolean
Defined in: ui/src/event/types.ts:266
Whether a command is currently enabled. Commands are enabled by default until disabled.
Parameters
command
string
Returns
boolean
mount()
mount(
root):void
Defined in: ui/src/event/types.ts:157
Mount a view tree as the loop's root and paint the first frame. Call once before dispatching.
Parameters
root
Returns
void
onCommand()
onCommand(
command,handler): () =>void
Defined in: ui/src/event/types.ts:319
Register a handler for a named command; returns a function that unregisters it. Every handler registered for a command runs (in registration order) when that command is emitted, and a handled command is consumed there — a downstream view matching the same command does not also receive it.
Handlers run in the pre-process phase, so an onCommand handler fires before a focused view could handle the same command. One exception: while a modal (e.g. a Dialog) owns the dispatch scope, commands are confined to the modal subtree, so a general onCommand handler does not fire until the modal closes.
Parameters
command
string
The command name to handle.
handler
() => void
Called when the command is emitted.
Returns
A function that unregisters this handler (idempotent).
() => void
refreshCaret()
refreshCaret():
void
Defined in: ui/src/event/types.ts:358
Re-send the current caret cell to onCaret out of band. run() calls it once after the first frame (which is painted directly, not through a tick) to position the initial cursor. A no-op when onCaret is unset.
Returns
void
releaseCapture()
releaseCapture():
void
Defined in: ui/src/event/types.ts:331
Release the pointer capture. A no-op if nothing is captured.
Returns
void
resize()
resize(
size):void
Defined in: ui/src/event/types.ts:199
Resize the viewport: reflow the tree and paint exactly one frame.
Parameters
size
Returns
void
setAcceleratorMode()
setAcceleratorMode(
on):void
Defined in: ui/src/event/types.ts:287
Turn accelerator mode on or off. When on, every reachable ~X~ hotkey is underlined and a bare letter fires the matching accelerator like Alt+letter. The reveal key (default F12) toggles this for you; call it directly to arm/dismiss the mode programmatically. A no-op when the feature is disabled (revealKey: null).
Parameters
on
boolean
Returns
void
setCapture()
setCapture(
view):void
Defined in: ui/src/event/types.ts:329
Capture the pointer to view: while captured, all mouse/wheel events go to view (with view-local ev.local coordinates), bypassing hit-testing and focus-on-click — this is how a drag or resize keeps tracking even after the cursor leaves the affordance. Setting a new target replaces any current one; capture is released automatically when a modal opens/closes or the target unmounts.
Parameters
view
Returns
void
setTheme()
setTheme(
theme):void
Defined in: ui/src/event/types.ts:304
Replace the active theme and repaint every view with the new colors in one coalesced frame. Safe to call from anywhere — a command handler, an async callback, or a bare imperative call between input ticks — because the swap runs inside the loop's own tick and reuses its trailing flush + onFrame, so the repainted frame reaches the host even outside a dispatch.
Parameters
theme
Theme
The theme to switch to.
Returns
void
Example
import { createEventLoop } from '@jsvision/ui';
import { resolveCapabilities, nordTheme } from '@jsvision/core';
const caps = resolveCapabilities({ env: {}, platform: 'linux' }).profile;
const loop = createEventLoop({ width: 40, height: 10 }, { caps });
loop.setTheme(nordTheme); // repaints immediately, from any call contextstop()
stop():
void
Defined in: ui/src/event/types.ts:176
Stop the loop's out-of-tick painter. After stop(), a mutation that would normally schedule a deferred repaint — a timer, a promise continuation, a direct call between ticks — is ignored, and any already-queued deferred paint is skipped, so a late callback during or after teardown never writes to a stopped host. Idempotent. In-tick painting (a dispatch/resize/command) is unaffected: a running loop never calls this, and run() calls it once during shutdown. It does not dispose the mounted view tree.
Returns
void
Example
import { createEventLoop } from '@jsvision/ui';
import { resolveCapabilities } from '@jsvision/core';
const caps = resolveCapabilities({ env: {}, platform: 'linux' }).profile;
const loop = createEventLoop({ width: 40, height: 10 }, { caps });
// Inside run()'s shutdown, after the terminal is restored:
loop.stop(); // gate the deferred painter before detaching the frame/caret sinksviewAt()
viewAt(
point):View|null
Defined in: ui/src/event/types.ts:260
Return the topmost enabled, visible view at a zero-based terminal cell without dispatching an event. The query uses the same active modal scope, clipping, and z-order traversal as pointer routing, but has no focus, selection, callback, or paint side effects.
Parameters
point
The zero-based terminal cell to inspect.
Returns
View | null
The frontmost view at the cell, or null outside the active routing scope.
Example
import { at, Button, createEventLoop, Group } from '@jsvision/ui';
import { resolveCapabilities } from '@jsvision/core';
const caps = resolveCapabilities({ env: {}, platform: 'linux' }).profile;
const saveButton = new Button('Save');
const root = new Group();
root.add(at(saveButton, 2, 1, 10, 2));
const loop = createEventLoop({ width: 20, height: 6 }, { caps });
loop.mount(root);
const target = loop.viewAt({ x: 4, y: 2 });
if (target === saveButton) console.log('Save is reachable at that cell');