Skip to content

Data and events

Every piece of external data a bar shows comes from a subprocess. A layout declares what it wants to read from — on every tick, unconditionally — and tauler decides what to spawn, keep, or kill.

A subprocess is identified by the (bin, script) pair it was declared with. Each tick, that set is diffed against the running one: unchanged identities keep their process, new ones are spawned, and ones that vanished are killed.

That has a consequence worth internalising before writing anything else:

A hook that comes and goes restarts its subprocess on every transition. For a singleton like tauler-notify that means dropped notifications and a momentarily released D-Bus name — which looks like a broken notification daemon, not like a conditional in a layout file.

Two components asking for the same (bin, script) share one subprocess, without either knowing about the other.

The latest stdout line from the subprocess, as a string.

const time = useStringStream("/usr/bin/bash", `
while true; do date +"%H:%M"; sleep 1; done
`);

Despite the name, this is not a React hook. It is a Rust-registered global that reads the current value out of a map. There are no ordering rules, no dependency arrays and no cleanup functions. Calling it registers the subprocess for this tick.

The same, but each stdout line is parsed as JSON and returned as an object.

const data = useJSONStream("/usr/bin/myscript");

tauler keeps exactly one value per stream — the latest line. A widget that needs to show what happened before now gets it by piping the stream through tauler-accumulate, inside the script you are already writing:

const recent = useJSONStream("/bin/sh", `
journalctl -f -o json | tauler-accumulate -n 5
`);

recent is an array of the last five lines, oldest first. One array is written per input line, starting from the first one — the window grows to -n rather than staying blank until it fills, so the widget appears immediately.

It is a ring buffer and nothing more. Each line is parsed as JSON if it parses, and kept as a string if it does not, so a bare number arrives as a number and an error message arrives as a string:

$ printf '0.41\n0.52\noops\n' | tauler-accumulate -n 2
[0.41]
[0.41,0.52]
[0.52,"oops"]

There is no query or filter option, because both sides of it already have one. To trim fat lines before they are buffered, put jq in the pipe:

Terminal window
journalctl -f -o json | jq -c .MESSAGE | tauler-accumulate -n 5

To reshape the window afterwards, do it in the layout file, which is JavaScript:

const load = useJSONStream("/bin/sh", `
while :; do cut -d' ' -f1 /proc/loadavg; sleep 1; done | tauler-accumulate -n 60
`);
const average = load.reduce((a, b) => a + b, 0) / load.length;
const newestFirst = [...load].reverse();

Two things to know. Numbers are re-serialized, so 0.60 comes back as 0.6 — accumulate strings if the exact text matters. And a stream is never restarted once its subprocess exits, which a pipe makes twice as likely, so a dead source keeps rendering its last window indefinitely.

The window is an ordinary array, so an existing component consumes it with no glue:

import { DataTable } from "@ui/datatable";
function RecentLogs() {
const lines = useJSONStream("/bin/sh", `
journalctl -f -o json --output-fields=MESSAGE,_COMM | tauler-accumulate -n 5
`) ?? [];
return (
<div class="flex flex-col gap-2 rounded-lg border px-3 py-3">
<span class="text-[10px] text-foreground opacity-60">RECENT</span>
<DataTable
columns={[{ key: "_COMM", label: "UNIT" }, { key: "MESSAGE", label: "MESSAGE" }]}
rows={[...lines].reverse()}
/>
</div>
);
}

And the window is what makes “peak over the last minute” expressible at all — the latest line on its own cannot say it:

function Load() {
const load = useJSONStream("/bin/sh", `
while :; do cut -d' ' -f1 /proc/loadavg; sleep 1; done | tauler-accumulate -n 60
`) ?? [];
const now = load.length ? load[load.length - 1] : 0;
const peak = load.length ? Math.max(...load) : 0;
const mean = load.length ? load.reduce((a, b) => a + b, 0) / load.length : 0;
return (
<div class="flex flex-col gap-1 rounded-lg border px-3 py-2">
<span class="text-[10px] text-foreground opacity-60">LOAD</span>
<span class="text-[18px] text-foreground">{now.toFixed(2)}</span>
<span class="text-[11px] text-foreground opacity-70">
{`peak ${peak.toFixed(2)} · avg ${mean.toFixed(2)} · ${load.length} samples`}
</span>
</div>
);
}

Both guard against an empty window with ?? [], because a stream has no value until its first line arrives.

Registers the subprocess and returns a proxy for addressing it. Every property is a function, and calling one produces an intent — a plain JSON object naming a destination and the message to deliver there.

const notify = useEvents("~/.cargo/bin/tauler-notify");
notify.dismiss({ id: 42 })
// { "channel": "~/.cargo/bin/tauler-notify",
// "event": { "type": "dismiss", "id": 42 } }

The property name becomes event.type, and the argument’s keys are merged alongside it. Calling with no argument yields just the type.

on_click is always an array of intents — never a bare object, and never a callback. Functions do not survive the JSON boundary at the end of evaluation, so a handler that is a function is silently dropped.

on_click={[
i3.switchWorkspace({ workspace: ws.name }),
notify.dismiss({ id: n.id }),
]}

On a click, tauler finds the topmost element painted over that point that carries an on_click, then delivers each intent’s event object verbatim to that intent’s channel over stdin — one JSON object per line, no wrapping envelope. No JavaScript runs on click.

A module therefore only ever sees its own vocabulary, and never learns that a click caused the message. Because a handler is a list, one gesture can address several subprocesses at once; each intent is delivered independently and in no guaranteed order, and an intent naming an unknown channel is logged and skipped without affecting the others.

Scroll wheel motion is not a click. X11 reports it as button presses 4–7, and those are discarded before hit-testing.

Sugar over useJSONStream + useEvents, for the common case of a subprocess you both read from and talk to. It sends an init event on startup, reads JSON from stdout, and accepts intents on stdin.

<Module bin="~/.cargo/bin/tauler-i3">
{(data, events) => (
<WorkspaceList workspaces={data?.workspaces} events={events} />
)}
</Module>

The child function receives data — the latest parsed JSON — and events, the same proxy useEvents returns.

Any prop other than bin and children is merged into the init payload and written to the subprocess’s stdin: once at spawn, and again whenever the value changes. Identical props are not re-sent, so a module only ever sees real changes.

Keys of the derived init payload (type, config, output, dpi, …) win over declared props — that payload is the module protocol, not user-editable state.

tauler-i3 reads its gaps this way. Every side is declared rather than derived; an omitted side reserves nothing, and outputs with no panel are revoked to zero regardless. The values are logical pixels and reach i3 untouched — see Screen layout.

<Module bin="~/.cargo/bin/tauler-i3" gaps={{ left: 300, top: 8 }}>
{(data, events) => <WorkspaceList workspaces={data?.workspaces} events={events} />}
</Module>

Note that registering a bin as a module changes its spec, and a changed spec restarts the subprocess — the same rule as above, for the same reason.

Injected by Rust before each evaluation. Read-only.

field description
ctx.screen_width monitor width in logical pixels
ctx.screen_height monitor height in logical pixels
ctx.outputs array of { name, screen_width, screen_height } for every connected output
ctx.dpi display DPI

A plain JS object that persists in the JavaScript context between ticks. It is the only way to accumulate state across renders — tracking which workspaces have unread notifications across a stream of events, say.

Use it sparingly. Every tick is otherwise a pure function of the current stream values, and globals is the one thing that breaks that. Prefer deriving what you need from the current values, and reach for globals only when you genuinely need to remember something over time.