git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commita5b8a03517795ebbb16bb48c928d2e10967395bf
parent259d73f7a9
authorLucas Galante <[email protected]>
date2026-09-16 08:26
feat(idle): display-off and sleep timeouts from an `idle { }` config block

`idle { display_off <s>; sleep <s>; sleep_command "…" }` — both timeouts 0
(off) by default. A new Server subcomponent (src/server/idle.rs) runs two
event-loop timers re-armed from Seat::handle_activity and held disarmed
while an idle-inhibitor is active. Display off reuses the output-power
protocol's soft-disable (DisabledSoft), waking only the outputs it
darkened; sleep runs the command under `sh -c` (default `systemctl
suspend`). The wlroots session's `active` signal — via a new shim, since
wlr_session is opaque to bindgen — counts as activity, so resume lights
the screen without a key.

Also: the hardware pointer handlers (motion, absolute motion, button,
axis) and the keyboard group never called handle_activity, so idle-notify
clients were never told about mouse or keyboard use; they do now, and
injected ccectl pointer/key events count too. `ccectl idle` reports and
drives the state (`idle timeouts`, `wake`, `sleep`, `display on|off`).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                          |  24 +++
 README.md                          |  15 ++
 src/cce_ctl.rs                     |   3 +
 src/lib.rs                         |   2 +
 src/server/config.rs               |  17 ++
 src/server/cursor.rs               |   8 +
 src/server/idle.rs                 | 333 +++++++++++++++++++++++++++++++++++++
 src/server/idle_inhibit_manager.rs |   1 +
 src/server/keyboard_group.rs       |   7 +
 src/server/output.rs               |   4 +
 src/server/seat.rs                 |   1 +
 src/server/server.rs               |   4 +
 src/server/window_manager.rs       |   3 +
 src/server/wlroots_log_wrapper.c   |   9 +
 wrapper.h                          |   2 +
 15 files changed, 433 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3857ff7..f144760 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -475,6 +475,30 @@ prints, per output, mode / scale / logical size / mm / logical px per mm and whe
 the mm came from (`configured`, `measured`, `none`); the creation log line says the
 same.
 
+**Idle timeouts** — `idle { display_off <s>; sleep <s>; sleep_command "…" }`,
+both 0 (off) by default — are `src/server/idle.rs`, a `Server` subcomponent
+rather than window-manager state: two `wl_event_loop` timers re-armed from
+`Seat::handle_activity`, held disarmed while `IdleInhibitManager::check_active`
+reports an inhibitor. "Display off" reuses the wlr-output-power-management
+path (`OutputStateValue::DisabledSoft` + `dirty_windowing`): the output stays
+in the layout, nothing re-arranges, and no frame events fire while it is dark.
+Only outputs the timeout darkened (`Output::idle_off`) are woken by the next
+input, so one a client turned off with `wlopm` stays as the client left it.
+The sleep command is `sh -c` under a fork, reaped by the server's SIGCHLD
+handler; `systemctl suspend` returns as soon as the job is queued, so resume
+is detected from the wlroots session's `active` signal instead (the
+`river_wlr_session_get_active_signal` shim — `wlr_session` is opaque to
+bindgen), treated as activity so a lid-open lights the screen without a key.
+Note that until 2026-09-16 the hardware pointer handlers (`handle_motion`,
+`handle_motion_absolute`, `handle_button`, `handle_axis`) and `handle_group_key`
+never called `handle_activity` at all — only tablet, touch and gestures did —
+so `ext-idle-notify` clients were never told about mouse or keyboard use;
+injected `ccectl pointer-*`/`keypress` events count as activity too, which is
+what lets a shadow session exercise the timeouts (`ccectl idle timeouts 2 0`,
+then `ccectl outputs` reads `enabled=false`, then any injected input reads
+`true`). `ccectl idle` prints the state; `idle wake|sleep|display on|off` act
+now. Untested in a shadow, which has no session: the resume wake.
+
 Persistent window state is saved to **`~/.local/state/cce/state.json`**
 (`XDG_STATE_HOME/cce/state.json`) on shutdown and restored on start
 (`save_state` / `load_state` / `spawn_restored_windows`). A window's
