feat: shake cursor to find by magnifying the cursor

This commit is contained in:
Hojjat 2026-07-20 16:11:13 -06:00 • committed by Victoria Brekenfeld
parent 04026259e0
commit f20de50e41
7 changed files with 205 additions and 7 deletions

View file

@ -8,6 +8,7 @@ use crate::{
utils::prelude::*,
wayland::handlers::compositor::FRAME_TIME_FILTER,
};
use keyframe::{ease, functions::EaseInOutCubic};
use smithay::{
backend::{
allocator::Fourcc,
@ -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},
@ -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 {
@ -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) {
@ -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,
}
}
}

View file

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

View file

@ -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()

View file

@ -969,6 +969,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 {

View file

@ -337,6 +337,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)

View file

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