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 {