Skip to content

Events

Subscribe instead of polling: dedicate a connection to the event stream for outputs, layouts, workspaces, the session lock, the active look and portal global shortcuts.

subscribe names the event kinds it wants (output, keyboard, workspace, lock, look, global-shortcut; scoot msg subscribe with no names sends output; an empty events list on the raw socket is refused); the reply echoes the subscription, and afterwards that connection carries events until the session ends:

Terminal window
$ scoot msg subscribe
{"type":"subscribed","events":["output"]}
{"type":"output_removed","output":2,"name":"DP-1","adopter":1,"adopted_start":2,"adopted_count":2,"adopter_prev_active":0,"adopter_active":2,"origin":"DP-1"}
{"type":"output_added","output":3,"name":"DP-1","width":3840,"height":2160,"scale":1.5}
{"type":"output_restored","output":3,"name":"DP-1","adopter":1,"adopted_start":2,"adopted_count":2,"adopter_prev_active":2,"adopter_active":0,"origin":"DP-1","moved":3}
{"type":"output_changed","output":1,"name":"DP-1","width":2952,"height":1660,"scale":1.5}
$ scoot msg subscribe keyboard
{"type":"subscribed","events":["keyboard"]}
{"type":"keyboard_changed","name":"Russian","index":1}
$ scoot msg subscribe workspace
{"type":"subscribed","events":["workspace"]}
{"type":"workspaces","output":1,"name":"DP-1","active":0,"counts":[2,0,1]}
$ scoot msg subscribe lock
{"type":"subscribed","events":["lock"]}
{"type":"lock_changed","locked":true}
$ scoot msg subscribe look
{"type":"subscribed","events":["look"]}
{"type":"look_changed","name":"moonrise","source_hash":"9f2c"}
$ scoot msg subscribe global-shortcut
{"type":"subscribed","events":["global_shortcut"]}
{"type":"shortcut_activated","app_id":"org.example.recorder","shortcut_id":"toggle-record","timestamp_ms":4242}
{"type":"shortcuts_changed"}

scoot msg subscribe prints the answer, then one compact JSON object per line per event, until killed or the connection ends. A desktop notification is one pipe away (scoot msg subscribe | ... notify-send). It exits 0 at a clean end of stream; re-run to resubscribe.

Three rules, matching the request/reply contract beside them:

  • A subscribed connection serves no further requests. After the subscribed answer it carries events only; any other request on it is refused with an error naming the rule. Open another connection for requests — they pipeline, so one is enough for any number of them.
  • Filtering is by kind, not by field. The server sends every event of the subscribed kinds, and the client filters or debounces further itself. The removal/add pair below (plus a restore when the monitor held windows) fires on every monitor standby too. output_changed fires only on an applied resize, never on standby, and a refused resize fires nothing.
  • The same socket and the same credentials. A subscriber connects to the same 0600, same-user control socket every other client uses.

output_added — an output was added: a monitor plugged in (or back in) under --tty. It fires on every add: a first plug, a replug that held no windows, and a replug that brings windows back, which sends output_added first and output_restored after it. “The connected set changed” is output_added plus output_removed; output_restored alone misses a first plug and an empty monitor’s replug. It is sent once the output is laid out, so an outputs query answered after it lists the output. The outputs a session starts with come before any subscriber can exist: read outputs once for the baseline, then apply events after it. (--headless adds its --outputs N at startup only, so it never sends one at runtime.)

Field Meaning
output The new output’s id, as outputs reports it — a fresh one on every add, never a removed output’s.
name Its connector name (DP-1 under --tty, headless-2 otherwise) — the same string outputs names it by.
width / height The framebuffer size in physical pixels.
scale The scale it came up at: its live output-scale scale, else the config file’s.

output_removed — an output was removed and its workspaces adopted:

Field Meaning
output The removed output’s id, as outputs reported it.
name Its connector name (DP-1 under --tty, headless-2 otherwise) — the identity a later restore matches on.
adopter The output that adopted its workspaces, or null when none did.
adopted_start / adopted_count The adopted block on the adopter: the 0-based workspace index it starts at, in the post-removal list, and how many workspaces it holds (0 when the removed output held no windows).
adopter_prev_active / adopter_active The adopter’s active workspace before and after the removal, 0-based — where it was already looking, or the adopted workspace the switch moved it to when focus was on the removed output. null with no live adopter to read off.
origin Which connector the adopted workspaces are tagged as coming from (DP-1) — what bars show and what windows reports each adopted window adopted from. null when nothing was adopted.

output_restored — an output came back under a matching identity (note the fresh output id: ids are stable for the session, not across unplug cycles). The adoption fields describe the record this restore consumed, as the removal filed it; moved says how many still-open windows actually went back — windows moved by hand or closed in between stay where they are. adopter_prev_active / adopter_active are the adopter’s view before the restore and after it returns to its pre-adopt view (null when the adopter itself is gone — then nothing moved either).