diff --git a/README.md b/README.md
index 03700e4..f36a5e2 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,21 @@ settings. The config format is [KDL](https://kdl.dev). A startup script at
 
 Configuration can also be changed live over the control socket with `ccectl`.
 
+Idle timeouts go in an `idle { }` block: `display_off` and `sleep` are seconds of
+no input (0, the default, disables one), and `sleep_command` overrides the
+default `systemctl suspend`:
+
+```kdl
+idle {
+    display_off 600
+    sleep 1800
+}
+```
+
+Any pointer or key input wakes the darkened outputs and restarts both countdowns; an
+idle-inhibitor held by a mapped surface (a playing video) pauses them. `ccectl idle`
+reports the state, and `ccectl idle timeouts <display_off> <sleep>` sets them live.
+
 ## Development
 
 See [CLAUDE.md](CLAUDE.md) for a detailed tour of the architecture, the FFI
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index b86e860..107b02e 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -64,6 +64,9 @@ fn usage(name: &str, to_stderr: bool) {
     print("  overview                   # toggle; the exit lands on the focused window, not the pointer");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
     print("  outputs [--json]           # list outputs: mode, scale, logical size, physical mm, px/mm, and where the mm came from");
+    print("  idle [status]              # idle timeouts: configured seconds, idle time, inhibited/displays_off/sleeping");
+    print("  idle wake|sleep|display on|display off   # act now: wake darkened outputs, run the sleep command, darken/wake outputs");
+    print("  idle timeouts <display_off_s> <sleep_s>  # set the timeouts live (0 = off); config.kdl `idle { }` on reload");
     print("  status-hide-mode [true|false]");
     print("  adjust-position-mode [true|false|query]");
     print("  exit [force]               # log out; waits for windows to close, cancels if one stays (a save prompt); force skips the wait");
diff --git a/src/lib.rs b/src/lib.rs
index 21bbb61..903d158 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -59,6 +59,8 @@ pub mod xkb_keyboard;
 pub mod xkb_config;
 #[path = "server/idle_inhibit_manager.rs"]
 pub mod idle_inhibit_manager;
+#[path = "server/idle.rs"]
+pub mod idle;
 #[path = "server/lock_manager.rs"]
 pub mod lock_manager;
 #[path = "server/input_device.rs"]
diff --git a/src/server/config.rs b/src/server/config.rs
index 4253084..6f51155 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -826,6 +826,9 @@ pub struct Config {
     pub surface: SurfaceConfig,
     #[serde(default)]
     pub window_manager: Option<WindowManagerConfig>,
+    /// The `idle { }` block: display-off and sleep timeouts (seconds).
+    #[serde(skip)]
+    pub idle: crate::idle::IdleConfig,
 }
 
 #[derive(Debug, Deserialize)]
@@ -1719,6 +1722,14 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         }
     }
 
+    // 3b. idle timeouts
+    let mut idle = crate::idle::IdleConfig::default();
+    if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "idle") {
+        idle.display_off_s = get_child_arg_i64(node, "display_off", 0);
+        idle.sleep_s = get_child_arg_i64(node, "sleep", 0);
+        idle.sleep_command = get_child_arg_string_opt(node, "sleep_command");
+    }
+
     // 4. output
     let mut output = None;
     let mut display = HashMap::new();
@@ -2344,6 +2355,7 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         transparency,
         surface,
         window_manager,
+        idle,
     })
 }
 
@@ -2534,6 +2546,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     let root_plate_rgba = parse_hex_color_rgba(&config.surface.root_plate_color);
     state.layout.window_opacity = root_plate_rgba[3] < 0.999;
     state.layout.scenefx_optimized_blur = config.output.as_ref().map(|o| o.scenefx_optimized_blur).unwrap_or(true);
