From 6e0aea0eb2832f4f92f46a26dbfc85f3a1235495 Mon Sep 17 00:00:00 2001 From: Niklas Herder Date: Sat, 12 Sep 2026 08:59:31 +0200 Subject: [PATCH] config: group cursor hiding into CursorHideConfig Replaces the flat cursor_hide_timeout key with a grouped cursor_hide key carrying the idle, fullscreen-idle, typing and touch triggers, plus the pure decision function the compositor's idle timer evaluates on fire. The fullscreen timeout shortens the global one rather than replacing it, so an unset fullscreen timeout cannot suppress an enabled global one. Drafted with Claude (Anthropic); reviewed and tested by the committer. --- cosmic-comp-config/src/lib.rs | 164 +++++++++++++++++++++++++++++++++- src/backend/render/cursor.rs | 2 +- src/config/mod.rs | 15 ++-- 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index 510927f9..04415d5f 100644 --- a/cosmic-comp-config/src/lib.rs +++ b/cosmic-comp-config/src/lib.rs @@ -3,6 +3,7 @@ use cosmic_config::{CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::time::Duration; use crate::input::TouchpadOverride; @@ -106,8 +107,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, + /// When the cursor hides itself: idle, fullscreen idle, typing, touch + pub cursor_hide: CursorHideConfig, /// Briefly magnify the cursor when the pointer is shaken, to help locate it pub cursor_shake_to_find: bool, pub activation_policy: ActivationPolicy, @@ -148,7 +149,7 @@ impl Default for CosmicCompConfig { edge_snap_threshold: 0, accessibility_zoom: ZoomConfig::default(), appearance_settings: AppearanceConfig::default(), - cursor_hide_timeout: None, + cursor_hide: CursorHideConfig::default(), cursor_shake_to_find: true, activation_policy: ActivationPolicy::default(), decoration_preference: DecorationPreference::default(), @@ -198,6 +199,74 @@ fn default_repeat_delay() -> u32 { 600 } +/// What the cursor idle timer should do when it fires. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HideDecision { + /// Nothing applies in this context; drop the timer until the next activity. + Drop, + /// Hide the cursor now. + Hide, + /// Not yet — re-arm for this long. + RearmAfter(Duration), +} + +/// When the cursor hides itself. Every trigger is revealed by pointer input, so +/// these differ only in what arms the hide and after how long. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct CursorHideConfig { + /// Seconds of pointer inactivity before hiding, anywhere. `None` disables. + pub idle_timeout: Option, + /// Seconds of pointer inactivity before hiding over a fullscreen window. + /// Shortens `idle_timeout` in fullscreen; it never lengthens it. + pub fullscreen_idle_timeout: Option, + /// Hide as soon as a key is pressed. + pub while_typing: bool, + /// Hide on touch input, until the pointer next moves. + pub after_touch: bool, +} + +impl CursorHideConfig { + /// The shortest timeout that applies right now, if any. + pub fn effective_timeout(&self, fullscreen: bool) -> Option { + let secs = if fullscreen { + match (self.idle_timeout, self.fullscreen_idle_timeout) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } + } else { + self.idle_timeout + }?; + Some(Duration::from_secs(secs as u64)) + } + + /// What to do when the idle timer fires after `elapsed` without pointer input. + pub fn resolve(&self, elapsed: Duration, fullscreen: bool) -> HideDecision { + match self.effective_timeout(fullscreen) { + None => HideDecision::Drop, + Some(timeout) if elapsed >= timeout => HideDecision::Hide, + Some(timeout) => HideDecision::RearmAfter(timeout - elapsed), + } + } + + /// The delay to arm the timer with, before context is known. Callers on the + /// input path cannot read fullscreen state without deadlocking, so they arm + /// pessimistically and `resolve` corrects it on fire. + pub fn shortest_timeout(&self) -> Option { + self.effective_timeout(true) + } +} + +impl Default for CursorHideConfig { + fn default() -> Self { + CursorHideConfig { + idle_timeout: None, + fullscreen_idle_timeout: Some(3), + while_typing: false, + after_touch: true, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] pub struct ZoomConfig { pub start_on_login: bool, @@ -263,3 +332,92 @@ pub enum XwaylandDescaling { #[default] Fractional, } + +#[cfg(test)] +mod test { + use super::{CursorHideConfig, HideDecision}; + use std::time::Duration; + + const OFF: CursorHideConfig = CursorHideConfig { + idle_timeout: None, + fullscreen_idle_timeout: None, + while_typing: false, + after_touch: false, + }; + + fn secs(n: u64) -> Duration { + Duration::from_secs(n) + } + + #[test] + fn no_timeout_never_hides() { + assert_eq!(OFF.resolve(secs(600), false), HideDecision::Drop); + assert_eq!(OFF.resolve(secs(600), true), HideDecision::Drop); + } + + #[test] + fn global_timeout_applies_everywhere() { + let cfg = CursorHideConfig { + idle_timeout: Some(10), + ..OFF + }; + assert_eq!( + cfg.resolve(secs(4), false), + HideDecision::RearmAfter(secs(6)) + ); + assert_eq!(cfg.resolve(secs(10), false), HideDecision::Hide); + // A fullscreen window must not suppress an enabled global timeout. + assert_eq!(cfg.resolve(secs(10), true), HideDecision::Hide); + } + + #[test] + fn fullscreen_timeout_only_applies_in_fullscreen() { + let cfg = CursorHideConfig { + fullscreen_idle_timeout: Some(3), + ..OFF + }; + assert_eq!(cfg.resolve(secs(3), true), HideDecision::Hide); + assert_eq!(cfg.resolve(secs(600), false), HideDecision::Drop); + } + + #[test] + fn fullscreen_shortens_but_never_lengthens() { + let short_fs = CursorHideConfig { + idle_timeout: Some(10), + fullscreen_idle_timeout: Some(3), + ..OFF + }; + assert_eq!(short_fs.resolve(secs(3), true), HideDecision::Hide); + assert_eq!( + short_fs.resolve(secs(3), false), + HideDecision::RearmAfter(secs(7)) + ); + + let long_fs = CursorHideConfig { + idle_timeout: Some(3), + fullscreen_idle_timeout: Some(30), + ..OFF + }; + assert_eq!(long_fs.resolve(secs(3), true), HideDecision::Hide); + + // The arming delay is pessimistic: the shortest timeout that could + // apply in any context, because arm-time code cannot read the shell. + assert_eq!(short_fs.shortest_timeout(), Some(secs(3))); + assert_eq!(long_fs.shortest_timeout(), Some(secs(3))); + assert_eq!(OFF.shortest_timeout(), None); + } + + #[test] + fn boundary_is_inclusive() { + let cfg = CursorHideConfig { + idle_timeout: Some(5), + ..OFF + }; + assert_eq!( + cfg.resolve(Duration::from_millis(4999), false), + HideDecision::RearmAfter(Duration::from_millis(1)) + ); + assert_eq!(cfg.resolve(secs(5), false), HideDecision::Hide); + assert_eq!(cfg.resolve(secs(6), false), HideDecision::Hide); + } +} diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index 8e14d1be..97959c45 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -839,7 +839,7 @@ 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 timeout = state.common.config.cosmic_conf.cursor_hide.idle_timeout; let loop_handle = &state.common.event_loop_handle; let cursor_state = seat.user_data().get::().unwrap(); let now = Instant::now(); diff --git a/src/config/mod.rs b/src/config/mod.rs index f8a51585..714dd29b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -49,8 +49,8 @@ mod types; use cosmic::config::CosmicTk; pub use cosmic_comp_config::EdidProduct; use cosmic_comp_config::{ - ActivationPolicy, AppearanceConfig, CosmicCompConfig, DecorationPreference, KeyboardConfig, - TileBehavior, XkbConfig, XwaylandDescaling, XwaylandEavesdropping, ZoomConfig, + ActivationPolicy, AppearanceConfig, CosmicCompConfig, CursorHideConfig, DecorationPreference, + KeyboardConfig, TileBehavior, XkbConfig, XwaylandDescaling, XwaylandEavesdropping, ZoomConfig, input::{DeviceState as InputDeviceState, InputConfig, TouchpadOverride}, output::comp::{ OutputConfig, OutputInfo, OutputState, OutputsConfig, TransformDef, load_outputs, @@ -991,10 +991,13 @@ fn config_changed(config: cosmic_config::Config, keys: Vec, state: &mut let new = get_config::(&config, "cursor_shake_to_find"); state.common.config.cosmic_conf.cursor_shake_to_find = new; } - "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; + "cursor_hide" => { + let new = get_config::(&config, "cursor_hide"); + if new != state.common.config.cosmic_conf.cursor_hide { + state.common.config.cosmic_conf.cursor_hide = new; + // Reveal on every change: a visible cursor is the safe state, + // and it avoids stranding a hidden cursor when the trigger + // that hid it is switched off. let seats: Vec<_> = state.common.shell.read().seats.iter().cloned().collect(); let mut needs_render = false; for seat in seats {