From 569cfa6442571c8d384a296a58f09b8956ad4616 Mon Sep 17 00:00:00 2001 From: Niklas Herder Date: Sat, 12 Sep 2026 09:13:02 +0200 Subject: [PATCH] cursor: re-evaluate the idle timer in context, add fullscreen hiding The timer now decides on fire rather than at arm time, so a shorter fullscreen timeout applies without hooking any of the six fullscreen transition sites in shell/mod.rs. Arming still touches no shell lock, which the PointerMotion call site requires. hidden becomes Option so the reveal rule can depend on why the cursor went away. Co-Authored-By: Claude Sonnet 5 Drafted with Claude (Anthropic); reviewed and tested by the committer. --- src/backend/render/cursor.rs | 194 +++++++++++++++++++++++++++++------ src/config/mod.rs | 7 +- src/input/mod.rs | 21 ++-- 3 files changed, 179 insertions(+), 43 deletions(-) diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index 97959c45..24ad5a60 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -5,9 +5,11 @@ use crate::{ element::AsGlowRenderer, wayland::{SurfaceRenderElement, push_render_elements_from_surface_tree}, }, + shell::focus::target::KeyboardFocusTarget, utils::prelude::*, wayland::handlers::compositor::FRAME_TIME_FILTER, }; +use cosmic_comp_config::HideDecision; use keyframe::{ease, functions::EaseInOutCubic}; use resvg::{tiny_skia, usvg}; use serde::Deserialize; @@ -451,6 +453,32 @@ pub fn draw_dnd_icon( ); } +/// Why the cursor is currently hidden. Determines what brings it back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HideReason { + /// No pointer activity for the configured timeout. + Idle, + /// A key was pressed while `while_typing` is enabled. + Typing, + /// Touch input arrived while `after_touch` is enabled. + Touch, +} + +/// Whether a pointer event carries movement. A touch-hidden cursor comes back +/// only on real movement, so the distinction has to reach the call site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointerEventKind { + Motion, + Other, +} + +impl HideReason { + /// Whether an event of this kind should reveal the cursor. + fn revealed_by(self, kind: PointerEventKind) -> bool { + matches!(kind, PointerEventKind::Motion) || !matches!(self, HideReason::Touch) + } +} + pub type CursorState = Mutex; pub struct CursorStateInner { current_cursor: Option, @@ -462,9 +490,10 @@ pub struct CursorStateInner { current_image: Option, image_cache: Vec, - hidden: bool, + hidden: Option, idle_timer: Option, last_armed: Option, + last_pointer_activity: Instant, // shake-to-find shake_path: VecDeque, @@ -694,7 +723,8 @@ impl Default for CursorStateInner { current_image: None, image_cache: Vec::new(), - hidden: false, + hidden: None, + last_pointer_activity: Instant::now(), idle_timer: None, last_armed: None, @@ -747,7 +777,7 @@ pub fn draw_cursor( let mut state_ref = seat_userdata.get::().unwrap().lock().unwrap(); let state = &mut *state_ref; - if state.hidden { + if state.hidden.is_some() { return; } @@ -837,54 +867,130 @@ pub fn draw_cursor( 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.idle_timeout; +/// Reveal the cursor if this event should, and (re)arm the idle-hide timer. +/// Returns true if the cursor was previously hidden and is now visible. +pub fn notify_cursor_activity(state: &State, seat: &Seat, kind: PointerEventKind) -> bool { + let cursor_state = seat.user_data().get::().unwrap(); + + let revealed = { + let mut inner = cursor_state.lock().unwrap(); + inner.last_pointer_activity = Instant::now(); + match inner.hidden { + Some(reason) if reason.revealed_by(kind) => { + inner.hidden = None; + true + } + // Still hidden on purpose: leave the timer alone. + Some(_) => return false, + None => false, + } + }; + + refresh_idle_timer(state, seat); + revealed +} + +/// (Re)arm the idle-hide timer without counting as pointer activity. +/// +/// Armed with the shortest timeout any context could want: the input that arms +/// the timer is usually what goes on to make a window fullscreen, so the +/// context is not yet knowable here. `on_idle_timer` evaluates it on fire and +/// re-arms for the remainder if nothing applied yet. +pub fn refresh_idle_timer(state: &State, seat: &Seat) { + let config = state.common.config.cosmic_conf.cursor_hide; 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 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() + if inner.hidden.is_some() { + return; + } + // Coalesce bursts from high-frequency pointers. + let throttled = inner.idle_timer.is_some() && inner .last_armed .is_some_and(|t| now.duration_since(t) < ACTIVITY_THROTTLE); if throttled { - return was_hidden; + return; } - - let old_token = inner.idle_timer.take(); inner.last_armed = None; - (was_hidden, old_token) + inner.idle_timer.take() }; 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); - } - } + let Some(delay) = config.shortest_timeout() else { + return; + }; - was_hidden + let timer = Timer::from_duration(delay); + let timer_seat = seat.clone(); + if let Ok(token) = + loop_handle.insert_source(timer, move |_, _, state| on_idle_timer(state, &timer_seat)) + { + let mut inner = cursor_state.lock().unwrap(); + inner.idle_timer = Some(token); + inner.last_armed = Some(now); + } } -fn hide_cursor(state: &mut State, seat: &Seat) { +/// Whether the seat is focusing a fullscreen surface. A fullscreen window that +/// is not focused can be stacked behind others, so its presence on the output +/// says nothing about what the user is looking at. +fn fullscreen_focused(seat: &Seat) -> bool { + matches!( + seat.get_keyboard().and_then(|kbd| kbd.current_focus()), + Some(KeyboardFocusTarget::Fullscreen(_)) + ) +} + +fn on_idle_timer(state: &mut State, seat: &Seat) -> TimeoutAction { + let config = state.common.config.cosmic_conf.cursor_hide; + let fullscreen = fullscreen_focused(seat); + let cursor_state = seat.user_data().get::().unwrap(); + + let elapsed = { + let inner = cursor_state.lock().unwrap(); + Instant::now().duration_since(inner.last_pointer_activity) + }; + + match config.resolve(elapsed, fullscreen) { + HideDecision::Hide => { + hide_cursor(state, seat, HideReason::Idle); + TimeoutAction::Drop + } + HideDecision::Drop => { + let mut inner = cursor_state.lock().unwrap(); + inner.idle_timer = None; + inner.last_armed = None; + TimeoutAction::Drop + } + HideDecision::RearmAfter(delay) => { + cursor_state.lock().unwrap().last_armed = Some(Instant::now()); + TimeoutAction::ToDuration(delay) + } + } +} + +/// Hide the cursor immediately, for a trigger that does not wait on the timer. +pub fn hide_cursor_now(state: &mut State, seat: &Seat, reason: HideReason) { + let loop_handle = state.common.event_loop_handle.clone(); + let token = { + let cursor_state = seat.user_data().get::().unwrap(); + let mut inner = cursor_state.lock().unwrap(); + inner.idle_timer.take() + }; + if let Some(token) = token { + loop_handle.remove(token); + } + hide_cursor(state, seat, reason); +} + +fn hide_cursor(state: &mut State, seat: &Seat, reason: HideReason) { if let Some(ptr) = seat.get_pointer() && ptr.is_grabbed() { @@ -893,7 +999,10 @@ fn hide_cursor(state: &mut State, seat: &Seat) { let cursor_state = seat.user_data().get::().unwrap(); { let mut inner = cursor_state.lock().unwrap(); - inner.hidden = true; + if inner.hidden.is_some() { + return; + } + inner.hidden = Some(reason); inner.idle_timer = None; inner.last_armed = None; } @@ -902,3 +1011,24 @@ fn hide_cursor(state: &mut State, seat: &Seat) { state.backend.schedule_render(&output); } } + +#[cfg(test)] +mod test { + use super::{HideReason, PointerEventKind}; + + #[test] + fn touch_hidden_cursor_waits_for_movement() { + assert!(HideReason::Touch.revealed_by(PointerEventKind::Motion)); + assert!(!HideReason::Touch.revealed_by(PointerEventKind::Other)); + } + + #[test] + fn idle_and_typing_hidden_cursors_are_revealed_by_any_pointer_event() { + for reason in [HideReason::Idle, HideReason::Typing] { + assert!(reason.revealed_by(PointerEventKind::Motion)); + // A click or a scroll must rescue the cursor rather than forcing a + // blind click. + assert!(reason.revealed_by(PointerEventKind::Other)); + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 24c579bc..9bdef7c1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1013,8 +1013,11 @@ fn config_changed(config: cosmic_config::Config, keys: Vec, state: &mut 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); + needs_render |= crate::backend::render::cursor::notify_cursor_activity( + state, + &seat, + crate::backend::render::cursor::PointerEventKind::Motion, + ); } if needs_render { let outputs: Vec<_> = diff --git a/src/input/mod.rs b/src/input/mod.rs index bbc6a931..05fa1205 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,7 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-only use crate::{ - backend::render::{ElementFilter, cursor::notify_cursor_activity}, + backend::render::{ + ElementFilter, + cursor::{PointerEventKind, notify_cursor_activity}, + }, config::{ Action, Config, PrivateAction, key_bindings::{ @@ -349,7 +352,7 @@ impl State { .cloned() { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Motion); let current_output = seat.active_output(); if self.common.config.cosmic_conf.cursor_shake_to_find @@ -723,7 +726,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Motion); let (output, position) = if matches!(&backend_id, InputBackendId::Ei(_)) { // EI absolute coordinates are in the compositor's *global* // logical space: each advertised region carries its output's @@ -818,7 +821,7 @@ impl State { return; }; self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Other); let current_focus = seat.get_keyboard().unwrap().current_focus(); let shortcuts_inhibited = current_focus.as_ref().is_some_and(|f| { @@ -1069,7 +1072,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Other); if self.source_modifiers(&backend_id, &seat).logo && self @@ -1608,7 +1611,7 @@ impl State { .cloned() { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Motion); let Some(output) = mapped_output_for_device(&self.common.config, &shell, &event.device()) .cloned() @@ -1712,7 +1715,7 @@ impl State { .cloned() { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Motion); let Some(output) = mapped_output_for_device(&self.common.config, &shell, &event.device()) .cloned() @@ -1861,7 +1864,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Other); let serial = SERIAL_COUNTER.next_serial(); let output = seat.active_output(); @@ -1914,7 +1917,7 @@ impl State { .cloned(); if let Some(seat) = maybe_seat { self.common.idle_notifier_state.notify_activity(&seat); - notify_cursor_activity(self, &seat); + notify_cursor_activity(self, &seat, PointerEventKind::Other); if let Some(tool) = seat.tablet_seat().get_tool(&event.tool()) { tool.button( self,