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(),