/// rattery: a sandboxed terminal host for ratatui apps.
///
/// The `terminal` interface is the whole contract between a rattery app (the
/// guest, compiled to a WASI 0.2 component) and the host that owns the real
/// terminal. It is deliberately shaped like ratatui's `Backend` trait plus a
/// crossterm-style event stream, so the guest never emits escape sequences and
/// the host never needs to know what a widget is.
package rattery:tui@0.4.0;
interface terminal {
// ---- rendering types -------------------------------------------------
variant color {
reset,
black,
red,
green,
yellow,
blue,
magenta,
cyan,
gray,
dark-gray,
light-red,
light-green,
light-yellow,
light-blue,
light-magenta,
light-cyan,
white,
rgb(tuple<u8, u8, u8>),
indexed(u8),
}
flags modifier {
bold,
dim,
italic,
underlined,
slow-blink,
rapid-blink,
reversed,
hidden,
crossed-out,
}
/// One terminal cell. `symbol` is a grapheme cluster, not necessarily a
/// single char.
record cell {
symbol: string,
fg: color,
bg: color,
underline-color: color,
modifier: modifier,
}
/// A cell that changed since the last frame, in absolute coordinates.
record cell-update {
x: u16,
y: u16,
cell: cell,
}
record size {
width: u16,
height: u16,
}
record position {
x: u16,
y: u16,
}
record window-size {
columns-rows: size,
pixels: size,
}
enum clear-type {
all,
after-cursor,
before-cursor,
current-line,
until-new-line,
}
// ---- input types -----------------------------------------------------
flags key-modifiers {
shift,
control,
alt,
super,
hyper,
meta,
}
enum key-event-kind {
press,
repeat,
release,
}
flags key-event-state {
keypad,
caps-lock,
num-lock,
}
enum media-key {
play,
pause,
play-pause,
reverse,
stop,
fast-forward,
rewind,
track-next,
track-previous,
%record,
lower-volume,
raise-volume,
mute-volume,
}
enum modifier-key {
left-shift,
left-control,
left-alt,
left-super,
left-hyper,
left-meta,
right-shift,
right-control,
right-alt,
right-super,
right-hyper,
right-meta,
iso-level3-shift,
iso-level5-shift,
}
variant key-code {
backspace,
enter,
left,
right,
up,
down,
home,
end,
page-up,
page-down,
tab,
back-tab,
delete,
insert,
f(u8),
character(char),
null,
esc,
caps-lock,
scroll-lock,
num-lock,
print-screen,
pause,
menu,
keypad-begin,
media(media-key),
modifier(modifier-key),
}
record key-event {
code: key-code,
modifiers: key-modifiers,
kind: key-event-kind,
state: key-event-state,
}
enum mouse-button {
left,
right,
middle,
}
variant mouse-event-kind {
down(mouse-button),
up(mouse-button),
drag(mouse-button),
moved,
scroll-down,
scroll-up,
scroll-left,
scroll-right,
}
record mouse-event {
kind: mouse-event-kind,
column: u16,
row: u16,
modifiers: key-modifiers,
}
/// A newer version of the app the host holds, validated and ready.
record update {
/// The server's validator for the new version (an ETag or date),
/// for display; it is opaque.
version: option<string>,
/// Milliseconds, as of this call, before the host may reload on its
/// own at the next idle moment. Absent: the host leaves it to the
/// app.
reload-after-ms: option<u64>,
/// Milliseconds, as of this call, before the host reloads regardless.
reload-by-ms: option<u64>,
}
/// Whether this app can be updated at all, and by whom.
enum availability {
/// The host checks on its own and tells the app.
watched,
/// The host checks when the app calls `check-update`.
on-request,
/// The host has nowhere to look (the component is embedded in it):
/// `check-update` finds nothing. The host's embedder may still hand
/// it a version, which arrives as `update-changed` like any other.
unavailable,
}
variant event {
focus-gained,
focus-lost,
key(key-event),
mouse(mouse-event),
paste(string),
resize(size),
/// The pending update changed: one appeared, was superseded, or was
/// withdrawn. Read `pending-update` for the state; handle it by
/// saving state (see `storage`) and calling `reload` when convenient.
update-changed,
}
// ---- rendering (mirrors ratatui's Backend trait) ---------------------
draw: func(updates: list<cell-update>);
append-lines: func(n: u16);
hide-cursor: func();
show-cursor: func();
get-cursor-position: func() -> position;
set-cursor-position: func(pos: position);
clear: func(kind: clear-type);
get-size: func() -> size;
get-window-size: func() -> window-size;
flush: func();
// ---- input -----------------------------------------------------------
/// Wait for the next input event. Async: other tasks in the app, such as
/// server calls, keep running while it waits.
next-event: async func() -> event;
/// Drain every event received since the last call. Never blocks.
read-events: func() -> list<event>;
// ---- environment -----------------------------------------------------
/// The origin (`scheme://host[:port]`) the app was loaded from, if any.
/// The host restricts outbound HTTP to this origin unless told otherwise.
origin: func() -> option<string>;
/// The full URL the app was loaded from, query string included, if it
/// was loaded from one. The terminal's `window.location`.
location: func() -> option<string>;
/// Set the terminal window title.
set-title: func(title: string);
/// Tell the host the app is ready for the user: its data is loaded and
/// it is showing a real screen rather than a placeholder. Optional;
/// embedders that want a readiness signal beyond "a frame was drawn"
/// wait for this.
ready: func();
/// Ask the host to look for a newer version now; returns the pending
/// update afterwards. Fails when the source cannot be reached or
/// refuses this host.
check-update: async func() -> result<option<update>, string>;
/// The pending update, with its deadlines as of now.
pending-update: func() -> option<update>;
/// Whether this app can be updated at all, and by whom.
update-availability: func() -> availability;
/// Restart the app in place on the pending update, or on the current
/// version if there is none. Never returns: the instance is torn down
/// and a new one is started.
reload: func();
enum log-level {
trace,
debug,
info,
warn,
error,
}
/// Emit a log record. The host hands it to the embedder live, sanitised
/// and rate limited; nothing is written to the terminal.
log: func(level: log-level, target: string, message: string);
}
/// Key-value storage scoped to the app's origin, like a browser's
/// `localStorage`: private to the origin, bounded by a quota, persisted by
/// the host between runs when it has an origin to key it by.
interface storage {
variant storage-error {
/// The origin's quota (bytes or entries) would be exceeded.
quota-exceeded,
/// The key or value is larger than allowed.
too-large,
/// The host has storage disabled.
disabled,
}
get: func(key: string) -> option<list<u8>>;
set: func(key: string, value: list<u8>) -> result<_, storage-error>;
remove: func(key: string);
keys: func() -> list<string>;
clear: func();
/// Bytes in use and the quota, for the app to show or budget against.
usage: func() -> tuple<u64, u64>;
}
/// WebSockets, provided by the host because WASI 0.2 has none. Connections
/// are subject to the same origin policy and cookie jar as HTTP requests.
interface websocket {
variant message {
text(string),
binary(list<u8>),
}
variant error {
/// The origin policy refused the connection.
denied,
/// Connecting or the handshake failed.
connect(string),
/// The connection is closed; the payload is the close reason, if any.
closed(option<string>),
/// A protocol or transport error after the connection opened.
protocol(string),
}
resource socket {
/// Connect to a `ws://`, `wss://`, `http://`, or `https://` URL and
/// complete the handshake.
connect: static async func(url: string) -> result<socket, error>;
/// Wait for the next message. Once the connection has closed or
/// failed, the error, every time.
receive: async func() -> result<message, error>;
/// Send a message. Waits while the outgoing queue is full, so a
/// slow peer applies backpressure instead of growing host memory.
send: async func(message: message) -> result<_, error>;
/// Close the connection.
close: func();
}
}
/// A rattery app imports the terminal and websockets, plus (through `std` and
/// the `wasip3` crate) the WASI interfaces it links: 0.2 for stdio and clocks,
/// 0.3 for HTTP. It exports one async `run`; the host drives it with the
/// component model's async ABI, so the app is a real async program.
world app {
import terminal;
import websocket;
import storage;
/// Run the app to completion. `err` is a message the host shows the user.
export run: async func() -> result<_, string>;
}