From 28258e5a5fbe17cf714ddf5a0036d5c750545956 Mon Sep 17 00:00:00 2001 From: Niklas Herder Date: Wed, 20 May 2026 23:52:45 +0200 Subject: [PATCH] cursor: Add idle-hide timeout Adds a cursor_hide_timeout config key (Option seconds) to CosmicCompConfig. When set, the cursor is hidden after the configured period of pointer inactivity and revealed again by any pointer event. Touch input does not count as activity (no visible cursor to surface). Implementation: - Per-seat hidden flag, calloop timer token, and last-armed Instant on CursorStateInner. - notify_cursor_activity called from each pointer-related input branch (motion, button, axis, tablet) resets the flag and reschedules the timer; rapid successive calls are coalesced behind a 100ms throttle so high-frequency mice don't churn the calloop timer source. - On timer fire, the hidden flag is set, draw_cursor short-circuits to an empty element list, and a render is scheduled. Active pointer grabs (drags, resizes) suppress the hide. - Config reload arms or cancels the timer immediately; None as the configured value collapses the cancel path into the same function. Closes #2231. Drafted with Claude (Anthropic); reviewed and tested by the committer. --- cosmic-comp-config/src/lib.rs | 3 ++ src/backend/render/cursor.rs | 95 ++++++++++++++++++++++++++++++++++- src/config/mod.rs | 19 +++++++ src/input/mod.rs | 10 +++- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index 6063d476..8c4d9588 100644 --- a/cosmic-comp-config/src/lib.rs +++ b/cosmic-comp-config/src/lib.rs @@ -99,6 +99,8 @@ pub struct CosmicCompConfig { pub edge_snap_threshold: u32, pub accessibility_zoom: ZoomConfig, pub appearance_settings: AppearanceConfig, + /// Hide the cursor after this many seconds of pointer inactivity (None disables) + pub cursor_hide_timeout: Option, } impl Default for CosmicCompConfig { @@ -135,6 +137,7 @@ impl Default for CosmicCompConfig { edge_snap_threshold: 0, accessibility_zoom: ZoomConfig::default(), appearance_settings: AppearanceConfig::default(), + cursor_hide_timeout: None, } } } diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index 76426c5e..3c75996d 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -17,14 +17,25 @@ use smithay::{ Seat, pointer::{CursorIcon, CursorImageAttributes, CursorImageStatus}, }, - reexports::wayland_server::protocol::wl_surface, + reexports::{ + calloop::{ + RegistrationToken, + timer::{TimeoutAction, Timer}, + }, + wayland_server::protocol::wl_surface, + }, render_elements, utils::{ Buffer as BufferCoords, Logical, Monotonic, Physical, Point, Scale, Size, Time, Transform, }, wayland::compositor::{get_role, with_states}, }; -use std::{collections::HashMap, io::Read, sync::Mutex}; +use std::{ + collections::HashMap, + io::Read, + sync::Mutex, + time::{Duration, Instant}, +}; use tracing::warn; use xcursor::{ CursorTheme, @@ -196,6 +207,10 @@ pub struct CursorStateInner { cursors: HashMap, current_image: Option, image_cache: Vec<(Image, MemoryRenderBuffer)>, + + hidden: bool, + idle_timer: Option, + last_armed: Option, } impl CursorStateInner { @@ -246,6 +261,10 @@ impl Default for CursorStateInner { cursors: HashMap::new(), current_image: None, image_cache: Vec::new(), + + hidden: false, + idle_timer: None, + last_armed: None, } } } @@ -271,6 +290,10 @@ where let mut state_ref = seat_userdata.get::().unwrap().lock().unwrap(); let state = &mut *state_ref; + if state.hidden { + return Vec::new(); + } + let named_cursor = state.current_cursor.or(match cursor_status { CursorImageStatus::Named(named_cursor) => Some(named_cursor), _ => None, @@ -335,3 +358,71 @@ where Vec::new() } } + +const ACTIVITY_THROTTLE: Duration = Duration::from_millis(100); + +/// Reveal the cursor and (re)arm the idle-hide timer; returns true if it was previously hidden +pub fn notify_cursor_activity(state: &State, seat: &Seat) -> bool { + let timeout = state.common.config.cosmic_conf.cursor_hide_timeout; + let loop_handle = &state.common.event_loop_handle; + let cursor_state = seat.user_data().get::().unwrap(); + let now = Instant::now(); + + let (was_hidden, old_token) = { + let mut inner = cursor_state.lock().unwrap(); + let was_hidden = inner.hidden; + inner.hidden = false; + + let throttled = timeout.is_some() + && !was_hidden + && inner.idle_timer.is_some() + && inner + .last_armed + .is_some_and(|t| now.duration_since(t) < ACTIVITY_THROTTLE); + if throttled { + return was_hidden; + } + + let old_token = inner.idle_timer.take(); + inner.last_armed = None; + (was_hidden, old_token) + }; + + if let Some(token) = old_token { + loop_handle.remove(token); + } + + if let Some(secs) = timeout { + let timer = Timer::from_duration(Duration::from_secs(secs as u64)); + let seat = seat.clone(); + if let Ok(token) = loop_handle.insert_source(timer, move |_, _, state| { + hide_cursor(state, &seat); + TimeoutAction::Drop + }) { + let mut inner = cursor_state.lock().unwrap(); + inner.idle_timer = Some(token); + inner.last_armed = Some(now); + } + } + + was_hidden +} + +fn hide_cursor(state: &mut State, seat: &Seat) { + if let Some(ptr) = seat.get_pointer() + && ptr.is_grabbed() + { + return; + } + let cursor_state = seat.user_data().get::().unwrap(); + { + let mut inner = cursor_state.lock().unwrap(); + inner.hidden = true; + inner.idle_timer = None; + inner.last_armed = None; + } + let outputs: Vec<_> = state.common.shell.read().outputs().cloned().collect(); + for output in outputs { + state.backend.schedule_render(&output); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 66cb2b83..d75eed4e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -947,6 +947,25 @@ fn config_changed(config: cosmic_config::Config, keys: Vec, state: &mut } } } + "cursor_hide_timeout" => { + let new = get_config::>(&config, "cursor_hide_timeout"); + if new != state.common.config.cosmic_conf.cursor_hide_timeout { + state.common.config.cosmic_conf.cursor_hide_timeout = new; + let seats: Vec<_> = state.common.shell.read().seats.iter().cloned().collect(); + let mut needs_render = false; + for seat in seats { + needs_render |= + crate::backend::render::cursor::notify_cursor_activity(state, &seat); + } + if needs_render { + let outputs: Vec<_> = + state.common.shell.read().outputs().cloned().collect(); + for output in outputs { + state.backend.schedule_render(&output); + } + } + } + } _ => {} } } diff --git a/src/input/mod.rs b/src/input/mod.rs index 69aaccce..548a60ab 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::{ - backend::render::ElementFilter, + backend::render::{ElementFilter, cursor::notify_cursor_activity}, config::{ Action, Config, PrivateAction, key_bindings::{ @@ -309,6 +309,7 @@ impl State { let shell = self.common.shell.write(); if let Some(seat) = shell.seats.for_device(&event.device()).cloned() { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); let current_output = seat.active_output(); let mut position = seat.get_pointer().unwrap().current_location().as_global(); @@ -622,6 +623,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); let output = seat.active_output(); let geometry = output.geometry(); let position = geometry.loc.to_f64() @@ -688,6 +690,7 @@ impl State { return; }; self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); let current_focus = seat.get_keyboard().unwrap().current_focus(); let shortcuts_inhibited = current_focus.as_ref().is_some_and(|f| { @@ -899,6 +902,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); if seat.get_keyboard().unwrap().modifier_state().logo && self @@ -1367,6 +1371,7 @@ impl State { let shell = self.common.shell.write(); if let Some(seat) = shell.seats.for_device(&event.device()).cloned() { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); let Some(output) = mapped_output_for_device(&self.common.config, &shell, &event.device()) .cloned() @@ -1432,6 +1437,7 @@ impl State { let shell = self.common.shell.write(); if let Some(seat) = shell.seats.for_device(&event.device()).cloned() { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); let Some(output) = mapped_output_for_device(&self.common.config, &shell, &event.device()) .cloned() @@ -1493,6 +1499,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); if let Some(tool) = seat.tablet_seat().get_tool(&event.tool()) { match event.tip_state() { TabletToolTipState::Down => { @@ -1515,6 +1522,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); + notify_cursor_activity(self, &seat); if let Some(tool) = seat.tablet_seat().get_tool(&event.tool()) { tool.button( event.button(),