Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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);