Units
A layout file usually describes a bar. A Unit lets it describe something else: which workspace an app belongs on, a theme a program should be using, a light that should be on. You say what should be true. tauler keeps looking at the world and does what it takes.
const WindowPlacement = unit({ key: (a) => a.class, value: (a) => a.workspaces, reconciler: optativeSet({ observe: () => currentPlacement() }), updateOne: (a) => moveToWorkspace(a.class, a.workspace),})
export default function render() { return ( <root> <WindowPlacement class="Chromium" workspace={1} /> <panel id="bar" anchor="top" width={1920} height={32}>…</panel> </root> )}Chromium now lives on workspace 1. Drag it somewhere else and it comes back. Open it after a reboot and it lands where you said.
unit() returns a component. Using it — <WindowPlacement class="Chromium" workspace={1} />
— declares one Item: one thing that should be true, with one desired value. Items draw
nothing.
The four parts
Section titled “The four parts”key names an Item. Two Items with the same key are the same thing. The key is what lets
tauler match what it sees in the world against what you asked for.
value is what decides “changed”. If the observed value and the declared value differ, the
Item needs an update. Return whatever you want to compare — a string, a number, an
object.
observe reports what the world actually holds, as an array of Items in the same shape you
declare them. This is the only thing tauler believes. A hook that says it succeeded proves
nothing. The next observe does.
The hooks act. Each comes in two spellings and you pick one:
| batch | per Item |
|---|---|
enter(items) |
enterOne(item) |
update(pairs) |
updateOne(item, old) |
exit(items) |
exitOne(item) |
The batch form is handed all the Items that need that transition, so a Unit that talks
to an API can make one request for ten lights instead of ten requests. Items arrive in the
order the layout declared them. update’s batch form gets {item, old} pairs, so you can
see what the world had before:
update: (pairs) => pairs.forEach(({ item, old }) => fade(old.state, item.state)),A hook you define neither spelling of is a transition you are not managing, which is fine and costs nothing.
What a Sweep does
Section titled “What a Sweep does”One Sweep is: run observe, compare it against the Items the layout declared, call the
hooks the comparison asks for.
| the world | the layout | hook |
|---|---|---|
| absent | declared | enter |
present, different value |
declared | update |
| present | not declared | exit |
Sweeps run on their own thread, off the render loop. A hook that takes forty seconds makes its Unit converge late. It never drops a frame.
A Unit sweeps on a fixed interval. What the last Sweep did makes no difference to when the next one runs:
const WindowPlacement = unit({ refreshInterval: 5000, // ms. 5000 is also the default …})That interval is how quickly a change made outside tauler — you dragging the window
somewhere else — gets undone. It is also your blast radius: a Unit that can never converge,
because its hook is failing or because it declares state: true where the world says
"on", retries exactly this often and no faster. Short enough to feel immediate, long
enough not to hammer whatever observe talks to.
Hooks run somewhere else
Section titled “Hooks run somewhere else”The hooks and observe run in a second JavaScript runtime, on the reconciler thread, and
that one has a shell:
observe: () => JSON.parse(sh`some-command --json`)sh, read, ls, exists and hash exist there and do not exist while your layout
is being rendered. That is deliberate: a shell command during a render would block the bar
for as long as the command takes. If you reach for sh outside a hook, you get
sh is not defined.
The practical consequence: your layout file is evaluated twice, in two runtimes. Anything at module top level runs once in each. Keep the expensive things inside hooks.
Units are a native-only feature. Nothing on this page applies to a browser-hosted layout.
Worked example: apps on the right workspace
Section titled “Worked example: apps on the right workspace”i3 can tell you where every window is and can move them, so a Unit that keeps apps on the workspaces you want is a shell command each way.
const WindowPlacement = unit({ refreshInterval: 5000,
key: (a) => a.class, // A class can hold several windows, so what matters is the set of workspaces it // occupies — declaring one number means "all of them, there". value: (a) => [...new Set(a.workspaces ?? [a.workspace])].map(Number).sort((x, y) => x - y),
reconciler: optativeSet({ observe: () => JSON.parse(sh`timeout 5 i3-msg -t get_tree | jq -c ' [ recurse(.nodes[]?) | select(.type=="workspace") as $ws | [ $ws | recurse(.nodes[]?, .floating_nodes[]?) | select(.window_properties != null) | { class: .window_properties.class, num: $ws.num } ] | .[] ] | group_by(.class) | map({ class: .[0].class, workspaces: (map(.num) | unique) }) | map(select(.class != null))'`), }),
updateOne: (a) => sh`timeout 5 i3-msg '[class="^${a.class}$"] move --no-auto-back-and-forth to workspace number ${a.workspace}' >/dev/null`,})Three things in there are worth pulling out, because each is a general lesson.
value is a set, not a number. Five Chromium windows share one class, so one key covers
all of them. If two are on different workspaces, value is [1, 3], that differs from the
declared [1], and one move consolidates them. Comparing a single workspace number would
have compared an arbitrary one of the five.
There is no enterOne. An app that is not running is declared but not observed, which is
an enter — and a Unit that defines no enter is a Unit that does not manage that
transition. Without this, quitting Spotify would make tauler relaunch it five seconds later.
A missing hook is a design decision you can make.
timeout 5 on both commands. i3’s IPC can fail to answer, and a hook that never returns
holds the reconciler thread for the life of the process. Put a deadline on anything that
talks to something else.
A nicer way to say it
Section titled “A nicer way to say it”unit() returns a component, so the readable spelling is a component too — and it needs
nothing from tauler:
const App = (p) => pconst Workspace = ({ num, children }) => children.map((app) => <WindowPlacement class={app.class} workspace={num} />)Which buys you:
<root> <Workspace num={1}> <App class="Chromium" /> <App class="Slack" /> </Workspace>
<Workspace num={10}> <App class="Spotify" /> </Workspace></root><App> never becomes an Item — it is an inert {class: "Chromium"} that Workspace reads
and discards. Workspace builds the real Item one line later, copying its own num onto
each. There is no prop inheritance and no context. The parent constructs the child
explicitly, which is why this is two ordinary functions rather than a feature.
It also stays one Unit. Grouping is a spelling, not a scope, so a batch hook still gets every Item at once however the layout arranged them.
A Unit that talks to a network service
Section titled “A Unit that talks to a network service”Home Assistant’s REST API is two calls — GET /api/states to see, POST /api/services/light/turn_on to act — so a Unit for a light is short. What it adds to the
example above is a secret.
Keep the token out of the process table. Anything passed as an argument is visible to every process on the machine, and it lands in shell history and logs. A curl config file does not:
install -m 600 /dev/null ~/.config/tauler/hass.curlrccat >> ~/.config/tauler/hass.curlrc <<'EOF'header = "Authorization: Bearer YOUR_LONG_LIVED_TOKEN"header = "Content-Type: application/json"EOFThen the Unit:
const HASS = 'http://homeassistant.local:8123'const MINE = ['light.desk', 'light.hall']
const hass = (path, body) => body ? sh`curl -sfK "$HOME/.config/tauler/hass.curlrc" -d ${JSON.stringify(body)} ${HASS + path}` : sh`curl -sfK "$HOME/.config/tauler/hass.curlrc" ${HASS + path}`
const Light = unit({ refreshInterval: 5000,
key: (light) => light.entity, value: (light) => light.state,
reconciler: optativeSet({ observe: () => JSON.parse(hass('/api/states')) .filter((s) => MINE.includes(s.entity_id)) .map((s) => ({ entity: s.entity_id, state: s.state })), }),
// A light that Home Assistant has never heard of and one whose state is wrong // need the same call, so both hooks are the same call. enterOne: (light) => apply(light), updateOne: (light) => apply(light),})
function apply(light) { hass(`/api/services/light/turn_${light.state === 'on' ? 'on' : 'off'}`, { entity_id: light.entity, })}Used:
<root> <Light entity="light.desk" state={working ? 'on' : 'off'} /> <panel id="bar" anchor="top" width={1920} height={32}>…</panel></root>MINE is what keeps observe honest — without it every other light in the house shows up
as an Item nobody declared.
Note there is no exit. Dropping <Light> from the layout means tauler stops managing that
light, not that it turns it off. If you want it off, declare it off.
That’s true regardless of whether an exit hook is defined, because of how it would fire:
exit only runs for an Item missing from a batch that is otherwise still present — a unit
type with zero instances anywhere in the current render gets no batch at all, and nothing
calls observe() for it to diff against. Concretely: exit fires when you drop one of
several coexisting <Light>s while others remain declared, not when you drop your only
<Light>. There is currently no hook that fires for “this whole unit type disappeared from
the layout.”
Rendering a config file
Section titled “Rendering a config file”ConfigFile turns a path and a render function into a Unit: render() is called for
the text that should be on disk, ConfigFile reads the file back to see what is, and
writes it again when the two disagree.
function rasi(sections) { return Object.entries(sections) .map(([name, props]) => `${name} {\n` + Object.entries(props).map(([k, v]) => ` ${k}: ${v};`).join('\n') + '\n}' ) .join('\n\n') + '\n'}
const RofiTheme = ConfigFile({ path: '/home/you/.config/rofi/tauler.rasi', render: () => rasi({ '*': { 'background-color': '#221F2B', 'text-color': '#E9E4DA' }, window: { width: '480px' }, }),})Used:
<root> <RofiTheme /> <panel id="bar" anchor="top" width={1920} height={32}>…</panel></root>render runs again every Sweep, reading whatever it wants — a color out of globals,
today’s wallpaper, anything the rest of your layout already reaches. Change what it
returns and the next Sweep rewrites tauler.rasi to match. Launch rofi with rofi -theme ~/.config/rofi/tauler.rasi and it picks up the new file the next time it opens — rofi
reads its theme fresh on every launch, so there is nothing to reload.
Telling something the file changed
Section titled “Telling something the file changed”rofi is the easy case. A target that keeps running — a terminal, a notification daemon —
needs to be told, and that is what apply is for: it runs once, right after every write,
with the path and the text just written.
const KittyTheme = ConfigFile({ path: '/home/you/.config/kitty/theme.conf', render: () => kittyConf({ background: '#1B1924', foreground: '#E9E4DA' }), apply: () => sh`kitty @ set-colors --all /home/you/.config/kitty/theme.conf`,})apply does not run on a Sweep that changed nothing — only enter and update write,
and only a write calls it. A target with nothing to signal, like rofi, just omits it.
A throwing apply retries on the next Sweep, even though the file already matches.
observe only ever reads the file back, so once write()’s sh call has landed, the
file matches render()’s output whether or not apply afterward actually succeeded —
without more, the next Sweep would see “content already matches” and call it converged.
ConfigFile guards against that: right after apply returns without throwing, it writes
a small marker next to path recording a hash of what was applied, and observe folds
that marker into what counts as “matching.” A missing or stale marker (because apply
threw, or hasn’t run yet) means the file’s content matching render() is not enough —
the diff still sees a mismatch, and write/apply run again next Sweep. This costs
nothing when apply isn’t given: the marker is skipped entirely.
How it’s built
Section titled “How it’s built”ConfigFile is not a new tauler primitive; it is unit(), optativeSet, sh, read,
exists and hash — every one of them a builtin this page has already used or just met
above — composed once so you do not have to compose them again for the next file. Its
whole body:
function ConfigFile({ path, render, apply, mode }) { const appliedMarker = `${path}.applied` function write() { const rendered = render() if (mode) { sh`mkdir -p $(dirname ${path}) && printf '%s' ${rendered} > ${path}.new.$$ && chmod ${mode} ${path}.new.$$ && mv -f ${path}.new.$$ ${path}` } else { sh`mkdir -p $(dirname ${path}) && printf '%s' ${rendered} > ${path}.new.$$ && mv -f ${path}.new.$$ ${path}` } if (apply) { apply(path, rendered) sh`printf '%s' ${hash(rendered)} > ${appliedMarker}` } } const wasApplied = (content) => !apply || (exists(appliedMarker) && read(appliedMarker) === hash(content)) return unit({ key: () => path, value: (f) => 'content' in f ? { content: f.content, applied: wasApplied(f.content) } : { content: render(), applied: true }, reconciler: optativeSet({ observe: () => (exists(path) ? [{ content: read(path) }] : []), }), enterOne: write, updateOne: write, })}A few things are worth pulling out:
There is no write builtin. sh’s tagged template already quotes ${rendered} as
a single shell argument, so printf '%s' ARG > PATH writes it byte-for-byte — quotes,
newlines and all — with what tauler already ships.
The write goes to a temp file, then mvs into place. mv -f within the same
directory is a rename(2), which is atomic — a reader that opens path mid-write always
sees either the whole old file or the whole new one, never a truncated partial write. This
matters most for exactly the targets that read path fresh on every launch (rofi, mpv):
they’re the most likely to open the file at an arbitrary moment. Two things this doesn’t
solve, on purpose: a watcher keyed on the file’s original inode rather than its path (Qt’s
QFileSystemWatcher does this) can miss the swap and needs re-registering; and on an
SELinux-enforcing system, the fresh temp file can pick up a broader label than the file it
replaced. Neither has a target in the wild here today, so neither is handled — noted for
when one shows up.
mode, when given, is chmod’d onto the temp file before the mv, not onto path
after it. That way a hardened permission (chmod 600 for a file holding a secret) is
never briefly absent at path in between the rename and a follow-up chmod. Leave it out
and a freshly-created file just keeps the default umask, same as before atomic writes.
value tells the two sides apart by shape, then folds in whether apply actually
landed. observe always returns {content}; a declared <RofiTheme/> never has that
key, because it takes no props — that is what lets one value answer “what does the file
hold” for one side and “what should it hold” for the other, instead of comparing a value
against itself. applied rides alongside content in both branches so a stale marker
makes the two sides compare unequal even when content alone already matches — that’s
the whole apply-retry mechanism described above; it doesn’t need any hook this reconciler
doesn’t already have.
A serialiser is not part of this. rasi() — and kittyConf() above, which does not
exist; write it the same way — is plain JavaScript from an object to one format’s text.
A different format wants different lines, and nothing about ConfigFile is generic
across formats. That is a deliberate stopping point, not an unfinished corner.
path has to be absolute. exists and read are plain filesystem calls, and neither
expands a leading ~ — the same limit .rasi itself has for background-image paths.
ConfigFile assumes it fully owns path’s content. observe diffs the whole file
against render()’s whole output; a target that rewrites or reformats the same file on its
own — some apps normalize their config on load — will fight that diff, rewriting on every
Sweep that catches the drift, and if the target folds its own state into that file (not just
whitespace) each rewrite is data loss, not churn. If a target needs to own part of a file,
give ConfigFile a different path the target’s own format can include — the same split
this page’s own deployment uses for a target with a section it manages itself.
Dropping <ConfigFile/> from the layout has the same exit limit <Light> does, further
in: key: () => path gives every ConfigFile() exactly one possible Item, so its unit
type has either one instance or none — never “one of several.” That means the “only fires
when one of several coexisting instances disappears” case above can never apply to it: a
dropped <ConfigFile/>’s file is not just deliberately left on disk, there is currently no
hook that ever gets a chance to fire for it at all. Finding out means reading the layout
file, not the file it wrote.
One apply shared across several files
Section titled “One apply shared across several files”Some targets need to be told about a change in one file, but the reload covers several —
Waybar’s config.jsonc and style.css both restart the same process; a set of systemd user
units all want one daemon-reload. Each ConfigFile is its own independent Unit, so nothing
coalesces their apply calls by default: two files changing together can trigger the reload
twice, back to back, in the same Sweep.
Because apply is ordinary JavaScript closed over by write — never a serialized Item prop
(ADR 0034) — the same closure can be passed to more than one ConfigFile() call, and it can
hold state to skip a redundant second call:
function throttledApply(fn, waitMs = 200) { let last = 0 return (...args) => { const now = Date.now() if (now - last < waitMs) return last = now fn(...args) }}
const reloadWaybar = throttledApply(() => sh`pkill -SIGUSR2 waybar`)
const Config = ConfigFile({ path: '~/.config/waybar/config.jsonc', render: renderConfig, apply: reloadWaybar })const Style = ConfigFile({ path: '~/.config/waybar/style.css', render: renderStyle, apply: reloadWaybar })This is a throttle, not a debounce — there is no setTimeout in this runtime, so nothing can
wait for things to go quiet and fire once afterward. What makes the leading-edge check enough
here: a Sweep folds over every batch sequentially in one synchronous call, so Config and
Style drifting together land their updateOne hooks microseconds apart in that same
Sweep, and the second sh call is the one throttledApply skips. A change landing in a
later Sweep (refreshInterval or more apart) is a separate event and always goes through.
What this doesn’t fix: Config and Style can still have different refreshIntervals
and drift out of sync with each other, so Waybar can reload once against a stale style.css
and again later once it catches up — torn state between two independent Sweep cadences. The
shared closure only removes the redundant-signal half of that problem, not the
out-of-order half.
Driving a Unit from the bar
Section titled “Driving a Unit from the bar”A Unit reads the same globals your layout does, so a button can change what a Unit
declares:
<root> <Light entity="light.desk" state={globals.desk ? 'on' : 'off'} /> <panel id="bar" anchor="top" width={1920} height={32}> <button on_click={() => { globals.desk = !globals.desk }}>desk</button> </panel></root>The click updates globals. The next Sweep sees the new declaration and acts.
globals is read-only inside a hook. The bar owns it. A hook that assigns to it throws.
If a hook needs to record something, the thing to record it in is the world — and observe
is what reads it back.
Where to put a Unit
Section titled “Where to put a Unit”Anywhere in the tree. An Item draws nothing, so declaring one next to the UI it drives is the natural thing:
function DeskLight({ on }) { return ( <> <Light entity="light.desk" state={on ? 'on' : 'off'} /> <button on_click={toggle}>desk</button> </> )}When it isn’t working
Section titled “When it isn’t working”Sweeps log at debug level:
RUST_LOG=tauler::units=debug taulerThat gives you one line per Sweep with what entered, updated and exited, plus the exception from any hook that threw.
What survives a restart
Section titled “What survives a restart”Nothing is torn down when tauler exits or re-execs. The next Sweep after it comes back runs
observe, sees the world as it is, and carries on from there — which is the same thing it
does on the very first Sweep. A Unit does not need to know whether it has run before.