ui

tape
@profile(t2);
import ui from "ui";
import gfx from "gfx";

component Swatch {
    @tape { ui.drawable(); }
    prop bg: color = #3366CC;

    fn paint(s: *gfx.Surface, x: i64, y: i64, w: i64, h: i64) {
        s.fill_rect(x, y, w, h, bg);
    }
}

pub fn main() -> i32 {
    ui.run(Swatch {}, 200, 200, "Swatch") or { return 1; };
    return 0;
}

ui runs the component model: it updates the tree when state changes, lays it out, paints it on a gfx.Surface, and delivers pointer and keyboard input.

Showing a tree

FunctionDoes
run(root, w, h, title) -> void or UiErrorOpen a window and run until it is closed
run_window(root, Window { ... }) -> void or UiErrorThe same, with the window placed, frameless or on top
quit()End run after the event being handled, as if the window were closed
headless(root, w, h) -> Screen or UiErrorThe same tree offscreen, rendered into its own surface

run_window takes a ui.Window; every field has a default:

FieldDefault
width, height800, 600The client area, in pixels
title"tape"UTF-8; any character the font can show
x, ynullThe window’s outer top-left on the screen; null lets the platform choose (a frameless window is centered)
framelessfalseNo title bar or border: the client area is the whole window
topmostfalseStays above other windows
tape
ui.run_window(Deck {}, ui.Window {
    width: 1280; height: 720; title: "Deck";
    x: 0; y: 0; frameless: true; topmost: true;
}) or { return 1; };

Call ui.quit() from a handler (a key, a click, a timer) to close the window and return from run. On Linux the placement, frame and stacking are requests the window manager may ignore; on Uusi the compositor places windows itself.

Waking on your own handles

A window that also follows something else (a log, a timer, a pipe, a network connection) names what run should wait on besides the window, and a function to call when it can be read:

tape
var g_view: *u8 = null;

fn on_log(h: i64) {
    let n: i64 = read_some(h);            // read what is there now
    append_lines(n);
    ui.invalidate(g_view);                // the view reads module state
}

pub fn main() -> i32 {
    let v = LogView {};
    g_view = v as *u8;
    ui.watch(log_handle, on_log) or { return 1; };
    ui.run(v, 640, 400, "Log") or { return 2; };
    return 0;
}
FunctionDoes
watch(handle, on_ready: fn(i64)) -> void or UiErrorCall on_ready(handle) whenever the handle can be read
unwatch(handle)Stop
watch_socket(handle, want, on_ready) -> void or UiErrorThe same for a net socket (stream.h, listener.h); want is net.READABLE, net.WRITABLE or both
unwatch_socket(handle)Stop
watch_count()Handles and sockets watched (at most ui.MAX_WATCHES, 15; one more is UiError.TooManyWatches)

A handle is the platform’s: a Win32 HANDLE (an event, a waitable timer, a process), a Linux or macOS file descriptor, a Uusi kernel handle (a channel, devices/log, devices/timer). run waits on the window and the handles together, so nothing polls: the callback runs as soon as one is ready, then the window repaints what the callback changed, as after a click. Sockets are checked every ui.POLL_MS (10 ms) while any is watched; on macOS watched descriptors are too.

The callback does the reading. A handle stays ready, and its callback keeps being called, until it has been read (or reset, for a Win32 event that resets by hand), so read what is there or unwatch it. Watch before run or from any handler or callback while it runs; a callback may unwatch its own handle. Watching a handle again replaces its callback.

Headless, screen.ready(handle) does what run does for a ready handle (its callback, then a frame) and screen.poll_sockets() what it does every POLL_MS (the ready sockets’ callbacks, then a frame).

on_ready is a module function. A component’s own method can’t go there: it needs its instance, which a plain function value doesn’t carry, so ui.watch(h, tick) inside a component is a compile error. A component puts a ui.Watch or ui.Timer in its view instead (next section).