+    // Idle timeouts live on the server, not the window manager; a reload
+    // re-arms them from now with the new figures.
+    if !state.server.is_null() {
+        unsafe { (*state.server).idle.configure(&config.idle); }
+    }
     state.layout.status_backdrop_blur_ignore_transparent = config.layout.status_backdrop_blur_ignore_transparent;
     state.layout.window_backdrop_blur_ignore_transparent = config.layout.window_backdrop_blur_ignore_transparent;
     state.layout.status_module_hide_mode_preview = config.layout.status_module_hide_mode_preview;
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index e3496b4..b26be6c 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -950,6 +950,7 @@ impl Cursor {
     /// layout pixels — `wlr_cursor_warp_absolute` is 0..1-normalized, which is the
     /// bug the old `pointer-move-to` had.
     pub unsafe fn inject_motion_to(&mut self, x: f64, y: f64) {
+        (*self.seat).handle_activity();
         ffi::wlr_cursor_warp(self.wlr_cursor, std::ptr::null_mut(), x, y);
         self.update_hovered();
         self.update_drag_icons();
@@ -1184,6 +1185,10 @@ impl Cursor {
 unsafe extern "C" fn handle_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, motion_listener);
     let event = data as *mut ffi::wlr_pointer_motion_event;
+    // Pointer input is activity for the idle timeouts and the idle-notify
+    // clients. Injected events (`ccectl pointer-*`) arrive here too, and
+    // count: a script driving the pointer is someone using the desk.
+    (*cursor.seat).handle_activity();
     // Real pointer motion ends an emulated view drag: the client must not
     // see the synthetic drag position and the true one interleaved.
     if cursor.view_drag.is_some() {
@@ -1243,6 +1248,7 @@ unsafe extern "C" fn handle_motion(listener: *mut ffi::wl_listener, data: *mut s
 unsafe extern "C" fn handle_motion_absolute(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, motion_absolute_listener);
     let event = data as *mut ffi::wlr_pointer_motion_absolute_event;
+    (*cursor.seat).handle_activity();
     
     let wlr_device = if (*event).pointer.is_null() {
         std::ptr::null_mut()
@@ -1283,6 +1289,7 @@ unsafe fn is_cloud_layer(layer_surface: *mut crate::layer_shell::LayerSurface) -
 unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, button_listener);
     let event = data as *mut ffi::wlr_pointer_button_event;
+    (*cursor.seat).handle_activity();
     if cursor.view_drag.is_some() {
         cursor.end_view_drag("button");
     }
@@ -2185,6 +2192,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
 unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, axis_listener);
     let event = data as *mut ffi::wlr_pointer_axis_event;
+    (*cursor.seat).handle_activity();
     
     let seat = &mut *cursor.seat;
     
diff --git a/src/server/idle.rs b/src/server/idle.rs
new file mode 100644
index 0000000..57fcc01
--- /dev/null
+++ b/src/server/idle.rs
@@ -0,0 +1,333 @@
+// SPDX-License-Identifier: GPL-3.0-only
+
+//! Idle timeouts: turn the displays off after `display_off` seconds without
+//! input, and run the sleep command after `sleep` seconds. Both are 0 (off)
+//! until `idle { }` in config.kdl sets them.
+//!
+//! Activity is whatever `Seat::handle_activity` already counts as activity
+//! for the `ext-idle-notify` clients — pointer motion, buttons, axes,
+//! gestures, tablet, and (since this module) keys. An `idle-inhibit`
+//! inhibitor on a mapped surface (a video player) pauses both timers, the
+//! same signal `wlr_idle_notifier_v1_set_inhibited` gets.
+//!
+//! "Display off" is the soft-disable the wlr-output-power-management
+//! protocol already drives (`OutputStateValue::DisabledSoft` — the output
+//! stays in the layout, nothing is re-arranged, and no frame events fire
+//! while it is dark, so an idle desktop also stops rendering). Only outputs
+//! this module darkened (`Output::idle_off`) are woken again: one a client
+//! turned off with `wlopm` stays as that client left it.
+//!
+//! Resume: `systemctl suspend` returns as soon as the job is queued, so the
+//! command's exit says nothing. Instead the wlroots session's `active`
+//! signal — which fires when the seat comes back from suspend, and on a VT
+//! switch back — is treated as activity, so a lid-open shows the screen
+//! without waiting for a key.
+
+use crate::ffi;
+use crate::server::{Server, WlListener, wl_signal_add, wl_listener_remove};
+use crate::output::{Output, OutputStateValue};
+
+pub const DEFAULT_SLEEP_COMMAND: &str = "systemctl suspend";
+
+/// The `idle { }` block, in seconds; 0 disables a timeout.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IdleConfig {
+    pub display_off_s: i64,
+    pub sleep_s: i64,
+    pub sleep_command: Option<String>,
+}
+
+impl Default for IdleConfig {
+    fn default() -> Self {
+        Self { display_off_s: 0, sleep_s: 0, sleep_command: None }
+    }
+}
+
+/// Re-arming the timers on every pointer-motion event would be a pair of
+/// `timerfd_settime` calls per event; once a second is plenty for timeouts
+/// measured in minutes.
+const REARM_MIN_MS: u64 = 1000;
+
+pub struct IdleManager {
+    pub server: *mut Server,
+    display_timer: *mut ffi::wl_event_source,
+    sleep_timer: *mut ffi::wl_event_source,
+    /// Timeouts in ms; 0 = disabled.
+    display_off_ms: i64,
+    sleep_ms: i64,
+    /// `None` means `DEFAULT_SLEEP_COMMAND`. (An `Option<String>` is
+    /// null-niche safe under `Server::new`'s zeroed init; a bare `String`
+    /// is not.)
+    sleep_command: Option<String>,
+    /// An idle-inhibitor is active: timers are held disarmed.
+    inhibited: bool,
+    /// The display timeout fired and outputs were darkened by us.
+    displays_off: bool,
+    /// The sleep command was spawned; cleared by the next activity.
+    sleeping: bool,
+    /// Monotonic ms of the last (re)arm and of the last activity.
+    armed_at_ms: u64,
+    last_activity_ms: u64,
+    session_active: ffi::wl_listener,
+    session_listening: bool,
+}
+
+fn now_ms() -> u64 {
+    let ts = crate::util::timestamp();
+    ts.tv_sec as u64 * 1000 + ts.tv_nsec as u64 / 1_000_000
+}
+
+impl IdleManager {
+    pub unsafe fn init(&mut self, server: *mut Server) -> Result<(), &'static str> {
+        self.server = server;
+        let event_loop = ffi::wl_display_get_event_loop((*server).wl_server);
+        self.display_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_display_timeout),
+            self as *mut IdleManager as *mut _,
+        );
+        if self.display_timer.is_null() {
+            return Err("Failed to create idle display timer");
+        }
+        self.sleep_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_sleep_timeout),
+            self as *mut IdleManager as *mut _,
+        );
+        if self.sleep_timer.is_null() {
+            ffi::wl_event_source_remove(self.display_timer);
+            self.display_timer = std::ptr::null_mut();
+            return Err("Failed to create idle sleep timer");
+        }
+        self.display_off_ms = 0;
+        self.sleep_ms = 0;
+        self.sleep_command = None;
+        self.inhibited = false;
+        self.displays_off = false;
+        self.sleeping = false;
+        self.armed_at_ms = 0;
+        self.last_activity_ms = now_ms();
+
+        // Headless and nested backends have no session; only DRM does.
+        let session = (*server).session;
+        if !session.is_null() {
+            let listener = &mut self.session_active as *mut ffi::wl_listener as *mut WlListener;
+            (*listener).notify = Some(handle_session_active);
+            wl_signal_add(ffi::river_wlr_session_get_active_signal(session), &mut self.session_active);
+            self.session_listening = true;
+        }
+        Ok(())
+    }
+
+    pub unsafe fn deinit(&mut self) {
+        if self.session_listening {
+            wl_listener_remove(&mut self.session_active);
+            self.session_listening = false;
+        }
+        if !self.display_timer.is_null() {
+            ffi::wl_event_source_remove(self.display_timer);
+            self.display_timer = std::ptr::null_mut();
+        }
+        if !self.sleep_timer.is_null() {
+            ffi::wl_event_source_remove(self.sleep_timer);
+            self.sleep_timer = std::ptr::null_mut();
+        }
+    }
+
+    /// Apply an `idle { }` block (config load and `ccectl reload`).
+    pub unsafe fn configure(&mut self, cfg: &IdleConfig) {
+        self.display_off_ms = cfg.display_off_s.max(0) * 1000;
+        self.sleep_ms = cfg.sleep_s.max(0) * 1000;
+        self.sleep_command = cfg.sleep_command.clone();
+        log::info!(
+            "idle timeouts: display_off={}s sleep={}s command={:?}",
+            cfg.display_off_s.max(0),
+            cfg.sleep_s.max(0),
+            self.sleep_command()
+        );
+        self.rearm(true);
+    }
+
+    pub fn sleep_command(&self) -> &str {
+        self.sleep_command.as_deref().unwrap_or(DEFAULT_SLEEP_COMMAND)
+    }
+
+    /// Input arrived (or the session came back). Wakes darkened outputs
+    /// and restarts both countdowns.
+    pub unsafe fn on_activity(&mut self) {
+        let now = now_ms();
+        self.last_activity_ms = now;
+        let changed = self.displays_off || self.sleeping;
+        if self.displays_off {
+            self.set_displays(true);
+        }
+        self.sleeping = false;
+        self.rearm(changed);
+    }
+
+    /// From `IdleInhibitManager::check_active`: an inhibitor appeared or
+    /// the last one went away.
+    pub unsafe fn set_inhibited(&mut self, inhibited: bool) {
+        if self.inhibited == inhibited {
+            return;
+        }
+        self.inhibited = inhibited;
+        log::debug!("idle: inhibited={}", inhibited);
+        self.rearm(true);
+    }
+
+    /// Arm (or disarm, when inhibited or unconfigured) both timers from
+    /// now. Throttled unless `force`: pointer motion calls this per event.
+    unsafe fn rearm(&mut self, force: bool) {
+        let now = now_ms();
+        if !force && now.saturating_sub(self.armed_at_ms) < REARM_MIN_MS {
+            return;
+        }
+        self.armed_at_ms = now;
+        let active = !self.inhibited;
+        let display_ms = if active { self.display_off_ms } else { 0 };
+        let sleep_ms = if active { self.sleep_ms } else { 0 };
+        if !self.display_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.display_timer, display_ms.min(i32::MAX as i64) as i32);
+        }
+        if !self.sleep_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.sleep_timer, sleep_ms.min(i32::MAX as i64) as i32);
+        }
+    }
+
+    /// Darken (`on == false`) every enabled output, or wake the ones this
+    /// module darkened. Goes through the same scheduled-state path as the
+    /// output-power protocol; the next transaction commits it.
+    pub unsafe fn set_displays(&mut self, on: bool) {
+        let server = &mut *self.server;
+        let head = &mut server.om.outputs as *mut ffi::wl_list;
+        let mut link = (*head).next;
+        let mut touched = 0;
+        while link != head {
+            let output = &mut *crate::container_of!(link, Output, link);
+            link = (*link).next;
+            if output.wlr_output.is_null() {
+                continue;
+            }
+            if on {
+                if output.idle_off {
+                    output.idle_off = false;
+                    if output.scheduled.state == OutputStateValue::DisabledSoft {
+                        output.scheduled.state = OutputStateValue::Enabled;
+                        touched += 1;
+                    }
+                }
+            } else if output.scheduled.state == OutputStateValue::Enabled {
+                output.scheduled.state = OutputStateValue::DisabledSoft;
+                output.idle_off = true;
+                touched += 1;
+            }
+        }
+        self.displays_off = !on;
+        log::info!("idle: displays {} ({} output(s))", if on { "on" } else { "off" }, touched);
+        if touched > 0 {
+            server.wm.dirty_windowing();
+        }
+    }
+
+    /// Run the sleep command (`sh -c`), detached; the server's SIGCHLD
+    /// handler reaps it.
+    pub unsafe fn sleep_now(&mut self) {
+        let cmd = self.sleep_command().to_string();
+        log::info!("idle: sleeping via `{}`", cmd);
+        self.sleeping = true;
+        match nix::unistd::fork() {
+            Ok(nix::unistd::ForkResult::Child) => {
+                crate::process::cleanup_child();
+                let sh = std::ffi::CString::new("/bin/sh").unwrap();
+                let dash_c = std::ffi::CString::new("-c").unwrap();
+                let cmd_c = std::ffi::CString::new(cmd).unwrap_or_else(|_| std::ffi::CString::new("true").unwrap());
+                let args = [sh.as_c_str(), dash_c.as_c_str(), cmd_c.as_c_str()];
+                let _ = nix::unistd::execv(&sh, &args);
+                std::process::exit(1);
+            }
+            Ok(nix::unistd::ForkResult::Parent { .. }) => {}
+            Err(e) => {
+                log::error!("idle: failed to fork for sleep command: {}", e);
+                self.sleeping = false;
+            }
+        }
+    }
+
+    /// `ccectl idle` report.
+    pub fn status(&self) -> String {
+        let idle_s = now_ms().saturating_sub(self.last_activity_ms) / 1000;
+        format!(
+            "display_off={}s sleep={}s command={:?} idle={}s inhibited={} displays_off={} sleeping={}\n",
+            self.display_off_ms / 1000,
+            self.sleep_ms / 1000,
+            self.sleep_command(),
+            idle_s,
+            self.inhibited,
+            self.displays_off,
+            self.sleeping
+        )
+    }
+
+    /// `ccectl idle …` — see `cce_ctl.rs` for the surface.
+    pub unsafe fn ipc(&mut self, args: &[&str]) -> String {
+        match args {
+            [] | ["status"] => self.status(),
+            ["wake"] => {
+                self.on_activity();
+                "ok\n".to_string()
+            }
+            ["display", "off"] => {
+                self.set_displays(false);
+                "ok\n".to_string()
+            }
+            ["display", "on"] => {
+                self.set_displays(true);
+                "ok\n".to_string()
+            }
+            ["sleep"] => {
+                self.sleep_now();
+                "ok\n".to_string()
+            }
+            ["timeouts", display, sleep] => {
+                match (display.parse::<i64>(), sleep.parse::<i64>()) {
+                    (Ok(d), Ok(s)) if d >= 0 && s >= 0 => {
+                        let cfg = IdleConfig { display_off_s: d, sleep_s: s, sleep_command: self.sleep_command.clone() };
+                        self.configure(&cfg);
+                        "ok\n".to_string()
+                    }
+                    _ => "error: idle timeouts <display_off_s> <sleep_s> (non-negative seconds, 0 = off)\n".to_string(),
+                }
+            }
+            _ => "error: usage: idle [status|wake|display on|display off|sleep|timeouts <display_off_s> <sleep_s>]\n".to_string(),
+        }
+    }
+}
+
+unsafe extern "C" fn handle_display_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let idle = &mut *(data as *mut IdleManager);
+    if !idle.inhibited && !idle.displays_off {
+        log::info!("idle: display timeout reached");
+        idle.set_displays(false);
+    }
+    0
+}
+
+unsafe extern "C" fn handle_sleep_timeout(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let idle = &mut *(data as *mut IdleManager);
+    if !idle.inhibited && !idle.sleeping {
+        log::info!("idle: sleep timeout reached");
+        idle.sleep_now();
+    }
+    0
+}
+
+unsafe extern "C" fn handle_session_active(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
+    let idle = &mut *crate::container_of!(listener, IdleManager, session_active);
+    let session = (*idle.server).session;
+    if session.is_null() || !ffi::river_wlr_session_get_active(session) {
+        return;
+    }
+    log::info!("idle: session active (resume / VT switch), waking");
+    idle.on_activity();
+}
diff --git a/src/server/idle_inhibit_manager.rs b/src/server/idle_inhibit_manager.rs
index e2d9d27..84cc135 100644
--- a/src/server/idle_inhibit_manager.rs
+++ b/src/server/idle_inhibit_manager.rs
@@ -75,6 +75,7 @@ impl IdleInhibitManager {
         if !notifier.is_null() {
             ffi::wlr_idle_notifier_v1_set_inhibited(notifier, inhibited);
         }
+        (*self.server).idle.set_inhibited(inhibited);
     }
 }
 
