idle
Idle inhibitors, plus threshold and inhibit methods.
mantle.idle reads like the others (:get, :map, :on_change) but has no actions. Its
thresholds take Lua callbacks, which cannot cross to the Supervisor, so it has methods
instead. Any method, :get included, starts it.
local dimmed = state("dimmed", false)
mantle.idle:register_threshold(300, function()
dimmed:set(true)
end, function()
dimmed:set(false)
end)
State
mantle.idle:get() returns IdleState, nil before the first push. A field marked ? may be absent.
mantle.idle’s payload.
| Field | Type | Description |
|---|---|---|
compositor_hold_stale | boolean | The compositor last withheld idle notifications, but input resumed before it could answer again. Its hold may have ended; a fresh idle period clears this uncertainty. |
inhibited | boolean | True when logind blocks idle, a ScreenSaver client holds it, or the compositor was last observed withholding idle notifications. The compositor answer can remain true after a release while the seat is active; the next idle threshold refreshes it. |
inhibitors | IdleInhibitor[] | Holders other than this shell, ScreenSaver clients included. The compositor’s hold has an empty who; draw why then. An unconfirmed compositor hold is omitted. |
IdleInhibitor
One holder blocking idle.
| Field | Type | Description |
|---|---|---|
who | string | Free-text holder name, e.g. "mpv"; draw it, never match it. |
why | string | Free-text reason, e.g. "Playing video"; often empty. |
Actions
None: read-only, so any method but get, map and on_change raises.
Methods
| Method | Contract |
|---|---|
:register_threshold(seconds, on_idle, on_resume) | Runs on_idle after seconds (1 to 4294967, whole) without input and on_resume(cause) when that idle ends or input follows a non-input resume. Returns an integer handle. If this idle period already passed seconds for another registration, on_idle runs at once |
:cancel_threshold(handle) | Drops one registration. An unknown or cancelled handle is a no-op |
:inhibit(reason) | Takes one hold on a logind idle block inhibitor. Counted: two calls need two releases |
:release_inhibit() | Releases one hold; with none held, a no-op |
on_resume receives "input" for user input, "activity" when the compositor’s regular idle
notification resumes before input is confirmed, or "inhibitor" when logind blocks idle. An
"activity" or "inhibitor" callback can be followed by "input" for the same idle period. On a
protocol v1 compositor there is no input-only listener; its regular resume is reported as
"input" and may also represent compositor-specific activity such as a presence sensor.
| Event | Thresholds | Inhibit holds |
|---|---|---|
| In-place reload | Dropped before the config evaluates again; top-level register_threshold calls re-register. The same seconds keeps its timer and does not re-run on_idle this idle period | Kept |
| Renderer replacement | Dropped | Dropped |
Backend
| Part | Behaviour |
|---|---|
| Thresholds | ext_idle_notifier_v1 on the Supervisor’s own Wayland connection. Missing protocol, or setup over 5 s: thresholds never fire, logged once |
| Inhibit | Every hold, from any generation, shares one logind Inhibit("idle", "block") fd, closed when the last hold goes |
| ScreenSaver | Hosts org.freedesktop.ScreenSaver when the name is free. A browser’s video hold arrives here, directly or through xdg-desktop-portal, and takes the same fd. A client that leaves the bus loses its holds |
| Gate | Mantle, not logind, acts on idle, so it honours inhibitors itself. While logind’s BlockInhibited names idle, idled thresholds get on_resume("inhibitor") and none fire; input during the hold sends on_resume("input"). On release, ones still idle get on_idle again |
| Compositor holds | A Wayland idle inhibitor shows when the shortest threshold’s input-only twin fires and the normal notification does not, or when the normal one resumes alone because a hold began on an idle seat, so it needs a registered threshold and an idle seat. It sets inhibited and adds one holder with an empty who. After a detected hold, input resuming sets compositor_hold_stale, removes the unconfirmed holder from inhibitors, and keeps inhibited at its last value until the next idle threshold |
How do I…
Keep the screen awake (caffeine)
The hold survives reloads, so named state records it:
local caffeine = state("caffeine", false)
rect {
on_click = function()
if caffeine:get() then
mantle.idle:release_inhibit()
else
mantle.idle:inhibit("caffeine")
end
caffeine:set(not caffeine:get())
end,
children = {
text {
content = computed({ caffeine, mantle.idle }, function(held, idle)
if held then
return "awake (held)"
elseif idle.inhibited then
return "awake (another app)"
end
return "idle allowed"
end, "idle allowed"),
},
},
}
Gotchas
| Trap | Fix |
|---|---|
register_threshold inside on_change, a timer or a click handler | Each call adds a registration that lives until the next reload. Register once at top level, or keep the handle and cancel_threshold it |
An inhibit hold that never ends | Holds survive reloads and are counted. Record the hold in a state and release once per inhibit |
| A threshold never fires while a video plays | Something holds idle off and inhibited is true. Draw inhibitors to show who |
See also: Lock screen recipe, which locks after an idle threshold.