@qxuken/kui (0.1.0-alpha.5)
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.
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, 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;
function update(model: Model, msg: Msg, ev: UiEvent<Msg>): Model | undefined {
switch (msg.kind) { // one union, no casts
case 'add': return { count: model.count + msg.by };
case 'reset': return { count: 0 };
}
}
const view = (model: Model) => (
<box pad={24} gap={16}>
<button onClick={{ kind: 'add', by: 1 }}>+1</button>
<text size={20}>{`count = ${model.count}`}</text>
</box>
);
const app = createApp({ init, update, view }, { width: 640, height: 480 }); // headless
const final = await runWindowed({ init, update, view }, { title: 'counter' }); // a window
The runWindowed loop also 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.
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.key(name)settle the loop for you;app.ctx.cursor/mouse/scroll/keyDown/modifiersare the raw events (a drag is cursor, mouse down, cursor, mouse up).app.ctx.setTime(s)is the clock — never set, transitions snap, which is what most tests want. - 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. - 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).
-
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. -
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. -
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, and any time after. Changes arrive as{kind: 'resize', width, height, scale}events, 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 }onrunWindowed, 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. -
Keys:
onKeyon the root pluskeyFocus; presses arrive as{ kind: 'key', code, ... }withcodea character or a name ('space','enter','f5').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.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.