diff --git a/src/server/keyboard_group.rs b/src/server/keyboard_group.rs
index dd3f93b..e89683c 100644
--- a/src/server/keyboard_group.rs
+++ b/src/server/keyboard_group.rs
@@ -337,6 +337,13 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
         return;
     }
 
+    // Keys are activity for the idle timeouts (and the idle-notify clients)
+    // exactly as pointer events are; before 2026-09-16 only tablet, touch
+    // and gestures counted, so idle-notify clients never saw a key.
+    if !group.seat.is_null() {
+        (*group.seat).handle_activity();
+    }
+
     // A real key press ends an emulated view drag (see `cursor::ViewDrag`):
     // the drag holds Space and a pointer button down on the client's behalf,
     // and a key pressed on top of that reaches the app as a chord nobody
diff --git a/src/server/output.rs b/src/server/output.rs
index 02f954d..f7cbfce 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -210,6 +210,9 @@ pub struct Output {
     /// not repeat the atomic TEST_ONLY commit every frame. Cleared when the
     /// fullscreen client's tearing request goes away.
     pub tearing_test_failed: bool,
+    /// Darkened by the idle timeout (`IdleManager::set_displays`), so the
+    /// next activity wakes this one and leaves a client-darkened output alone.
+    pub idle_off: bool,
     pub last_grid_viewport_w: i32,
     pub last_grid_viewport_h: i32,
     pub last_grid_zoom: f64,
@@ -544,6 +547,7 @@ impl Output {
             backdrop_cam: (f64::NAN, f64::NAN, f64::NAN),
             backdrop_measured_at: None,
             tearing_test_failed: false,
+            idle_off: false,
             last_grid_viewport_w: 0,
             last_grid_viewport_h: 0,
             last_grid_zoom: 0.0,
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 0d9e050..f8d3aaf 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -395,6 +395,7 @@ impl Seat {
     }
 
     pub unsafe fn handle_activity(&mut self) {
+        (*self.server).idle.on_activity();
         let notifier = (*self.server).input_manager.idle_notifier;
         if !notifier.is_null() {
             ffi::wlr_idle_notifier_v1_notify_activity(notifier, self.wlr_seat);
diff --git a/src/server/server.rs b/src/server/server.rs
index a95d9f1..e8a4ed1 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -13,6 +13,7 @@ use crate::input_manager::InputManager;
 use crate::libinput_config::LibinputConfig;
 use crate::xkb_config::XkbConfig;
 use crate::idle_inhibit_manager::IdleInhibitManager;
+use crate::idle::IdleManager;
 use crate::lock_manager::LockManager;
 
 // Activation-attention notifications ("<app> needs attention / has requested
@@ -320,6 +321,7 @@ pub struct Server {
     pub libinput_config: LibinputConfig,
     pub xkb_config: XkbConfig,
     pub idle_inhibit_manager: IdleInhibitManager,
+    pub idle: IdleManager,
     pub lock_manager: LockManager,
     pub inspector: crate::inspector::Inspector,
     pub cce_window_management: crate::cce_window_management::CceWindowManagement,
@@ -840,6 +842,7 @@ impl Server {
             self.libinput_config.init(server_ptr).map_err(|_| "Failed to init libinput_config")?;
             self.xkb_config.init(server_ptr).map_err(|_| "Failed to init xkb_config")?;
             self.idle_inhibit_manager.init(server_ptr).map_err(|_| "Failed to init idle_inhibit_manager")?;
+            self.idle.init(server_ptr).map_err(|_| "Failed to init idle")?;
             self.lock_manager.init(server_ptr).map_err(|_| "Failed to init lock_manager")?;
             self.inspector.init(server_ptr).map_err(|_| "Failed to init inspector")?;
             self.cce_window_management.init(server_ptr).map_err(|_| "Failed to init cce_window_management")?;
@@ -911,6 +914,7 @@ impl Server {
             log::info!("[deinit] self.input_manager.deinit finished");
 
             log::info!("[deinit] deinitializing other subcomponents");
+            self.idle.deinit();
             self.idle_inhibit_manager.deinit();
             self.lock_manager.deinit();
             self.layer_shell.deinit();
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 715b1e4..d019ba4 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5184,6 +5184,7 @@ impl WindowManager {
                 self.dirty_windowing();
                 "ok\n".to_string()
             }
+            "idle" => unsafe { (*self.server).idle.ipc(&parts[1..]) },
             "outputs" => {
                 // One line per output: the figures a client's `units::Metric`
                 // is built from (mode, scale, logical size, physical mm) and
@@ -5688,6 +5689,7 @@ impl WindowManager {
                         let next_seat = (*curr_seat).next;
                         let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
                         (*seat).ensure_synthetic_keyboard();
+                        (*seat).handle_activity();
                         ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, crate::util::msec_timestamp(), keycode, state);
                         if let Some(name) = mod_name {
                             let kb = ffi::river_wlr_seat_get_keyboard((*seat).wlr_seat);
@@ -5727,6 +5729,7 @@ impl WindowManager {
                         // without a keymap, and a keymap-less client drops
                         // every key we notify. Attach one first.
                         (*seat).ensure_synthetic_keyboard();
+                        (*seat).handle_activity();
                         let time = crate::util::msec_timestamp();
                         ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, time, keycode, ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED);
                         ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, time + 1, keycode, ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_RELEASED);
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index 5f09ef3..05e9c36 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -9,6 +9,7 @@
 #include <stdio.h>
 
 #include <wlr/util/log.h>
+#include <wlr/backend/session.h>
 
 #define BUFFER_SIZE 1024
 
@@ -1189,3 +1190,11 @@ void river_scene_set_desk_subpixel(struct wlr_scene *scene, double sub_x, double
 	scene->desk_sub_x = sub_x;
 	scene->desk_sub_y = sub_y;
 }
+
+struct wl_signal *river_wlr_session_get_active_signal(struct wlr_session *session) {
+	return &session->events.active;
+}
+
+bool river_wlr_session_get_active(struct wlr_session *session) {
+	return session->active;
+}
diff --git a/wrapper.h b/wrapper.h
index 677d61a..384b0b7 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -121,6 +121,8 @@ struct wl_signal *river_wlr_output_get_present_signal(struct wlr_output *output)
 void *river_wlr_output_get_data(struct wlr_output *output);
 void river_wlr_output_set_data(struct wlr_output *output, void *data);
 const char *river_wlr_output_get_name(struct wlr_output *output);
+struct wl_signal *river_wlr_session_get_active_signal(struct wlr_session *session);
+bool river_wlr_session_get_active(struct wlr_session *session);
 enum wlr_output_adaptive_sync_status river_wlr_output_get_adaptive_sync_status(struct wlr_output *output);
 
 bool river_wlr_output_get_enabled(struct wlr_output *output);