@qxuken/kui (0.1.0-alpha.16)
Installation
About this package
kui
kui for Node: JSX views (a custom jsx-runtime, no React) lowered into the kui IR in one call per frame, Elm-style messages as data. The library itself is documented in the kui repository.
The package is published to that Forgejo's npm registry under the @qxuken
scope; route the scope there once (Forgejo does not proxy npmjs, so do not
override the default registry) and install:
npm config set @qxuken:registry https://drydock9.qxuken.dev/api/packages/qxuken/npm/
npm install @qxuken/kui@alpha
Or start from the template: npm create @qxuken/kui-node my-app.
Every release so far is a prerelease, so latest and alpha point at the same
newest alpha and a bare npm install @qxuken/kui gets it; npm view @qxuken/kui@alpha version is the query that still answers if latest is ever
absent. Ranges do not pin a prerelease — ^0.1.0-alpha.8 and ~0.1.0-alpha.8
both admit every later alpha of the same 0.1.0 — so an app that wants the
version it tested writes that version exactly and commits its lockfile.
The tarball bundles the native addon for linux-x64, linux-arm64, darwin-arm64,
darwin-x64 and win32-x64 under prebuilds/; native.cjs picks the one matching
process.platform-process.arch. On any other platform build it from the
repo (cargo build -p kui-node --release) and set KUI_NODE_LIB to the
resulting library.
// tsconfig: "jsx": "react-jsx", "jsxImportSource": "@qxuken/kui"
import { createApp, runWindowed } from '@qxuken/kui';
import type { CoreMsg, Ctx, KuiWindow, UiEvent } from '@qxuken/kui';
type Model = { count: number };
// This app's own messages plus the ones the core sends by itself.
type Msg = { kind: 'add'; by: number } | { kind: 'reset' } | CoreMsg;
// The fourth argument is the surface the loop drives — the headless `Ctx`
// under `createApp`, the `KuiWindow` under `runWindowed` — for `editText`,
// `focus`, `play` and the rest. Both drivers pass it.
function update(model: Model, msg: Msg, ev: UiEvent<Msg>, ui: Ctx | KuiWindow): Model | undefined {
switch (msg.kind) { // one union, no casts
case 'add': return { count: model.count + msg.by };
case 'reset': return { count: 0 };
}
}
// `init` is the first model, or a function the surface is handed to — after
// `setup`, so the fonts and images it registered are there to measure
// against. Under a window that argument is the window, so a first model can
// be built at the size it really opened at instead of at a constant
// corrected on the first `resize`:
// runWindowed({ init: (win) => ({ count: 0, size: win.size() }), ... })
const init = (): Model => ({ count: 0 });
// `view(model, window, surface)`: the window's name (`'main'` unless
// `windows` declared others) and the surface, for the measurement a tree
// needs while it is being built. A single-window app that measures nothing
// takes `model` alone.
const view = (model: Model, _window: string, ui: Ctx | KuiWindow) => (
<box pad={24} gap={16}>
<button onClick={{ kind: 'add', by: 1 }}>+1</button>
{/* Wide enough for the widest count it will ever show, so the button
beside it does not shift as the number grows. */}
<box width={ui.measureText('count = 0000', { size: 20 }).width}>
<text size={20}>{`count = ${model.count}`}</text>
</box>
</box>
);
const app = createApp({ init, update, view }, { width: 640, height: 480 }); // headless
const final = await runWindowed({ init, update, view }, { title: 'counter' }); // a window
Both drivers run one loop over one surface, so what differs between them is
only what really differs: runWindowed pumps the OS and resolves with the
final model, createApp is synchronous. Everything else — the clock, the
diagnostics gate, the test affordances — is the same code either way. To
drive the window one of these opens — a smoke test reading win.quads(),
say — press with access(key, 'click'): against a real window that is not
only the screen reader's path but the only synthetic input it takes, since
click, type and key are refused on a surface the OS drives.
That loop takes a clock — tick: { every: 250, msg: (now) => ({ kind: 'tick', now }) } — and re-renders on a tick only when update returns a new model,
so a countdown is free between displayed seconds. That contract cuts both
ways; see A clock below. A window fires the ticks off its own timer; a
test moves the hands itself with app.advance(ms), so an app with a clock
still runs headless.
An effect the app defines and kui knows nothing about — a file write, a
request, the clipboard — is data on the same terms as everything else the
loop handles (ADR 0013).
update returns it beside the model:
case 'save': return withEffects({ ...model, dirty: false }, { kind: 'write', path: model.path, text: model.text });
and the app says once, in the options, what performing one means —
effects: (effect, dispatch, surface) => { … } — which the loop calls
after the frame, so an effect that dispatches its result
(dispatch({ kind: 'saved' })) lands in the next turn, and one that reads
the surface sees the frame its cause produced. update stays pure, the
same handler serves createApp and runWindowed, and headless
app.effects() drains what update returned whether or not a handler
ran, so a test asserts the effect the way it asserts an audio command.
withEffects(undefined, …) keeps the model, and a function init may
return one too. kui's own effects stay where they are: a sound is
surface.play or an <audio> node, a window is windows.
Testing
createApp runs the same app headless, and everything a frame produces
comes back as data, so a test drives the app the way a user would and
asserts on what the core produced:
- Input:
app.click(x, y),app.type(s),app.press(code)settle the loop for you;app.ctx.cursor/mouse/scroll/modifiersare the raw events (a drag is cursor, mouse down, cursor, mouse up).pressis the key one. A real key press is two channels and a window drives both — the raw press anonKeysink hears, and then what the core is asked to do with that key (Escape dismisses a modal, Tab walks the ring, an arrow nudges a focused slider, Space presses a focused control).press('escape')does both;app.key(name)andapp.ctx.keyDown(code)are its two halves, for a test that means to drive one channel and not the other.app.release(code)is the key coming up. The loop sets the frame clock before every frame, so atransitioneases from the frame that changes it andapp.advance(ms)is what moves it; a test that wants only the end state advances past the duration. (A bareCtxwhosesetTimeis never called snaps.) - Time:
app.advance(ms)is the window's timer by hand. It fires everytickthat falls inside the span, moves the frame clock behindtransitionwith it, and re-renders — so a ticking app (a countdown, a clock, a game loop) is driven from a test the same way a user's window drives it, and a transition can be watched a step at a time (app.ctx.animating()says when it has settled).startTimein the options pins where that clock starts, so assertions ontick.msg(now)are exact. - Effects:
app.effects()drains whatupdatereturned withwithEffectssince the last drain — the file write or request the app asked for, as a value — whether or not aneffectshandler was registered; with one, the handler already ran after the frame and itsdispatchhas already gone throughupdate. - Either surface:
settle,access,accessTree,dispatch,renderandstepare the loop's, not the headless driver's, so they work against a real window too —runWindowed'ssetup(win, app)hands you the same object. Synthetic input (click/type/key) needs a surface that takes it: aCtxdoes, and a window, which the OS drives, says so rather than pretending. - The frame:
decodeQuads(app.ctx.quads())is the display list (x,y,w,h,color,radii,kind), so "the compact tier fits its window" isevery((q) => q.x + q.w <= width). - Events and sound:
app.ctx.pollEvents(), andapp.ctx.audioCommands()is what a window would have played. - Measurement:
app.ctx.measureText(content, style, maxWidth)is what layout gives the same<text>, so a breakpoint assertion is arithmetic. It is the same objectview's third argument is, so a test measures what the view measured. - Warnings:
app.warningsis every silent misconfiguration the core noticed (agrowweight with nothing to split, a transition on an unkeyed list item, a duplicate key); assert it is empty.
Windowed app checklist
Things the package already does that are easy to miss when building a
real window. The full prop / element / event reference ships with the
package as props.md (docs/props.md in the repository), and so
do CHANGELOG.md — every release lists what it adds and,
separately, what you can delete — and the ADRs under docs/adr/, which
is where a doc comment pointing at docs/adr/0003-modal-surfaces.md
resolves from inside node_modules. howto.md ships with them:
about twenty questions — playing a sound, animating a removal, resetting an
editor, driving a real window in a test — answered in two sentences each and
pointing into the other three.
-
Hover and pressed colors are props, not queries:
hoverBg,pressedBg, andhoverGroup="name"to light connected pieces together. Addtransition={150}and the swap eases.<button>is exactly that data. For hover-dependent layout useonHover={tag}and react to{kind: 'hover', phase: 'enter' | 'leave'}events;win.isHovered(key)andisPressedanswer for keys you got from events. -
The pointer shape is derived, not declared: an editor is
text, a button or afocusablenodepointer, anonDragnodegrab, a plain boxdefault, andcursor="ewResize"overrides it where the derivation cannot know (a splitter). A window applies it by itself;ctx.cursorShape()reads it back for a test. -
Motion that never settles is data too:
keyframestakes CSS-style stops forwidth/height/bg/radius, cycled overtransitionms in CSS'sanimation-direction(repeat="alternate") and held back bydelayms so siblings stagger —<box transition={1100} easing="easeInOut" repeat="alternate" delay={i * 550} width={{ grow: 0 }} keyframes={[{ width: { grow: 1 } }]} />slides forever without the app ever waking up to flip it. Stops spread evenly unless they nameat(0..1); a slot a stop leaves out falls back to the node's own prop, so[{ at: 0.5, bg: '#f5a97f' }]is a pulse. -
Arrivals are data too. A node's first frame snaps, so a slide-in needed an off-screen frame and a second render;
enterstates the starting point instead:<box key="toast" float="viewport" transition={200} enter={{ dx: 320, bg: '#00000000' }} …/>slides in from the right and fades up on the frame it appears, and enters again after being dismissed. Addslideif it should also glide when layout moves it later — a float whosedx/dychanges eases to the new offset withslidealone. -
Frame timing without the overlay:
win.frameStats()is the latency HUD as data (last.viewMs,avgWorkMs, …),win.stats()the display list summary, andwin.animating()tells a test when motion has settled. -
Tooltips:
tooltip="hint"on any box (implies hover tracking). -
Per-corner radius:
radiusfor all four,radiusTL/radiusTR/radiusBR/radiusBLafter it for the exceptions. -
Sound: register bytes once (
win.addSound(buf)insetup, any wav/ogg/mp3/flac), then reach for it three ways. As props —clickSoundandhoverSoundon any box, the audio equivalent ofhoverBg. As an element —<audio key="music" src={id} loop volume={0.3} />is a playback retained by key: it plays while the view declares it, stops when the view drops it, andvolume/pausedchanges apply to the running sound rather than restarting it, so{model.music && <audio … />}is the whole on/off story. Or imperatively —win.play(id, { volume, loop, fadeIn, tag })returns a playback id forstop/setVolume/pause/resume, and atagcomes back as aSoundMsg({kind: 'sound', phase: 'ended', tag}) when that playback finishes on its own, which is how a chime chains into the next state. Volumes are linear amplitude. Headless (createApp) nothing plays:ctx.audioCommands()hands you what a window would have played, which is what to assert on. -
Fonts:
win.loadFontsDir('fonts')thenwin.addSystemFont('Antonio'), orwin.loadFontFile('fonts/Antonio.ttf'),win.addFont(bytes), or an installed family by name (seesystemFontFamilies()); then<text font={id}>. Register insetup(win)before the first frame. -
Window chrome: open with
chrome: 'custom', put a<titlebar>(or your own strip withwindow="drag"plus<windowButtons/>) in the root; on macOS it insets past the traffic lights itself. The root box'stitleprop names the window each frame. -
Overlays:
float="below" | "above"orfloat={{ anchor: 'viewport', at: ['end','end'], self: ['end','end'] }}draws on top without shifting anything. -
Sliders and dividers:
onDrag={tag}gives{ x, y, dx, dy, parent }—parentis the container rect, so a fraction needs no geometry query. -
Measuring text:
win.measureText('1,234', { size: 48, font })(andctx.measureTextheadless) returns{ width, height, lines }— what layout gives a<text>with that content and those props, at the window's scale; pass amaxWidthto see it wrapped, andwrap/maxLines/ellipsisapply. Size a column to its widest label, or pick the tier whose labels fit, from these numbers; they follow the font. Both are reachable where the sizing happens:view(model, window, surface)gets the surface third, andinit(surface)gets it before the first model, so neither needs the surface parked in a module-level variable. -
Where did layout put it:
onLayout={tag}on a keyed box brings back{ kind: 'layout', x, y, w, h, parent, tag }— on its first frame and whenever the rect changes, never on a frame that left it alone, so keeping it in the model and re-rendering does not loop. Aslidereports every frame it moves. It is the numbers layout already computed, handed back, for the case no prop covers yet. -
Warnings: the core notices what used to fail silently — a
{ grow: 2 }on the only grow child (or across the parent's main axis), atransitionon an unkeyed list item whose siblings changed count, two nodes on onekey— andrunWindowedprints each once (warnings: falsein the options to stop it;win.warnings()drains them yourself). The checks are a development aid: underNODE_ENV=productionthey do not run at all (diagnostics: trueforces them on). -
Window size:
win.size()gives{width, height, scale}(logical px) — insetup(win)before the first frame, ininit(win)while the first model is built, and any time after. It is the viewport the app lays out into: the window's inner size, less the devtools' dock while the panel is docked (KUI_DEVTOOLS=1), and the same numberenv().viewportreads once a frame has run. Changes arrive as{kind: 'resize', width, height, scale}events — a dock coming, going or being dragged among them — so a model that tracks the size updates inupdatelike anything else. Bound what the user can resize to withminWidth/minHeight/maxWidth/maxHeightnext towidth/heightat open; either half of a pair may stand alone, andwidth/heightare clamped into the bounds the OS will enforce. -
A clock:
tick: { every, msg }on either driver (app.advance(ms)fires them headless), orsetTimeouttoward the next boundary in your own loop; do not callupdateevery pump. Ticks are frequent, so unlike UI events a tick re-renders only whenupdatereturns a new model — a countdown that returnsundefineduntil the displayed second changes costs nothing in between. The same rule read backwards is the trap: a tick handler that mutates the model in place and returnsundefined(the escape hatch the rest ofupdateallows) never reaches the screen. Any non-undefinedreturn renders, soreturn modelafter a mutation is the whole fix.everymay read the model —every: (m) => m.running ? 16 : 1000— for an app whose cadence depends on its state: the loop re-reads it after everyupdate, and since the windowed driver never sleeps through a tick, a stopped countdown then costs a pump a second instead of pinning the idle backoff at 16 ms. -
Keys:
onKeyon the root pluskeyFocus; presses arrive as{ kind: 'key', phase: 'down', code, ... }withcodea character or a name ('space','enter','f5'), and that is all a keymap needs — nophasecheck. A held-key interaction (WASD, press-and-hold) addskeyUpto the sink and hears releases too, as the same shape withphase: 'up'.repeatmarks an auto-repeat, and a release carries a nulltext. A key only comes up where it went down — focus moving delivers the release first — so nothing is left stuck.onKey={null}is a sink whose events carry notag(the same goes foronDrag,onHoverandonLayout), so a root sink needs no inert message in the app's union. -
Messages are yours: annotate
updateand the loop follows —createApp/runWindowedinfer the union, soev,dispatchandtick.msgspeak it too (atick.msgwritten inline needs the union named —runWindowed<Model, Msg>(...), or amsgannotated where it is written — since its return is what would be inferred from).CoreMsgis what the core sends on its own (DragMsg,KeyMsg,HoverMsg,ModifiersMsg,changed/submit), each with the payload fields spelled out;pollEvents<KeyMsg<Tag>>()types a raw poll the same way. To have the payload props checked at the node as well, register the app's own union once:declare module '@qxuken/kui/jsx-runtime' { interface KuiMsg { msg: MyMsg } }onClickthen takes exactlyMyMsg(andonDrag/onHover/onKey/onLayouttakeMyMsg | null) rather than any plain data, so a typo fails where it is written. Register the messages you wrote, notMyMsg | CoreMsg:CoreMsgis typed in terms of the registration (itstagfields carry your messages), so naming it there makes the alias circular — keep that union forupdate. It is a program-wide declaration (one app per tsconfig); left out, payload props stay untyped and nothing else changes.