- UI is a node tree
- Signals hold mutable state
ff(...)reacts to signal reads/writes- JS snapshots current node state each reactive frame
- same-shape trees keep Rust tree state alive and send deltas only
- shape changes clear and rebuild Rust tree state once
- Rust still owns layout, paint, terminal buffers, and incremental flush
import { $, ff } from "../index.ts";
const value = $(0);
ff(() => {
// reads value(); reruns when value changes
console.log(value());
});
value(value() + 1);Available exports include: $, dd, ff, af, wait, whenSettled.
run(root: Node, options?: { debug?: boolean }): { quit: () => void }run(root)starts stdin handling, resize handling, render looprun(root, { debug: true })enables metrics output (dump/metrics.txt)- returned
quit()tears everything down and exits process
- JS builds a sent-tree snapshot from the current nodes
- If previous and current tree shapes match, JS emits style deltas plus text ops (
SetText,DeleteTextRange) - If shape differs, JS clears Rust tree state and re-inserts the full tree
- Rust applies ops, runs layout + paint, then updates the terminal buffer
- JS reads frame rectangles back into each node and rebuilds hit-testing lookup
- Rust flushes only changed terminal cells
Debug phase names in dump/metrics.txt match that pipeline:
serialize: JS tree snapshot + op generationtextSync: text op generation and byte countsrust: Rust layout + paintsync: frame rectangles copied back into JS nodesflush: terminal I/O
onKey(key: string, callback: () => void): void- exact string match on raw terminal key data
onKey("q", ...)handlesq- default hard quit already bound to
Ctrl+Q("\\x11")
- Focus tracked globally (single focused node)
- Mouse press on interactive node focuses it
- Clicking empty space blurs current focus
- Keyboard input routed to focused node first
- If focused node consumes key event, global
onKeyhandler does not run
- wheel events use SGR mouse input and dispatch to the deepest
ScrollViewor box withonWheel - dispatch starts at the deepest scrollable node under the cursor
- if the current node cannot consume more scroll, the event bubbles to parent containers
- current payload:
{ x, y, deltaX, deltaY, shift, alt, ctrl, raw }
For focused Input node:
- printable ASCII appends to text, then calls
onChange(nextText) - backspace (
\x7f) removes one char, then callsonChange(nextText) - enter/newline triggers
onSubmit(currentText)
For focused Button node:
- Enter or Space triggers
onClick() - mouse press + release on same button triggers
onClick() - non-activation keys call optional
onKeyDown(rawKey)
Use one quit path; clear timers/listeners before calling app.quit().
const app = run(root);
let done = false;
function quit() {
if (done) return;
done = true;
clearInterval(timer);
process.stdout.off("resize", onResize);
app.quit();
}
onKey("q", quit);- Keep long-lived nodes and mutate them with
setText,setStyle, or signals - Rebuilding whole subtrees every tick changes tree shape and forces Rust tree rebuilds
VirtualListkeeps a fixed slot pool and rebinds stable row nodes as scroll moves- normal scroll should not change subtree shape; viewport-size changes may resize the slot pool
- fixed-row virtualization uses top spacer + visible slots + bottom spacer inside
ScrollView - wrapped content should be flattened into visual rows before virtualization
createVirtualListController(...)remains legacy compatibility API for older examples