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<HideReason> so the reveal rule can depend on why
the cursor went away.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Drafted with Claude (Anthropic); reviewed and tested by the committer.
This commit is contained in:
Niklas Herder 2026-09-12 09:13:02 +02:00 • committed by Victoria Brekenfeld
parent 4e72d2ce98
commit 569cfa6442
3 changed files with 179 additions and 43 deletions

View file

@ -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<R>(
);
}
/// 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<CursorStateInner>;
pub struct CursorStateInner {
current_cursor: Option<CursorIcon>,
@ -462,9 +490,10 @@ pub struct CursorStateInner {
current_image: Option<Image>,
image_cache: Vec<CachedFrame>,
hidden: bool,
hidden: Option<HideReason>,
idle_timer: Option<RegistrationToken>,
last_armed: Option<Instant>,
last_pointer_activity: Instant,
// shake-to-find
shake_path: VecDeque<PathSample>,
@ -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<R>(
let mut state_ref = seat_userdata.get::<CursorState>().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<R>(
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<State>) -> 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<State>, kind: PointerEventKind) -> bool {
let cursor_state = seat.user_data().get::<CursorState>().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<State>) {
let config = state.common.config.cosmic_conf.cursor_hide;
let loop_handle = &state.common.event_loop_handle;
let cursor_state = seat.user_data().get::<CursorState>().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<State>) {
/// 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<State>) -> bool {
matches!(
seat.get_keyboard().and_then(|kbd| kbd.current_focus()),
Some(KeyboardFocusTarget::Fullscreen(_))
)
}
fn on_idle_timer(state: &mut State, seat: &Seat<State>) -> TimeoutAction {
let config = state.common.config.cosmic_conf.cursor_hide;
let fullscreen = fullscreen_focused(seat);
let cursor_state = seat.user_data().get::<CursorState>().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<State>, reason: HideReason) {
let loop_handle = state.common.event_loop_handle.clone();
let token = {
let cursor_state = seat.user_data().get::<CursorState>().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<State>, reason: HideReason) {
if let Some(ptr) = seat.get_pointer()
&& ptr.is_grabbed()
{
@ -893,7 +999,10 @@ fn hide_cursor(state: &mut State, seat: &Seat<State>) {
let cursor_state = seat.user_data().get::<CursorState>().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>) {
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));
}
}
}

View file

@ -1013,8 +1013,11 @@ fn config_changed(config: cosmic_config::Config, keys: Vec<String>, 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<_> =

View file

@ -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,