Skip to content

@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?

optional onCaret?: (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?

optional onFrame?: (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?

optional onResize?: (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

Size2D

Returns

void


popupHost?

optional popupHost?: 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?

optional readClipboardText?: 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

readonly renderRoot: 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?

optional writeClipboard?: (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?

optional writeClipboardText?: 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

ts
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

AppEvent

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

ts
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 handlers

emitCommand()

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

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

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

ts
// 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 Tab

focusPrev()

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

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

View

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

Size2D

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

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

ts
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 context

stop()

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

ts
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 sinks

viewAt()

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

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

ts
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');