Authoring API

The ui object from @typecad/ui is how you wire up a screen — mounting it, holding reactive values, connecting inputs, and drawing on a canvas. You call these functions in your TypeScript; Cuttlefish turns each one into the right device code when you build. (They only exist at build time — if you run them in a plain Node script they’ll throw an error, which is expected.)

import { ui } from '@typecad/ui';

ui.mount(tree, opts?)

Mount a finished UI tree to the configured display. The build checks that the display driver works with your framework; this call becomes the display startup plus the first draw of the screen.

ui.mount(screen);
ui.mount(screen, { spiFrequency: 40_000_000 });

MountOptions (all optional): display, bus, cs, dc, rst, rotation, backlight, spiFrequency, address (I2C), reset (I2C).

ui.signal(initial)

Declare a reactive signal. Lowers to a device variable plus a dirty flag. Three overloads narrow the type from the initial value:

const level = ui.signal(0);     // Signal<number>
const label = ui.signal('Hi');  // Signal<string>
const on = ui.signal(false);    // Signal<boolean>

A Signal<T> is callable to read (level()) and has .set(value) to write (level.set(50)).

ui.bind(node, property, compute)

Bind a node property to a recomputed value, evaluated each tick. Marks the node dirty when the value changes. Supported properties include color, background, borderColor, value, visible, and text.

ui.bind(screen.label, 'text', () => `Level: ${level()}`);
ui.bind(screen.bar, 'value', () => level());

ui.bindInput(node, onText)

Subscribe to an <input> element’s text. The callback fires after the on-screen keyboard commits.

ui.bindInput(screen.ssid, (text) => { UART0.writeLine(`SSID: ${text}`); });

ui.bindList(node, countFn, itemFn, onTap?)

Bind a <list> to dynamic data. Virtualized — only visible items render. countFn returns the item count (called each frame); itemFn returns the label for a given index; onTap (optional) fires when an item is tapped.

ui.bindList(
  screen.networks,
  () => scanResults.length,
  (i) => scanResults[i].name,
  (i) => connectTo(scanResults[i]),
);

ui.watchPin(pin, onFalling)

Watch a GPIO pin for falling edges (a button press). It checks the pin roughly every 20ms in the background — no hardware interrupt needed. Use for buttons wired to GPIO when you don’t have a touch screen.

ui.watchPin(4, () => { screen.count.value++; });

ui.onTap(node?)

An awaitable tap signal for use inside async functions. No argument resumes on the next tap anywhere (including empty space); pass an element to resume only on that element. Use for “tap to continue” flows and display-sleep/screensaver wake.

await ui.onTap(screen.continueButton);
showNextPage();

ui.drawer / ui.dialog / ui.toast

Overlay controls for the <drawer>, <dialog>, and <toast> elements:

ui.drawer.open('nav');      // slide the drawer in
ui.drawer.close('nav');     // and back out

ui.dialog.open('confirm');  // show the centered modal
ui.dialog.close('confirm');

ui.toast('saved');          // show a toast; auto-closes after its duration

ui.drawCanvas(node, callback)

Register a per-frame draw callback for a <canvas> element. Coordinates are canvas-relative and auto-clipped. Colors are ordinary CSS strings, converted at build time to the compact RGB565 color format the display uses. The callback receives a CanvasCtx:

ui.drawCanvas(screen.scope, (ctx) => {
  ctx.fillScreen('#000');
  ctx.line(0, 0, ctx.width, ctx.height, '#0f0');
  ctx.fillCircle(ctx.width / 2, ctx.height / 2, 8, '#f00');
  ctx.text(4, 4, `${screen.load.value}`, '#fff');
});

CanvasCtx

MethodSignature
width / heightreadonly number
drawPixel(x, y, color)
fillRect(x, y, w, h, color)
rect(x, y, w, h, color)
fillRoundRect(x, y, w, h, r, color)
roundRect(x, y, w, h, r, color)
line(x0, y0, x1, y1, color)
hline(x, y, w, color)
vline(x, y, h, color)
fillCircle(x, y, r, color)
circle(x, y, r, color)
rgbBitmap(x, y, data: number[], w, h)
text(x, y, str, color?)
fillScreen(color)