output_changed — an output’s mode changed in place: the same connector at a new framebuffer size, keeping its scale. This is the re-probe resize: a --tty hotplug offering a new mode for a connected connector (a VM window moving between displays of different densities), or a --nested host window being resized. It is not a removal: no workspace is adopted, nothing moves, and no restore follows. A script watching density recomputes the scale it wants from width / height and applies it with a config reload (output.scale and outputs.<name>.scale apply live). It fires once per applied resize, after the windows are re-laid-out at the new size; a refused resize fires nothing.

Field Meaning
output The output’s id, as outputs reports it.
name Its connector name (DP-1 under --tty, headless-2 otherwise) — the same string outputs names it by.
width / height The new framebuffer size in physical pixels.
scale The scale the output keeps running at — the scale to recompute from.

keyboard_changed — the seat keyboard’s effective layout (xkb group) changed: the same name and index the keyboard query answers with, naming the layout now in effect. No standard Wayland protocol reports this to an unfocused client, so this event and the query are the channel a layout indicator reads.

It fires once per change, never per keypress: typing on one layout sends nothing, and one group switch sends exactly one event. Rapid successive switches each send their own — every event names the layout in effect when it was sent, so a reader that processes them in order ends where the keyboard is. As with the query, this is read-only: nothing over IPC switches the layout, and scoot has no layout-switch bind or action. The group moves only through the keymap’s own mechanics (a toggle key from the XKB_DEFAULT_OPTIONS the session started with).

A subscriber that stops reading is disconnected rather than buffered without bound: past the same 1 MiB queued-reply bound, or with no byte leaving for the same 10-second stall window, the compositor shuts the connection down and drops the subscription. Output removal never waits for a subscriber. A client that disconnects itself leaves no record behind.

workspaces — one output’s workspace occupancy changed: which of its workspaces hold windows, as a full snapshot rather than a delta, so a subscriber that missed one is never wrong. A bar’s workspace module draws from it without polling windows. No standard Wayland protocol reports the “holds windows” bit to an unfocused client: ext-workspace-v1 carries the list, positions and the one active bit but not that bit, and no other standard protocol maps a toplevel to a workspace.

Field Meaning
output The output’s id, as outputs reports it.
name Its connector name (DP-1 under --tty, headless-2 otherwise) — the same string outputs names it by.
active The output’s active workspace, 0-based — the same numbering focus-workspace-index N takes.
counts One entry per workspace, in order: how many windows sit on it (counts[i] > 0 is the occupied flag a bar draws; the number itself is what an agent reads).

One event per output whose snapshot moved: a window opened, closed or moved between workspaces, the active workspace switched, or an output added (a removed output sends nothing, because its removal is already an output_removed). Events are coalesced to at most one per output per frame tick, however fast windows churn. A fresh subscription starts silent, like keyboard. Read windows once for the baseline (the counts are the histogram of each window’s workspace on its output) and apply snapshots after it.

lock_changed — the session locked or unlocked: the same locked flag the locked query answers with, naming the state it moved to. No standard Wayland protocol reports this to an unfocused client, so this event and the query are the channel that watches the lock.

It fires once per transition, never per request: a refused lock changes nothing and sends nothing, and a takeover of an already-locked session is no transition either. A fresh subscription starts silent: read locked once for the baseline and apply changes after it. As with the query, this is read-only: nothing over IPC locks or unlocks the session.

look_changed — the active look layer changed: the rendered look’s name (null when the layer went away) and its source_hash from the render’s render.json. Fires once per identity change, as seen by a reload; a reload that changes nothing emits nothing. A fresh subscription starts silent: the current look is whatever the last look_changed said, or no look if none arrived yet.

shortcut_activated / shortcut_deactivated — a portal global shortcut’s chord was pressed / released: app_id and shortcut_id naming the registration, timestamp_ms the compositor clock at intercept time. Sent only to global-shortcut subscribers. Compositor binds always win, so a chord the live keymap holds never produces these; nothing fires while locked; key repeat never re-fires. shortcuts_changed — the registered set moved (register, unregister, bind, unbind, reload, registrant disconnect): re-list with shortcuts for the new state. A fresh subscription starts silent: read shortcuts once for the baseline.

Protocol versions, by the version that introduced each part:

  • 5: the subscription (subscribed, output_removed and output_restored tags under the 3 → 4 bump, plus output_changed under 4 → 5).
  • 6: the keyboard reply and the keyboard_changed tag.
  • 7: the workspaces tag.
  • 8: the locked reply and the lock_changed tag.
  • 10: the output_added tag, on the existing output kind. An output subscriber built against protocol 9 or older fails to decode it: rebuild it (scoot msg from the same build as the session is always in step).
  • 11: the look_changed tag on the new look kind.
  • 12: the look swap. The look_apply and looks request tags with their look_applied and looks replies. An older server answers either request with an ordinary error, never a misapplied swap.
  • 13: global shortcuts. The shortcut_register, shortcut_unregister, shortcuts and shortcut_bind request tags with the shortcuts reply, plus the shortcut_activated, shortcut_deactivated and shortcuts_changed tags on the new global-shortcut kind.

A client that never sends subscribe (or keyboard, or locked) never receives any of them. An unknown event kind in a subscribe is answered with an ordinary error like any unknown request tag, so an older server meets a newer subscriber with an error, not a kill.