Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cosmic-comp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ pub struct CosmicCompConfig {
pub appearance_settings: AppearanceConfig,
/// Hide the cursor after this many seconds of pointer inactivity (None disables)
pub cursor_hide_timeout: Option<u32>,
/// Briefly magnify the cursor when the pointer is shaken, to help locate it
pub cursor_shake_to_find: bool,
pub activation_policy: ActivationPolicy,
}

Expand Down Expand Up @@ -139,6 +141,7 @@ impl Default for CosmicCompConfig {
accessibility_zoom: ZoomConfig::default(),
appearance_settings: AppearanceConfig::default(),
cursor_hide_timeout: None,
cursor_shake_to_find: true,
activation_policy: ActivationPolicy::default(),
}
}
Expand Down
158 changes: 157 additions & 1 deletion src/backend/render/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::{
utils::prelude::*,
wayland::handlers::compositor::FRAME_TIME_FILTER,
};
use keyframe::{ease, functions::EaseInOutCubic};
use smithay::{
backend::{
allocator::Fourcc,
Expand Down Expand Up @@ -38,7 +39,7 @@ use smithay::{
wayland::compositor::{get_role, with_states},
};
use std::{
collections::HashMap,
collections::{HashMap, VecDeque},
io::Read,
sync::Mutex,
time::{Duration, Instant},
Expand Down Expand Up @@ -270,6 +271,43 @@ pub struct CursorStateInner {
hidden: bool,
idle_timer: Option<RegistrationToken>,
last_armed: Option<Instant>,

// shake-to-find
shake_path: VecDeque<PathSample>,
shake_path_position: Point<f64, Logical>,
magnify_until: Option<Instant>,
magnify_target: f32,
magnification: f32,
anim_from: f32,
anim_start: Option<Instant>,
}

/// One sampled pointer position on the recent motion path.
#[derive(Clone, Copy)]
struct PathSample {
position: Point<f64, Logical>,
time: Instant,
}

/// How far back the motion path is considered when looking for a shake.
const SHAKE_INTERVAL: Duration = Duration::from_millis(1000);
/// Path-length / bounding-box-diagonal ratio required to count as a shake.
const SHAKE_SENSITIVITY: f64 = 4.0;
/// Minimum bounding-box diagonal (logical px) before a shake is considered.
const SHAKE_DIAGONAL_MIN: f64 = 100.0;
/// Two deltas count as "the same direction" if both lie within this tolerance.
const SHAKE_SAME_SIGN_TOLERANCE: f64 = 1.0;
/// Keep the cursor enlarged for this long after the last detected shake.
const SHAKE_HOLD: Duration = Duration::from_millis(2000);
/// Extra magnification added by each shake, growing from the normal cursor size.
const OVER_MAGNIFICATION: f32 = 1.0;
/// Duration of the grow/shrink animation.
const MAGNIFICATION_ANIM: Duration = Duration::from_millis(200);

/// small movement is ignored and direction stays the same
fn same_direction(a: f64, b: f64) -> bool {
(a >= -SHAKE_SAME_SIGN_TOLERANCE && b >= -SHAKE_SAME_SIGN_TOLERANCE)
|| (a <= SHAKE_SAME_SIGN_TOLERANCE && b <= SHAKE_SAME_SIGN_TOLERANCE)
}

impl CursorStateInner {
Expand All @@ -290,6 +328,116 @@ impl CursorStateInner {
pub fn size(&self) -> u32 {
self.cursor_size
}

/// Feed one relative-motion event into the shake detector.
pub fn detect_shake(&mut self, delta: Point<f64, Logical>, now: Instant) {
// Drop samples that have aged out of the time window.
while let Some(oldest) = self.shake_path.front() {
if now.duration_since(oldest.time) >= SHAKE_INTERVAL {
self.shake_path.pop_front();
} else {
break;
}
}

if delta.x != 0.0 || delta.y != 0.0 {
self.shake_path_position += delta;
let sample = PathSample {
position: self.shake_path_position,
time: now,
};

if self.shake_path.len() >= 2 {
let last = self.shake_path[self.shake_path.len() - 1].position;
let prev = self.shake_path[self.shake_path.len() - 2].position;
let last_delta = last - prev;
if same_direction(last_delta.x, delta.x) && same_direction(last_delta.y, delta.y) {
*self.shake_path.back_mut().unwrap() = sample;
} else {
self.shake_path.push_back(sample);
}
} else {
self.shake_path.push_back(sample);
}
}

if self.shake_path.len() < 2 {
return;
}

let first = self.shake_path[0].position;
let (mut left, mut top, mut right, mut bottom) = (first.x, first.y, first.x, first.y);
let mut path_length = 0.0;
for i in 1..self.shake_path.len() {
let p = self.shake_path[i].position;
left = left.min(p.x);
top = top.min(p.y);
right = right.max(p.x);
bottom = bottom.max(p.y);

let step = p - self.shake_path[i - 1].position;
path_length += step.x.hypot(step.y);
}

let diagonal = (right - left).hypot(bottom - top);
if diagonal < SHAKE_DIAGONAL_MIN {
return;
}

// Path noticeably longer than the diagonal => a shake gesture.
if path_length / diagonal > SHAKE_SENSITIVITY {
self.grow(now);
self.shake_path.clear();
}
}

/// grow the cursor by one more increment (unbounded)
fn grow(&mut self, now: Instant) {
self.animate_to(self.magnify_target + OVER_MAGNIFICATION, now);
self.magnify_until = Some(now + SHAKE_HOLD);
}

/// Start a 200ms `InOutCubic` tween from the current size to `target`.
fn animate_to(&mut self, target: f32, now: Instant) {
if (target - self.magnify_target).abs() < f32::EPSILON {
return;
}
self.anim_from = self.magnification;
self.anim_start = Some(now);
self.magnify_target = target;
}

/// Advance the magnification animation and return the current factor.
pub fn animated_magnification(&mut self, now: Instant) -> f32 {
// Begin shrinking back once the hold window elapses.
if let Some(until) = self.magnify_until
&& now >= until
{
self.magnify_until = None;
self.animate_to(1.0, now);
}

self.magnification = match self.anim_start {
Some(start) => {
// `ease` clamps the time to `0.0..=1.0` for us.
let t = now.duration_since(start).as_secs_f32() / MAGNIFICATION_ANIM.as_secs_f32();
if t >= 1.0 {
self.anim_start = None;
}
ease(EaseInOutCubic, self.anim_from, self.magnify_target, t)
}
None => self.magnify_target,
};
self.magnification
}

/// Whether the cursor is currently magnified or pending; drives continued redraws.
pub fn is_magnifying(&self) -> bool {
self.magnify_until.is_some()
|| self.anim_start.is_some()
|| self.magnification > 1.001
|| self.magnify_target > 1.001
}
}

pub fn load_cursor_env() -> (String, u32) {
Expand Down Expand Up @@ -324,6 +472,14 @@ impl Default for CursorStateInner {
hidden: false,
idle_timer: None,
last_armed: None,

shake_path: VecDeque::new(),
shake_path_position: Point::from((0.0, 0.0)),
magnify_until: None,
magnify_target: 1.0,
magnification: 1.0,
anim_from: 1.0,
anim_start: None,
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/backend/render/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ where
Workspace(
RelocateRenderElement<CropRenderElement<RescaleRenderElement<WorkspaceRenderElement<R>>>>,
),
Cursor(RescaleRenderElement<RelocateRenderElement<CursorRenderElement<R>>>),
Cursor(
RescaleRenderElement<RescaleRenderElement<RelocateRenderElement<CursorRenderElement<R>>>>,
),
Dnd(SurfaceRenderElement<R>),
MoveGrab(RescaleRenderElement<CosmicMappedRenderElement<R>>),
Postprocess(
Expand Down
23 changes: 18 additions & 5 deletions src/backend/render/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,22 +510,35 @@ pub fn cursor_elements<'a, 'frame, R>(
};
let location = pointer.current_location() - output.current_location().to_f64();

// Shake-to-find magnification, applied around the pointer tip.
let cursor_magnification = seat
.user_data()
.get::<cursor::CursorState>()
.map_or(1.0, |s| {
s.lock().unwrap().animated_magnification(Instant::now())
});
let cursor_center = location.to_physical(scale).to_i32_round();

if mode != CursorMode::None {
cursor::draw_cursor(
renderer,
seat,
location,
scale.into(),
zoom_scale,
zoom_scale * cursor_magnification as f64,
now,
blur_strength,
mode != CursorMode::NotDefault,
&mut |elem, hotspot| {
push(CosmicElement::Cursor(RescaleRenderElement::from_element(
RelocateRenderElement::from_element(
elem,
Point::from((-hotspot.x, -hotspot.y)),
Relocate::Relative,
RescaleRenderElement::from_element(
RelocateRenderElement::from_element(
elem,
Point::from((-hotspot.x, -hotspot.y)),
Relocate::Relative,
),
cursor_center,
cursor_magnification as f64,
),
focal_point
.as_logical()
Expand Down
4 changes: 4 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,10 @@ fn config_changed(config: cosmic_config::Config, keys: Vec<String>, state: &mut
}
}
}
"cursor_shake_to_find" => {
let new = get_config::<bool>(&config, "cursor_shake_to_find");
state.common.config.cosmic_conf.cursor_shake_to_find = new;
}
"cursor_hide_timeout" => {
let new = get_config::<Option<u32>>(&config, "cursor_hide_timeout");
if new != state.common.config.cosmic_conf.cursor_hide_timeout {
Expand Down
15 changes: 15 additions & 0 deletions src/input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,21 @@ impl State {
notify_cursor_activity(self, &seat);
let current_output = seat.active_output();

if self.common.config.cosmic_conf.cursor_shake_to_find
&& let Some(cursor_state) =
seat.user_data()
.get::<crate::backend::render::cursor::CursorState>()
{
let active = {
let mut cursor = cursor_state.lock().unwrap();
cursor.detect_shake(event.delta(), std::time::Instant::now());
cursor.is_magnifying()
};
if active {
self.backend.schedule_render(&current_output);
}
}

let mut position = seat.get_pointer().unwrap().current_location().as_global();

let under = State::surface_under(position, &current_output, &shell)
Expand Down
5 changes: 5 additions & 0 deletions src/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2286,6 +2286,11 @@ impl Shell {
.is_some_and(|state| state.lock().unwrap().is_animating())
})
})
|| self.seats.iter().any(|seat| {
seat.user_data()
.get::<crate::backend::render::cursor::CursorState>()
.is_some_and(|state| state.lock().unwrap().is_magnifying())
})
}

pub fn update_animations(&mut self) -> HashMap<ClientId, Client> {
Expand Down
Loading