ui.window (native SDL only)

Native-desktop window controls. No-ops on hardware targets.

ui.window.setTitle('My App');
ui.window.setIcon('./assets/icon.png');

Element types and .value

Every element exposes .value: number (its state) plus onClick, onHold, onRelease. Specific elements add more:

TypeNotable members
CheckElementonToggle(pin, onChange?).value flips on a GPIO falling edge
RangeElementonChange(callback?) — fires while the thumb drags
InputElementtext: string, onChange(callback?)
SelectElement.value cycles 0..N-1; text auto-reflects the option
RadioElement.value is 0/1; mutually exclusive within a name group
ProgressElement.value is 0-100 (read-only)

The named screen.* references (e.g. screen.label, screen.ok) are typed against a generated per-file ScreenTree whose fields match the id attributes in your template.

The .ui single-file component

A .ui file has up to three sections, in any order: a <script> block, a <style> block, and the HTML template (typically a <screen> element). Only one <script> block is allowed; multiple <style> blocks are concatenated.

<script> section — TypeScript logic, signals, and the ui.mount call:

import { ui } from '@typecad/ui';
import { Thread, Time } from '@typecad/hal';
export const level = ui.signal(0);
ui.mount(screen);
new Thread(0).start((): void => {
  while (true) {
    level.set((level() + 1) % 100);
    Time.sleep(250);
  }
});

<style> section — CSS rules and variables:

:root { --bg: #111; --fg: #eee; }
screen { background: var(--bg); color: var(--fg); padding: 12px; }
#bar { height: 16px; background: #2563eb; }

Template section — the HTML element tree:

<screen>
  <text id="label">Progress</text>
  <progress id="bar"></progress>
</screen>

The transpiler injects an implicit import { screen } from './app.ui.html' so the template is referenceable as screen.* inside the script.

Accepted entry extensions

Point your cuttlefish.config.ts entry at any of .ts, .tsx, or .ui.

Two project layouts

Pattern A — single-file .ui: script, style, and template in one file. The entry is the .ui file.

Pattern B — split main.ts + .ui.html: keep logic in main.ts and the template in a .ui.html file; import the template from the script. Useful for larger UIs.

State, input, and timing

  • .value reads/writes element state (screen.check.value = 1).
  • Pin inputonToggle(pin, onChange?) on <check>/<view>; ui.watchPin(pin, onFalling) for arbitrary GPIO.
  • Touch eventsonClick (up within 600ms), onHold (≥ 600ms), onRelease, with ~50ms debounce.
  • Two-way input bindingui.bindInput(node, onText) for <input>.
  • Slider changesonChange(callback?) on <range>.
  • Data-bound listsui.bindList(node, countFn, itemFn, onTap?).
  • Signals — reactive values via ui.signal; read with sig(), write with sig.set(...).
  • Periodic updates — a Thread with a Time.sleep loop drives animations and progress (there are no JS-named timers on embedded targets; the host preview still runs them).
⚙️ Advanced details — the runtime architecture
  • Retained-mode runtime. ui.mount initializes the display; ui_tick (called each loop) polls inputs, evaluates bindings, transitions, and @keyframes animations, and repaints dirty regions; ui_init marks all nodes dirty for the first frame.
  • Dirty-rectangle repaint. Only changed regions are redrawn and pushed.
  • Band-renderer scrolling. Scrolling shifts the cached viewport and repaints only the exposed strip (~10 KB SRAM bands), so there is no full-screen repaint per drag frame.
  • Persistent canvases in PSRAM. When BOARD_HAS_PSRAM and psramFound() are true, reusable offscreen canvases (list viewports, repair buffers, on-screen keyboards) are allocated in PSRAM. There is no full-screen framebuffer by default — on SPI TFTs without a tearing-effect signal it shows up as whole-screen flashing, so the runtime always pushes the minimal dirty region instead.

Back to Cuttlefish UI overview.