Building interfaces #
Budo gives you a canvas, not widgets. For tools, editors, forms, and settings screens, budo-ui is a small JavaScript toolkit built on that canvas: buttons, fields, lists, tables, menus, dialogs, page navigation, and more. It handles motion, keyboard focus, touch gestures, screen readers, and system preferences for you, so a simple screen stays a few lines of code.
Create a project with it:
budo init my-ui-app --template uiThe library lands in ui/, next to a starter main.js. The files are yours: read them, change them, or delete the widgets you don't use. ui/README.md lists every widget and option. To add the library to an existing project, copy examples/budo-ui/budo-ui.js and the examples/budo-ui/lib/ folder into a ui/ folder of your project. The live demo shows a workspace and the widget gallery.
budo-ui is a JavaScript library. Lua and WebAssembly apps draw their interfaces with the canvas API directly.
A first screen #
// Import the toolkit from the project's ui/ folder.
import { createUI } from './ui/budo-ui.js';
// Create the UI once, outside the frame loop.
const ui = createUI();
// The app owns its data: a text model and a flag.
const name = { value: '' };
let enabled = false;
function frame() {
// Start the frame with this frame's input, and paint the theme background.
ui.begin(sys.input.get());
ui.clear();
// Split the window, minus a 16 dp margin, into three 48 dp rows.
const [field, toggle, submit] = ui.rows(ui.inset(ui.bounds, ui.dp(16)), [48, 48, 48].map(ui.dp));
// A text field edits name.value in place.
ui.field('name', field, name, { placeholder: 'Name' });
// A toggle returns its new value.
enabled = ui.toggle('enabled', 'Enabled', toggle, enabled);
// A button returns true on the frame it is clicked.
if (ui.button('submit', 'Submit', submit, { primary: true })) ui.toast('Hello ' + name.value);
// Finish the frame: overlays, toasts, focus, and accessibility.
ui.end();
// Ask for the next frame while something moves, otherwise wait for input.
ui.nextFrame(frame);
}
// Start the frame loop.
sys.animation.requestFrame(frame);The interface is immediate mode. Each frame, the app draws it from its own data, and controls return their results right away: button returns true when clicked; toggle, checkbox, slider, and select return the new value; field edits its { value } model in place. Give each interactive control a stable, unique id. The library keeps only interaction and motion state under that id, and forgets ids that stop being drawn.
Hover colors, the button's press feedback, the toggle's thumb, and the field's focus ring all animate without any animation code. Because ui.nextFrame(frame) waits for input when nothing moves, an idle app uses almost no CPU.
Layout #
Layout works on plain rectangles, { x, y, width, height }, in physical canvas pixels.
ui.rows(rect, sizes) and ui.columns(rect, sizes) split a rectangle. Each size is a number of pixels, a { weight: n } share of the remaining space, or a { content: n } natural size (measure it with ui.textWidth(text) or ui.buttonWidth(label)); content sizes shrink together when space runs out. ui.inset(rect, amount) shrinks a rectangle, and ui.split, ui.scroll, and ui.panel add a draggable splitter, a scroll area, and a surface.
Two helpers convert sizes: ui.dp(n) turns density-independent pixels into physical ones, and ui.sp(n) does the same for text while following the user's text-size preference. ui.bounds is the window minus notches and system bars, and above the on-screen keyboard while it is open.
// A sidebar of one third and an inspector, separated by a draggable splitter.
ui.split('workspace', ui.bounds, drawSidebar, drawInspector, { ratio: 0.33 });
function drawInspector(rect) {
// Content taller than the rectangle scrolls; draw it in the content rectangle.
ui.scroll('inspector', rect, ui.dp(1200), content => drawSettings(content));
}Widgets drawn later sit on top and get the pointer first. ui.overlay(draw) draws above everything, which is how menus, popovers, and toasts stay on top.
The widgets #
| Kind | Widgets |
|---|---|
| Actions | button (secondary, primary, ghost, or danger, with an optional icon), iconButton, menu, contextMenu |
| Values | toggle, checkbox, radio, segmented, tabs, select, slider, field, inlineEdit, textArea |
| Content | label, paragraph, richText, progress, spinner, skeleton, tooltip, toast |
| Data | table, tree |
| Structure | panel, split, scroll, list, navigator, shared, dialog, sheet, glass, layer, overlay |
A few of them in use:
// A drop-down: returns the picked value.
color = ui.select('color', rect, ['Red', 'Green', 'Blue'], color);
// A reorderable list: the app applies the move it reports.
const result = ui.list('library', rect, items, { selectedId, reorder: true });
if (result.move) moveItem(result.move.fromId, result.move.toId);
// Wrapped text, cut after four lines.
ui.paragraph(notes, rect, { color: ui.colors.muted, maxLines: 4 });
// A modal dialog: returns true when the user dismisses it.
if (ui.dialog('confirm', open, content => drawBody(content), { title: 'Delete?' })) open = false;ui.textArea(id, rect, model, options) edits multiline text. It wraps and scrolls to follow the caret, selects with the mouse (double-click for a word, triple-click for a line), moves by words with Alt or Ctrl, and undoes whole runs of typing with Ctrl+Z. Its options are placeholder, label, maxLength, and readOnly.
Lists animate insertions, removals, and reordering by themselves. Dialogs and sheets fade in over a dimmed backdrop, and toasts stack and leave on their own.
Tables and trees #
ui.table and ui.tree draw only the visible rows, so ten thousand rows cost a screenful. A table asks for rows by index. Clicking a sortable header returns the new sort order, and the app sorts its own rows:
const result = ui.table('files', rect, {
// Column widths are dp, or { weight } shares; Size is right-aligned.
columns: [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'size', label: 'Size', width: 90, align: 'right' },
],
// The table asks for the rows it shows, by index.
rowCount: rows.length, row: i => rows[i], rowId: i => rows[i].id,
// The app owns the selection and the sort order.
selectedId, sort,
});
// Keep the selection the user made.
selectedId = result.selectedId;
// A header click reports a new sort: apply it to the app's rows.
if (result.sort) { sort = result.sort; rows.sort(compareBy(sort)); }
// Enter or a double click activates a row.
if (result.activatedId) openFile(result.activatedId);Users can resize columns by dragging the header edges. A tree takes nested { id, label, children, icon } nodes and remembers which ones are expanded. In both, the arrow keys move the selection, Left and Right collapse and expand tree nodes, and the selection scrolls into view.
Icons #
ui.icon(name, rect, { color }) draws an icon in any color, so it follows the theme and animates with it. 27 icons are built in: check, close, plus, minus, the four chevrons, two arrows, menu, more, search, trash, edit, copy, star, heart, home, user, info, alert, refresh, sun, moon, folder, and file. ui.registerIcon(name, d) adds your own from SVG path data on a 24-unit grid:
// Register a lightning bolt from 24-unit SVG path data.
ui.registerIcon('bolt', 'M13 3L5 14h6l-1 7 8-11h-6z');
// An icon-only button; the label is what screen readers announce.
if (ui.iconButton('flash', 'bolt', rect, { label: 'Flash' })) flash();
// A text button with a leading icon.
ui.button('add', 'Add', rect, { primary: true, icon: 'plus' });Icon buttons are round and ghost-styled by default. Always give them a label; otherwise screen readers announce the icon's name.
Pages and navigation #
The app owns a stack of route names. ui.navigator draws the top page, slides pages in and out when the stack changes, and returns true when the user goes back with Escape or a drag from the left edge:
// Draw the top route; pop the stack when the user goes back.
if (ui.navigator('nav', rect, stack, (route, page) => drawPage(route, page))) stack.pop();Inside a page, ui.shared(tag, rect, draw) marks an element that morphs between pages, such as a thumbnail that grows into a detail view.
Motion #
For your own animation, ask for an animated value by key:
// A number that springs toward its target: the drawer slides in and out.
const x = ui.spring('drawer', open ? 0 : -ui.dp(320));
// A color that moves to the new value through OKLab.
const tint = ui.color('banner', error ? ui.theme.danger : ui.theme.accent);
// Enter and exit: draw(t) runs while visible or leaving, with t from 0 to 1.
ui.presence('toast', visible, t =>
ui.layer(rect, { opacity: t, y: (1 - t) * ui.dp(24) }, () => drawToast(rect)));Springs are interruptible and independent of the frame rate: a new target redirects the motion without a jump. ui.layer(rect, options, draw) fades, moves, and scales a group, and with backdrop it frosts what is behind it. When the system asks for reduced motion, every animation jumps to its end; set ui.reducedMotion to override it.
Themes #
createUI(tokens) and ui.setTheme(tokens) take colors (background, surface, ink, accent, danger, ...) and sizes in dp (font, row, radius, gap, padding), merged over the light theme. Two more tokens shape surfaces: elevation, the three shadow levels that menus, dialogs, and toasts use (and that ui.shadow(rect, level) draws), and accentGradient, top-to-bottom colors that replace flat accent fills on primary buttons, progress bars, and toggles. Changing the theme animates every color to its new value.
// Import the built-in themes along with the toolkit.
import { createUI, themes } from './ui/budo-ui.js';
// Start from the dark theme, with a violet accent and gradient.
const ui = createUI({ ...themes.dark, accent: '#7C6FF7', accentGradient: ['#9D8FFF', '#6252E6'] });
// Or follow the system's dark mode with themes.light and themes.dark.
ui.setTheme('system');Keyboard, gestures, and the system #
budo-ui follows the platform without extra code in the app.
Tab and Shift+Tab move a focus ring through the widgets in drawing order, and Enter or Space activates the focused one. Menus and selects move the focus into their items, and dialogs and sheets keep it inside until they close.
On touch screens, scrolling carries momentum and stretches past the ends, list rows with a swipe action slide away, sheets drag down to close, and pages drag back. A text field focuses and opens the on-screen keyboard when the tap is released, so a drag that starts on a field scrolls instead. While the keyboard is open, ui.bounds shrinks above it and the focused field scrolls into view, so it stays visible above the keyboard.
Reduced motion and text size follow the system settings, and ui.setTheme('system') follows dark mode. Hovered widgets set the mouse cursor, fields copy and paste through the clipboard, and toggles and picks give haptic feedback on devices that have it. Every widget also describes itself to screen readers, which can operate it; see Accessibility.
Custom widgets #
Draw with ui.fill, ui.outline, and ui.text, or with sys.canvas directly, then ask for interaction:
// Hit-test the swatch, show a pointer cursor, and describe it to screen readers.
const { hovered, held, clicked } = ui.interact('swatch', rect, {
cursor: 'pointer',
a11y: { role: 'button', label: 'Pick red' },
});
// Animate its color between the rest, hover, and pressed states.
ui.fill(rect, ui.color('swatch-fill', held ? '#b91c1c' : hovered ? '#dc2626' : '#ef4444'), ui.dp(8));
// Act on a click, a tap, Enter or Space, or a screen-reader press.
if (clicked) pick('red');ui.interact returns { hovered, held, clicked, focused }. Passing a11y makes the widget reachable with Tab and visible to screen readers.
Testing #
examples/budo-ui/gallery.js draws every widget. The snapshot tests draw each gallery section on a recording canvas, a stub sys object that logs every call, and compare the calls with stored JSON, so any visual change shows up as a reviewable diff. Run them from the repository root:
# Compare the gallery with the stored snapshots.
node --test examples/budo-ui/budo-ui.snapshots.test.mjs
# Accept the current drawing as the new snapshots.
UPDATE_SNAPSHOTS=1 node --test examples/budo-ui/budo-ui.snapshots.test.mjsThe same approach works for your own screens: install a recording sys stub, drive ui.begin with synthetic input, and assert on what was drawn or clicked.