Skip to content
tauler

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.

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.

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.

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.

unit() returns a component, so the readable spelling is a component too — and it needs nothing from tauler:

const App = (p) => p
const 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.

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:

Terminal window
install -m 600 /dev/null ~/.config/tauler/hass.curlrc
cat >> ~/.config/tauler/hass.curlrc <<'EOF'
header = "Authorization: Bearer YOUR_LONG_LIVED_TOKEN"
header = "Content-Type: application/json"
EOF

Then 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.”

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.

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.

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.

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.

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.

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>
</>
)
}

Sweeps log at debug level:

Terminal window
RUST_LOG=tauler::units=debug tauler

That gives you one line per Sweep with what entered, updated and exited, plus the exception from any hook that threw.

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.