Timers and watches in a component

A component that samples something (a clock, a device, a socket) has the loop call one of its own methods, the way a button calls one: ui.Timer and ui.Watch are components with an event, and the method is bound to it. They have no size and paint nothing. The state stays in the component’s vars, and the children get it as props:

tape
@profile(t2);
import ui from "ui";
import gfx from "gfx";

// A bar as long as `percent`: knows nothing of where it comes from
component Meter {
    @tape { ui.drawable(); }
    prop percent: i64 = 0;

    fn paint(s: *gfx.Surface, x: i64, y: i64, w: i64, h: i64) {
        s.fill_rect(x, y, w, h, #202020);
        s.fill_rect(x, y, w * percent / 100, h, #33CC66);
    }
}

component Monitor {
    var cpu: i64 = 0;
    var mem: i64 = 0;

    fn sample() {                    // this Monitor's, once a second
        cpu = cpu_percent();
        mem = memory_percent();
    }

    view {
        ui.Column {
            ui.Timer { every: 1000; on_tick: sample; }
            Meter { percent: cpu; }
            Meter { percent: mem; }
        }
    }
}

pub fn main() -> i32 {
    ui.run(Monitor {}, 300, 80, "Activity") or { return 1; };
    return 0;
}

Each tick changes cpu and mem; the frame after it repaints the two meters and nothing else.

ComponentPropsEvent
ui.Timerevery: i64 (milliseconds), repeat: bool = trueon_tick() every every ms (once, with repeat: false)
ui.Watchhandle: i64, want: u8 = 0on_ready(handle: i64) whenever handle can be read
  • A Timer counts from when it is mounted, and again from when every or repeat change; every of 0 or less never ticks. A tick that comes late (a long handler) is not caught up: the timer goes on from then.
  • A Watch watches while it is mounted, again when handle or want change; a negative handle watches nothing. want: 0 is a platform handle as ui.watch takes; net.READABLE / net.WRITABLE make it a socket as ui.watch_socket takes. It counts in ui.watch_count().
  • Either stops when it leaves the view (an if turning false, its component unmounted).
  • ui.Column and ui.Row give them no space. A layout of your own skips them with ui.takes_space(child).

Headless, time moves only when you say: screen.advance(ms) moves the ui clock (ui.clock_ms()) on, and every timer due on the way ticks, each followed by a frame (it returns the ticks):

tape
let m = Monitor {};
var screen: ui.Screen = ui.headless(m, 300, 80) or { return 1; };
@assert(screen.advance(999) == 0);
@assert(screen.advance(1) == 1);            // one second after mounting
@assert(ui.frame_painted() == 2);           // the two meters

Making components draw and react

A component opts into what it needs with the dispatch groups:

Call in the bodyMethods it then implements
@tape { ui.drawable(); }fn paint(s: *gfx.Surface, x: i64, y: i64, w: i64, h: i64); gets bounds_x/y/w/h
@tape { ui.layoutable(); }fn layout(x: i64, y: i64, w: i64, h: i64): place the children (ui.layout_tree on each)
@tape { ui.interactive(); }fn handle_pointer(ev: *ui.PointerEvent), fn handle_key(ev: ui.KeyEvent); optionally fn focus_changed(focused: bool), fn handle_paste(text: []const u8)
@tape { ui.focusable(); }Nothing required: makes it a Tab stop; optionally fn skip_tab() -> bool, fn keeps_tab() -> bool

A pointer event goes to the component under the pointer and bubbles to its parents until one sets ev.handled = true. Only components that draw are hit: one without paint (a wrapper, a ui.Column) lets the pointer through to its children. A press makes the component that handled it capture the pointer and take focus: it gets the Move events and the release (Up if released over it, Cancel otherwise). Keys go to the focused component. ev.kind is Down, Up, Cancel, Move, Enter, Leave or Wheel; ev.modifiers holds the ui.Modifier bits (Shift, Ctrl, Alt, Meta) held at the event, and ev.clicks counts presses in a row on one component (2 for a double click): close together, within ui.DOUBLE_CLICK_MS (500) and ui.DOUBLE_CLICK_PX (4). Key events carry ui.Key codes and ui.Modifier bits.

A Wheel event carries the scroll as it came: ev.wheel_y (positive toward the end, the content moving up) and ev.wheel_x (positive to the right), in 1/120 of a wheel’s notch (ui.WHEEL_NOTCH): a touchpad sends a few units at a time. ev.delta is whole notches, positive away from the user, with the rest of a notch carried to the next event. ui.ScrollArea follows the fine amount, both ways.

The clipboard

tape
fn handle_key(ev: ui.KeyEvent) {
    if (ui.is_copy_key(ev)) { ui.set_clipboard(selected_text()); }
    if (ui.is_paste_key(ev)) { ui.paste(); }    // handle_paste gets the text
}
fn handle_paste(text: []const u8) { insert(text); }

ev.key is the key as a ui.Key (ev.key == ui.Key.Escape, ui.Key.A, ui.Key.F5), ui.Key.Other for a code the enum does not name; ev.keycode is the raw code, the same on every platform (ui.key_of(code) maps one). A typed character (KeyKind.Char) is a Unicode code point in ev.codepoint, whatever the keyboard layout; widgets.TextInput keeps its text as UTF-8 and moves its cursor by characters.

ui.set_clipboard(text) puts text on the system’s clipboard (and keeps it as the program’s own). ui.paste() asks for it: the focused component’s handle_paste gets it after the current event, or when the system answers. On Leivos reading the clipboard needs the user’s paste gesture, so call paste from the paste key’s (or a click’s) handler. ui.is_copy_key, is_cut_key and is_paste_key tell the usual keys (Ctrl or Command with C, X, V; Shift+Insert, Shift+Delete). widgets.TextInput copies, cuts and pastes with them. When the system’s clipboard is empty or holds no text, a paste gives nothing. Linux and macOS have the program’s own clipboard only, for now.

Focus

One component at a time has the keyboard focus. When the window opens with nothing focused, the first focusable component in view order gets it, as one Tab would, so a window whose only focusable component is its root handles keys at once; ui.focus from on_mount chooses another (null: none). A press moves it to the component that handled the press; a press that nothing handles (an empty area) takes it away, so two fields never both show a caret. focus_changed(true) and focus_changed(false) tell the component that gets and the one that loses it: draw a caret or a focus ring while it is focused.

tape
component Field {
    @tape { ui.drawable(); ui.interactive(); ui.focusable(); }
    state focused: bool = false;
    fn focus_changed(f: bool) { focused = f; }
    // paint draws the caret while focused ...
}
FunctionDoes
ui.has_focus(c)Whether c has the focus
ui.focus(c)Give c the focus (null: take it away). Called before the window opens (from on_mount), it takes effect when input starts, instead of the default first Tab stop: an app’s second field, or null for none

Tab and Shift+Tab move the focus between the ui.focusable() components, in view order, wrapping round. One whose skip_tab() returns true is passed by (a disabled field); one whose keeps_tab() returns true gets Tab in handle_key while it has the focus (an editor that indents). The Char event a Tab also makes is not delivered when the Tab moved the focus. widgets.TextInput is focusable and keeps its caret only while focused.

A key goes to the focused component’s handle_key. A component that doesn’t write a handle_key of its own (a Slider, a Button) passes the key on to the nearest component above it that does, so clicking a slider doesn’t stop your window’s shortcuts. A component with a handle_key takes every key that reaches it.

Layout containers: ui.Column, ui.Row (below), ui.ScrollArea (content_w, content_h), ui.WindowLayer (overlapping windows, ui.raise). ui.show_dialog(d) paints d over everything and gives it the pointer until ui.close_dialog().

Column and Row

ui.Column stacks its children top to bottom, ui.Row left to right. Each has three props of its own:

PropDefaultDoes
gap0Pixels between two children
padding0Pixels kept free inside every edge
alignui.Align.StretchWhere a child with a fixed size sits across: Start, Center, End; Stretch fills the width (a Column) or the height (a Row)

Their children size themselves with three more props, width, height and grow, which every widget, ui.Column, ui.Row and ui.ScrollArea have:

  • width: 120 (in a Row) or height: 32 (in a Column) is a fixed size;
  • the children without one share what is left, in proportion to their grow (1 unless given): grow: 2 takes twice its siblings’ share, grow: 0 none;
  • across, a fixed size is kept and placed by the container’s align.
tape
ui.Column { padding: 12; gap: 8;
    widgets.Label { text: "Name"; height: 20; }
    widgets.TextInput { height: 28; }
    widgets.Panel {}                                   // grows: the rest of the height
    ui.Row { height: 32; gap: 8; align: ui.Align.Center;
        widgets.Panel {}                               // pushes the buttons right
        widgets.Button { label: "Cancel"; width: 90; height: 28; }
        widgets.Button { label: "Save"; width: 90; height: 28; }
    }
}

grow: 0 with no fixed size gives a child its own size, the one it measures: a Button as wide as its label, a Label as tall as its text. With an align other than Stretch it takes its own size across too. ui.Column and ui.Row measure as well (their children, gaps and padding), so a ui.Row { grow: 0; ... } in a Column is as tall as its tallest child:

tape
ui.Row { grow: 0; gap: 8; padding: 8;
    widgets.Button { label: "Open"; grow: 0; }
    widgets.Button { label: "Save as..."; grow: 0; }
    widgets.Panel {}                                   // the rest
}

With none of them given, the children share the space equally, as before. Your own component takes the three sizing props with @tape { ui.sizable(); }; a component without them is laid out as one with grow: 1. It measures itself with @tape { ui.measurable(); } and a fn measure(max_w: i64, max_h: i64) -> ui.Size; ui.natural_size(child, max_w, max_h) is what a layout asks (fixed sizes first, then the measure). Being props, all of them can be set by #style rules (ui.Row { gap: 8; }), so a module’s spacing can live in one place. A layout of your own reads a child’s through if (child is ui.Sizable) { ... child.width ... }, or hands its children to ui.layout_line(__self, vertical, x, y, w, h, gap, padding, align), the one Column and Row use.

Animating

A component that moves with time is @tape { ui.animated(); } with a fn frame(dt_ms: f64) -> bool: after ui.animate(comp), ui calls it every frame (16 ms) until it returns false. Step tweens there and assign what the view reads. ui.stop_animation(comp) stops it, and screen.advance(ms) runs the frames headless. See tween and easing.

Keeping a view current

Keep what a view shows in the component’s vars, change it in the component’s own methods (bound to events: a click, a key, a ui.Watch or ui.Timer for handles and timers), and hand it down as props:

tape
import widgets from "widgets";

component Clicks {
    var count: i64 = 0;
    fn bump() { count = count + 1; }

    view {
        ui.Column {
            widgets.Label { text: "clicks: {count}"; }
            widgets.Button { label: "+1"; on_click: bump; }
        }
    }
}

A binding (text: "clicks: {count}", size: count % 3, a prop handed on to a child’s own view) is evaluated again when a var, state or prop it reads changes, and the child it sets repaints if the value differs. Nothing else is needed. A var changes when it is assigned, when an element or field of it is, and when a method that may change it (one taking *self) is called on it: items.push(x) re-reads text: "{items.len()}".

For a list prop (a slice), “differs” means its contents: rebuilding a list in place with the same rows repaints nothing, and only the components whose rows changed repaint. Numbers, strings, nested lists, floats and optionals inside the rows all count. A pointer inside a row counts by the address it holds, not by what it points at: if that data changes, call ui.invalidate on the component.

ui.invalidate(comp) is the explicit way out, for views that read state changed outside the tree, such as a module variable a module function given to ui.watch updates (the sample above). A binding that reads a module variable or calls a function is evaluated when its instance is built and again only at ui.invalidate: ui doesn’t follow module state. invalidate(comp) evaluates every binding and if/for of comp and of the components under it on the next frame, and repaints comp’s area.

What gets repainted

A frame repaints only what changed. A component is dirty when one of its vars or states is assigned (an element or field of one included) or its parent gives it a new prop value; the frame repaints the dirty components’ bounds, the old and new bounds of anything layout moved, and where removed components were: those rectangles (at most ui.MAX_RECTS, 8) are cleared and every drawable that meets them is painted again in tree order, clipped to them, and a window presents just them. A pointer move that changes no state repaints and presents nothing. So:

  • paint draws inside the component’s bounds (gfx drawing is clipped there; code that writes s’s pixels itself keeps inside s.clip()) and from the component’s own state, props and bounds;
  • what paint or layout writes (a position kept for hit tests) is not a change;
  • a view or paint that reads something ui can’t see (a module variable, a theme switch) needs ui.invalidate(comp), which evaluates the view’s bindings again and repaints the component’s whole area (the whole window for the root).

Testing without a window

A Screen from headless works like a window that you drive yourself:

tape
let app = Counter {};
var screen: ui.Screen = ui.headless(app, 200, 100) or { return 1; };
screen.click(20, 50);                       // press and release
screen.type_text("hello");                  // keys for the focused component
screen.press(ui.Key.Return);
@assert(screen.pixel(5, 5) == #FFFFFF);
@assert(screen.checksum() == 1234567890);   // the whole picture
let r: gfx.Rect = ui.bounds(ui.child(app as *u8, 0));   // where layout put a child
screen.free();
MethodDoes
pointer(kind, x, y), click(x, y), wheel(x, y, delta), scroll(x, y, dx, dy)Pointer input (scroll: a touchpad’s amounts, 1/120 notch), then a new frame
hold(modifiers)The ui.Modifier bits held for the pointer events that follow (a key event sets them too)
key(kind, keycode, codepoint, modifiers), press(key), type_text(text)Keyboard input, then a new frame
pixel(x, y), checksum(), focus()What the last frame shows; who has focus
frame()Render again (after changing state yourself)
ready(handle), poll_sockets()A watched handle’s callback, or the ready sockets’, then a new frame
advance(ms)The ui clock moved on: each ui.Timer due ticks, then a new frame
free()Free the screen’s surface; the component tree stays (ui.unmount it)

What the last frame (of a screen or a window) did, for tests that count repaints:

FunctionIs
ui.frame_painted()Components it painted (0 when nothing had changed)
ui.frame_damage(), ui.frame_rect(i)The rectangles it repainted (0 to 8)
ui.paint_count()Components painted since the program started
tape
screen.pointer(ui.PointerKind.Move, 5, 5);  // into a button
@assert(ui.frame_painted() == 1);           // the button, nothing else
screen.pointer(ui.PointerKind.Move, 6, 5);  // still in it
@assert(ui.frame_damage() == 0);            // nothing repainted

No display is needed, so these tests run anywhere tape test does.

The tree

FunctionDoes
child(comp, i), child_total(comp), children(comp)The children, through if and for
parent(comp)The parent component
bounds(comp) -> gfx.RectWhere the last layout put a drawable component
invalidate(comp)Re-evaluate every binding and if/for of it and the components under it (module variables and calls included), and repaint its area, on the next frame
hit_test(root, x, y)The component under a point
unmount(comp)Destroy comp and everything under it: on_unmount, then its children, then its memory (see Lifecycle)

ui.update_tree, layout_tree, paint_tree, do_pointer and do_key are the passes run and Screen use, for frameworks of your own.

Last modified: