From 04026259e0707e6c2f2bb0f26e113e48c5cb19fd Mon Sep 17 00:00:00 2001 From: chris-010 <10660568+chris-010@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:36:41 +0200 Subject: [PATCH 1/7] fix: treat unlocking the session as user activity SessionLockHandler::unlock clears the lock and schedules a render, but never tells IdleNotifierState that the user is there. Unlock paths that produce no input event -- fingerprint and smartcard readers -- therefore leave the idle clock running across the entire lock period, and it is already past its timeouts by the time the session is unlocked. The visible result is that the screen stays off after a successful unlock until the user moves the mouse or presses a key, or that the session locks itself again seconds after being unlocked. Notifying activity for every seat in unlock() resets the timers the same way an input event would. Drafted with AI assistance (Claude Code); the change was reviewed, built and tested by me on real hardware as described. Signed-off-by: chris-010 <10660568+chris-010@users.noreply.github.com> --- src/wayland/handlers/session_lock.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wayland/handlers/session_lock.rs b/src/wayland/handlers/session_lock.rs index 39e1e375..c4e93ec5 100644 --- a/src/wayland/handlers/session_lock.rs +++ b/src/wayland/handlers/session_lock.rs @@ -46,6 +46,11 @@ impl SessionLockHandler for State { let mut shell = self.common.shell.write(); shell.session_lock = None; + let seats = shell.seats.iter().cloned().collect::>(); + for seat in &seats { + self.common.idle_notifier_state.notify_activity(seat); + } + for output in shell.outputs() { self.backend.schedule_render(output); } From f20de50e4124c14d55aa72ca105b59e743edae8d Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 20 Jul 2026 16:11:13 -0600 Subject: [PATCH 2/7] feat: shake cursor to find by magnifying the cursor --- cosmic-comp-config/src/lib.rs | 3 + src/backend/render/cursor.rs | 158 +++++++++++++++++++++++++++++++++- src/backend/render/element.rs | 4 +- src/backend/render/mod.rs | 23 +++-- src/config/mod.rs | 4 + src/input/mod.rs | 15 ++++ src/shell/mod.rs | 5 ++ 7 files changed, 205 insertions(+), 7 deletions(-) diff --git a/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index e5e73883..864d6737 100644 --- a/cosmic-comp-config/src/lib.rs +++ b/cosmic-comp-config/src/lib.rs @@ -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, + /// Briefly magnify the cursor when the pointer is shaken, to help locate it + pub cursor_shake_to_find: bool, pub activation_policy: ActivationPolicy, } @@ -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(), } } diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index da01b270..30243dad 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -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, last_armed: Option, + + // shake-to-find + shake_path: VecDeque, + shake_path_position: Point, + magnify_until: Option, + magnify_target: f32, + magnification: f32, + anim_from: f32, + anim_start: Option, +} + +/// One sampled pointer position on the recent motion path. +#[derive(Clone, Copy)] +struct PathSample { + position: Point, + 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, 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, } } } diff --git a/src/backend/render/element.rs b/src/backend/render/element.rs index e1259dc1..cd2fb5c2 100644 --- a/src/backend/render/element.rs +++ b/src/backend/render/element.rs @@ -41,7 +41,9 @@ where Workspace( RelocateRenderElement>>>, ), - Cursor(RescaleRenderElement>>), + Cursor( + RescaleRenderElement>>>, + ), Dnd(SurfaceRenderElement), MoveGrab(RescaleRenderElement>), Postprocess( diff --git a/src/backend/render/mod.rs b/src/backend/render/mod.rs index e9cf651f..b348640f 100644 --- a/src/backend/render/mod.rs +++ b/src/backend/render/mod.rs @@ -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::() + .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() diff --git a/src/config/mod.rs b/src/config/mod.rs index 4a2500fd..93b53374 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -969,6 +969,10 @@ fn config_changed(config: cosmic_config::Config, keys: Vec, state: &mut } } } + "cursor_shake_to_find" => { + 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 { diff --git a/src/input/mod.rs b/src/input/mod.rs index 67780d53..0e307079 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -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::() + { + 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(¤t_output); + } + } + let mut position = seat.get_pointer().unwrap().current_location().as_global(); let under = State::surface_under(position, ¤t_output, &shell) diff --git a/src/shell/mod.rs b/src/shell/mod.rs index d9f95976..0baa3713 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -2286,6 +2286,11 @@ impl Shell { .is_some_and(|state| state.lock().unwrap().is_animating()) }) }) + || self.seats.iter().any(|seat| { + seat.user_data() + .get::() + .is_some_and(|state| state.lock().unwrap().is_magnifying()) + }) } pub fn update_animations(&mut self) -> HashMap { From 738e0b312497dec066a611dc5ea98b75df39058e Mon Sep 17 00:00:00 2001 From: Tom Grushka Date: Wed, 22 Jul 2026 00:50:14 -0600 Subject: [PATCH 3/7] a11y: magnifier: change zoom stepping to multiplicative, bound max, add presets 1. Change magnifier zoom stepping from additive to multiplicative, because zoom is a ratio, not a difference, and human perception is logarithmic (browser zoom steps, map zoom levels, microscope powers). 2. Add `pub const ZoomConfig::ZOOM_INCREMENT_PRESETS` with two additional presets: 10% (macOS, Cinnamon) and 75% (for users at 70-90x zoom). 3. Bound the zoom level to a generous maximum, beyond what anyone is expected to need, to prevent runaway / unrecoverable levels (#1230). Full background and rationale at #2615. I used AI to assist with research and issue writeup. Code is 100% mine. - Closes #2615. - Fixes #1239. - Partially addresses #1230 and #1237. --- cosmic-comp-config/src/lib.rs | 4 ++++ src/input/actions.rs | 10 +++++++++- src/shell/zoom.rs | 6 +++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index 864d6737..853ba447 100644 --- a/cosmic-comp-config/src/lib.rs +++ b/cosmic-comp-config/src/lib.rs @@ -198,6 +198,10 @@ pub struct ZoomConfig { pub enable_mouse_zoom_shortcuts: bool, } +impl ZoomConfig { + pub const ZOOM_INCREMENT_PRESETS: &[u32] = &[10, 25, 50, 75, 100, 150, 200]; +} + impl Default for ZoomConfig { fn default() -> Self { ZoomConfig { diff --git a/src/input/actions.rs b/src/input/actions.rs index a4932597..adabb8a6 100644 --- a/src/input/actions.rs +++ b/src/input/actions.rs @@ -29,6 +29,8 @@ use std::{os::unix::process::CommandExt, thread}; use super::gestures; +const MAX_ZOOM: f64 = 256.0; + fn propagate_by_default(action: &shortcuts::Action) -> bool { matches!( action, @@ -1116,7 +1118,13 @@ impl State { } if zoom_seat == *seat { - let new_level = (current_level + change).max(1.0); + let factor = 1.0 + change.abs(); + let new_level = if change < 0. { + current_level / factor + } else { + current_level * factor + } + .clamp(1.0, MAX_ZOOM); shell.trigger_zoom( seat, Some(&output), diff --git a/src/shell/zoom.rs b/src/shell/zoom.rs index ef81053f..0c0e9340 100644 --- a/src/shell/zoom.rs +++ b/src/shell/zoom.rs @@ -7,7 +7,7 @@ use cosmic::{ theme, widget::{self, icon::Named}, }; -use cosmic_comp_config::ZoomMovement; +use cosmic_comp_config::{ZoomConfig, ZoomMovement}; use cosmic_config::ConfigSet; use keyframe::{ease, functions::Linear}; use smithay::{ @@ -447,7 +447,7 @@ pub enum MenuMessage { impl ZoomProgram { pub fn new(level: f64, movement: ZoomMovement, increment: u32) -> Self { - let mut increments = vec![25, 50, 100, 150, 200]; + let mut increments = ZoomConfig::ZOOM_INCREMENT_PRESETS.to_vec(); if !increments.contains(&increment) { increments.push(increment); } @@ -828,7 +828,7 @@ impl Program for ZoomProgram { if let Some(pos) = self.increments.iter().position(|val| *val == increment) { self.increment_idx = pos; } else { - let mut increments = vec![25, 50, 100, 150, 200]; + let mut increments = ZoomConfig::ZOOM_INCREMENT_PRESETS.to_vec(); if !increments.contains(&increment) { increments.push(increment); } From 8304b18886cd94c6ecf16bf0215c78f8c2d7e5cc Mon Sep 17 00:00:00 2001 From: Tom Grushka <89836285+tgrushka@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:47:53 -0600 Subject: [PATCH 4/7] Clamp zoom level to 1.0 if < 1.01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix floating point edge cases that keep zoom enabled when level ≈ 1.0 Co-authored-by: Luuk van der Duim --- src/input/actions.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/input/actions.rs b/src/input/actions.rs index adabb8a6..8a5ef84c 100644 --- a/src/input/actions.rs +++ b/src/input/actions.rs @@ -1125,6 +1125,7 @@ impl State { current_level * factor } .clamp(1.0, MAX_ZOOM); + let new_level = if new_level < 1.01 { 1.0 } else { new_level }; shell.trigger_zoom( seat, Some(&output), From 01b79c3eae39c080b525b15d8ba3088ada9e8591 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Fri, 31 Jul 2026 14:46:52 -0600 Subject: [PATCH 5/7] feat: scalable cursor support --- Cargo.lock | 5 +- Cargo.toml | 1 + src/backend/render/cursor.rs | 301 ++++++++++++++++++++++++++++++----- 3 files changed, 262 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f2364e9e..b8be66ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -817,6 +817,7 @@ dependencies = [ "profiling", "rand 0.10.0", "regex", + "resvg 0.45.1", "ron 0.12.0", "rust-embed", "rustix 1.1.4", @@ -6893,9 +6894,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xdg" diff --git a/Cargo.toml b/Cargo.toml index 7d4ff3af..d807d369 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ log-panics = { version = "2", features = ["with-backtrace"] } ordered-float = "5.1" png = "0.18" regex = "1" +resvg = "0.45" ron = "0.12" rust-embed = { version = "8.11", features = ["debug-embed"] } sanitize-filename = "0.6.0" diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index 30243dad..97055a3d 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -9,6 +9,8 @@ use crate::{ wayland::handlers::compositor::FRAME_TIME_FILTER, }; use keyframe::{ease, functions::EaseInOutCubic}; +use resvg::{tiny_skia, usvg}; +use serde::Deserialize; use smithay::{ backend::{ allocator::Fourcc, @@ -52,14 +54,43 @@ use xcursor::{ static FALLBACK_CURSOR_DATA: &[u8] = include_bytes!("../../../resources/cursor.rgba"); +/// A single frame of a scalable SVG cursor: the parsed SVG plus its metadata. +#[derive(Debug, Clone)] +struct SvgFrame { + tree: usvg::Tree, + /// The nominal (logical) size the SVG is authored for. + nominal_size: f32, + /// Hotspot coordinates, in the SVG's own (nominal) coordinate space. + hotspot_x: f32, + hotspot_y: f32, + /// Delay to the next frame in milliseconds (0 for static cursors). + delay: u32, +} + +#[derive(Debug, Clone)] +enum CursorKind { + /// Legacy raster XCursor frames. + Xcursor(Vec), + /// Scalable SVG frames, rasterized on demand. + Svg(Vec), +} + #[derive(Debug, Clone)] pub struct Cursor { - icons: Vec, + kind: CursorKind, size: u32, } impl Cursor { pub fn load(theme: &CursorTheme, shape: CursorIcon, size: u32) -> Cursor { + // Prefer a scalable SVG cursor when the theme provides one. + if let Some(frames) = load_svg_icon(theme, shape) { + return Cursor { + kind: CursorKind::Svg(frames), + size, + }; + } + let icons = load_icon(theme, shape) .map_err(|err| warn!(?err, "Unable to load xcursor, using fallback cursor")) .or_else(|_| load_icon(theme, CursorIcon::Default)) @@ -76,44 +107,134 @@ impl Cursor { }] }); - Cursor { icons, size } + Cursor { + kind: CursorKind::Xcursor(icons), + size, + } } pub fn get_image(&self, scale: u32, millis: u32) -> Image { let size = self.size * scale; - frame(millis, size, &self.icons) + let idx = self.frame_index(size, millis); + self.render_frame(size, idx) + } + + /// Selects the index of the frame to display at nominal size `size` (in px) + /// and elapsed `millis`. + fn frame_index(&self, size: u32, millis: u32) -> usize { + match &self.kind { + CursorKind::Xcursor(images) => xcursor_frame_index(millis, size, images), + CursorKind::Svg(frames) => svg_frame_index(millis, frames), + } + } + + /// Produces the RGBA image for frame `idx`, rasterizing SVG cursors at the + /// requested nominal size `size` (in px). + fn render_frame(&self, size: u32, idx: usize) -> Image { + match &self.kind { + CursorKind::Xcursor(images) => images[idx].clone(), + CursorKind::Svg(frames) => rasterize_svg_frame(&frames[idx], size), + } } } -fn nearest_images(size: u32, images: &[Image]) -> impl Iterator { - // Follow the nominal size of the cursor to choose the nearest +/// Rasterize a scalable cursor frame at nominal pixel size `size`. Per the KDE +/// SVG cursor format, the SVG canvas and hotspot are scaled by +/// `size / nominal_size`. The returned [`Image`] uses the same premultiplied +/// byte order (BGRA / little-endian ARGB) as xcursor images. +fn rasterize_svg_frame(frame: &SvgFrame, size: u32) -> Image { + let factor = size as f32 / frame.nominal_size; + let svg_size = frame.tree.size(); + let width = ((svg_size.width() * factor).floor() as u32).max(1); + let height = ((svg_size.height() * factor).floor() as u32).max(1); + + let mut image = Image { + size, + width, + height, + xhot: (frame.hotspot_x * factor).floor() as u32, + yhot: (frame.hotspot_y * factor).floor() as u32, + delay: frame.delay, + pixels_rgba: Vec::new(), + pixels_argb: Vec::new(), // unused + }; + + match tiny_skia::Pixmap::new(width, height) { + Some(mut pixmap) => { + resvg::render( + &frame.tree, + tiny_skia::Transform::from_scale(factor, factor), + &mut pixmap.as_mut(), + ); + // tiny-skia produces premultiplied RGBA; xcursor images consume the + // raw little-endian ARGB byte order (premultiplied BGRA). Swap R<->B. + let mut pixels = pixmap.take(); + for px in pixels.as_chunks_mut::<4>().0 { + px.swap(0, 2); + } + image.pixels_rgba = pixels; + } + None => { + warn!(width, height, "Failed to allocate cursor pixmap"); + image.pixels_rgba = vec![0; (width as usize) * (height as usize) * 4]; + } + } + + image +} + +/// Indices (into `images`) of all frames sharing the resolution nearest to `size`. +fn nearest_image_indices(size: u32, images: &[Image]) -> Vec { + // Follow the nominal size of the cursor to choose the nearest. let nearest_image = images .iter() .min_by_key(|image| u32::abs_diff(size, image.size)) .unwrap(); + let (width, height) = (nearest_image.width, nearest_image.height); - images.iter().filter(move |image| { - image.width == nearest_image.width && image.height == nearest_image.height - }) + images + .iter() + .enumerate() + .filter(|(_, image)| image.width == width && image.height == height) + .map(|(i, _)| i) + .collect() } -fn frame(mut millis: u32, size: u32, images: &[Image]) -> Image { - let total = nearest_images(size, images).fold(0, |acc, image| acc + image.delay); +fn xcursor_frame_index(mut millis: u32, size: u32, images: &[Image]) -> usize { + let indices = nearest_image_indices(size, images); + let total: u32 = indices.iter().map(|&i| images[i].delay).sum(); if total == 0 { - millis = 0; - } else { - millis %= total; + return indices[0]; } + millis %= total; - for img in nearest_images(size, images) { - if millis <= img.delay { - return img.clone(); + for &i in &indices { + if millis <= images[i].delay { + return i; } - millis -= img.delay; + millis -= images[i].delay; } - unreachable!() + *indices.last().unwrap() +} + +fn svg_frame_index(mut millis: u32, frames: &[SvgFrame]) -> usize { + let total: u32 = frames.iter().map(|frame| frame.delay).sum(); + + if total == 0 { + return 0; + } + millis %= total; + + for (i, frame) in frames.iter().enumerate() { + if millis <= frame.delay { + return i; + } + millis -= frame.delay; + } + + frames.len() - 1 } #[derive(thiserror::Error, Debug)] @@ -175,6 +296,79 @@ fn load_icon(theme: &CursorTheme, shape: CursorIcon) -> Result, Error Err(Error::NoDefaultCursor) } +/// A frame entry in a `cursors_scalable//metadata.json` file, per the +/// KDE SVG cursor format specification. +#[derive(Debug, Clone, Deserialize)] +struct SvgCursorMeta { + filename: String, + nominal_size: f32, + hotspot_x: f32, + hotspot_y: f32, + /// Only present for animated cursors; defaults to 0 for static ones. + #[serde(default)] + delay: u32, +} + +/// Resolves a scalable (SVG) cursor for `shape` +fn load_svg_icon(theme: &CursorTheme, shape: CursorIcon) -> Option> { + let shape_name = shape.to_string(); + let options = usvg::Options::default(); + for name in cursor_aliases(&shape_name) + .iter() + .copied() + .chain(std::iter::once(shape_name.as_str())) + { + if let Some(dir) = theme.load_scalable(name) + && let Some(frames) = parse_svg_dir(&dir, &options) + { + return Some(frames); + } + } + + None +} + +/// Reads a `cursors_scalable/` directory (its `metadata.json` and the +/// referenced SVG files) into a list of parsed frames. +fn parse_svg_dir(dir: &std::path::Path, options: &usvg::Options) -> Option> { + let metadata = std::fs::read(dir.join("metadata.json")).ok()?; + let metas: Vec = match serde_json::from_slice(&metadata) { + Ok(metas) => metas, + Err(err) => { + warn!(?dir, ?err, "Malformed SVG cursor metadata"); + return None; + } + }; + + let mut frames = Vec::with_capacity(metas.len()); + for meta in metas { + let svg_path = dir.join(&meta.filename); + let svg_data = match std::fs::read(&svg_path) { + Ok(data) => data, + Err(err) => { + warn!(?svg_path, ?err, "Unable to read SVG cursor"); + break; + } + }; + let tree = match usvg::Tree::from_data(&svg_data, options) { + Ok(tree) => tree, + Err(err) => { + warn!(?svg_path, ?err, "Unable to parse SVG cursor"); + break; + } + }; + frames.push(SvgFrame { + tree, + nominal_size: meta.nominal_size, + hotspot_x: meta.hotspot_x, + hotspot_y: meta.hotspot_y, + delay: meta.delay, + }); + } + + (!frames.is_empty()).then_some(frames) +} + render_elements! { pub CursorRenderElement where R: ImportAll + ImportMem + AsGlowRenderer, R::TextureId: Send; Static=MemoryRenderBufferRenderElement, @@ -266,7 +460,9 @@ pub struct CursorStateInner { cursors: HashMap, current_image: Option, - image_cache: Vec<(Image, MemoryRenderBuffer)>, + /// Rasterized cursor frames keyed by `(shape, pixel size, frame index)` so + /// each SVG frame is rasterized (and uploaded) at most once per size. + image_cache: Vec<((CursorIcon, u32, usize), Image, MemoryRenderBuffer)>, hidden: bool, idle_timer: Option, @@ -320,9 +516,11 @@ impl CursorStateInner { } pub fn get_named_cursor(&mut self, shape: CursorIcon) -> &Cursor { + let cursor_theme = &self.cursor_theme; + let cursor_size = self.cursor_size; self.cursors .entry(shape) - .or_insert_with(|| Cursor::load(&self.cursor_theme, shape, self.cursor_size)) + .or_insert_with(|| Cursor::load(cursor_theme, shape, cursor_size)) } pub fn size(&self) -> u32 { @@ -520,30 +718,47 @@ pub fn draw_cursor( } let integer_scale = (scale.x.max(scale.y) * buffer_scale).ceil() as u32; - let frame = state - .get_named_cursor(current_cursor) - .get_image(integer_scale, time.as_millis()); - let actual_scale = (frame.size / state.size()).max(1); + let size_px = state.size() * integer_scale; - let pointer_images = &mut state.image_cache; - let maybe_image = pointer_images - .iter() - .find_map(|(image, texture)| if image == &frame { Some(texture) } else { None }); - let pointer_image = match maybe_image { - Some(image) => image, - None => { - let buffer = MemoryRenderBuffer::from_slice( - &frame.pixels_rgba, - Fourcc::Argb8888, - (frame.width as i32, frame.height as i32), - actual_scale as i32, - Transform::Normal, - None, - ); - pointer_images.push((frame.clone(), buffer)); - pointer_images.last().map(|(_, i)| i).unwrap() - } + // Pick the frame to display without rasterizing, so a cache hit avoids + // any SVG rendering. The `&Cursor` borrow is scoped to this block. + let frame_idx = { + let cursor = state.get_named_cursor(current_cursor); + cursor.frame_index(size_px, time.as_millis()) }; + let key = (current_cursor, size_px, frame_idx); + + // Rasterize and upload this (shape, size, frame) only if not cached. + if !state + .image_cache + .iter() + .any(|(cached, _, _)| *cached == key) + { + let frame = { + let cursor = state.get_named_cursor(current_cursor); + cursor.render_frame(size_px, frame_idx) + }; + let actual_scale = (frame.size / state.size()).max(1); + let buffer = MemoryRenderBuffer::from_slice( + &frame.pixels_rgba, + Fourcc::Argb8888, + (frame.width as i32, frame.height as i32), + actual_scale as i32, + Transform::Normal, + None, + ); + state.image_cache.push((key, frame, buffer)); + } + + let (frame, pointer_image) = { + let (_, frame, buffer) = state + .image_cache + .iter() + .find(|(cached, _, _)| *cached == key) + .unwrap(); + (frame.clone(), buffer.clone()) + }; + let actual_scale = (frame.size / state.size()).max(1); let hotspot = Point::::from((frame.xhot as i32, frame.yhot as i32)) .to_logical( @@ -558,7 +773,7 @@ pub fn draw_cursor( MemoryRenderBufferRenderElement::from_buffer( renderer, location.to_physical(scale), - pointer_image, + &pointer_image, None, None, None, From 80b8eb47d733aca855380ec249fa5fe34fd6ee4e Mon Sep 17 00:00:00 2001 From: Hojjat Date: Fri, 21 Aug 2026 14:46:49 -0600 Subject: [PATCH 6/7] improv: round up and down sample cursor sizes to limit cache --- src/backend/render/cursor.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index 97055a3d..7a0861e5 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -497,6 +497,8 @@ const SHAKE_SAME_SIGN_TOLERANCE: f64 = 1.0; 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; +/// Upper bound on the nominal size (in px) a cursor frame is rasterized at. +const MAX_RASTER_SIZE: u32 = 512; /// Duration of the grow/shrink animation. const MAGNIFICATION_ANIM: Duration = Duration::from_millis(200); @@ -682,6 +684,21 @@ impl Default for CursorStateInner { } } +/// Pick the size a cursor frame is rasterized at, given the size the output wants +/// (`needed`) and the size it would want unmagnified (`base`). +/// +/// Rasterizations are restricted to `base * 2^n`, rounded up, and clamped to +/// [`MAX_RASTER_SIZE`]. +fn raster_size(needed: u32, base: u32) -> u32 { + let base = base.max(1); + let cap = MAX_RASTER_SIZE.max(base); + let mut rung = base; + while rung < needed && rung.saturating_mul(2) <= cap { + rung *= 2; + } + rung +} + #[profiling::function] pub fn draw_cursor( renderer: &mut R, @@ -717,8 +734,10 @@ pub fn draw_cursor( return; } - let integer_scale = (scale.x.max(scale.y) * buffer_scale).ceil() as u32; - let size_px = state.size() * integer_scale; + let output_scale = scale.x.max(scale.y); + let integer_scale = (output_scale * buffer_scale).ceil() as u32; + let unmagnified_px = state.size() * (output_scale.ceil() as u32); + let size_px = raster_size(state.size() * integer_scale, unmagnified_px); // Pick the frame to display without rasterizing, so a cache hit avoids // any SVG rendering. The `&Cursor` borrow is scoped to this block. From 31827ed2409f92d3bc224d3ce601c6f2136183fb Mon Sep 17 00:00:00 2001 From: Hojjat Date: Mon, 24 Aug 2026 10:57:29 -0600 Subject: [PATCH 7/7] improv: clear image_cache on refresh --- src/backend/render/cursor.rs | 86 ++++++++++++++++++++++++------------ src/shell/mod.rs | 23 ++++++++++ 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/backend/render/cursor.rs b/src/backend/render/cursor.rs index 7a0861e5..8e14d1be 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -460,9 +460,7 @@ pub struct CursorStateInner { cursors: HashMap, current_image: Option, - /// Rasterized cursor frames keyed by `(shape, pixel size, frame index)` so - /// each SVG frame is rasterized (and uploaded) at most once per size. - image_cache: Vec<((CursorIcon, u32, usize), Image, MemoryRenderBuffer)>, + image_cache: Vec, hidden: bool, idle_timer: Option, @@ -476,6 +474,15 @@ pub struct CursorStateInner { magnification: f32, anim_from: f32, anim_start: Option, + rest_started: Option, +} + +/// A rasterized cursor frame, keyed by `(shape, pixel size, frame index)`. +struct CachedFrame { + key: (CursorIcon, u32, usize), + image: Image, + buffer: MemoryRenderBuffer, + unmagnified: bool, } /// One sampled pointer position on the recent motion path. @@ -485,6 +492,9 @@ struct PathSample { time: Instant, } +/// How long everything must stay unmagnified before the enlarged frames go. +const MAGNIFIED_FRAME_GRACE: Duration = Duration::from_secs(10); + /// 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. @@ -529,6 +539,21 @@ impl CursorStateInner { self.cursor_size } + /// Drop the rasterizations only a magnified cursor needed, once nothing has + /// magnified it for [`MAGNIFIED_FRAME_GRACE`]. + pub fn drop_magnified_frames(&mut self, now: Instant, zoomed: bool) { + if zoomed || self.is_magnifying() { + self.rest_started = None; + return; + } + let rest_started = *self.rest_started.get_or_insert(now); + if now.duration_since(rest_started) < MAGNIFIED_FRAME_GRACE { + return; + } + + self.image_cache.retain(|frame| frame.unmagnified); + } + /// Feed one relative-motion event into the shake detector. pub fn detect_shake(&mut self, delta: Point, now: Instant) { // Drop samples that have aged out of the time window. @@ -680,6 +705,7 @@ impl Default for CursorStateInner { magnification: 1.0, anim_from: 1.0, anim_start: None, + rest_started: None, } } } @@ -748,34 +774,36 @@ pub fn draw_cursor( let key = (current_cursor, size_px, frame_idx); // Rasterize and upload this (shape, size, frame) only if not cached. - if !state - .image_cache - .iter() - .any(|(cached, _, _)| *cached == key) - { - let frame = { - let cursor = state.get_named_cursor(current_cursor); - cursor.render_frame(size_px, frame_idx) - }; - let actual_scale = (frame.size / state.size()).max(1); - let buffer = MemoryRenderBuffer::from_slice( - &frame.pixels_rgba, - Fourcc::Argb8888, - (frame.width as i32, frame.height as i32), - actual_scale as i32, - Transform::Normal, - None, - ); - state.image_cache.push((key, frame, buffer)); - } + let index = match state.image_cache.iter().position(|frame| frame.key == key) { + Some(index) => index, + None => { + let image = { + let cursor = state.get_named_cursor(current_cursor); + cursor.render_frame(size_px, frame_idx) + }; + let actual_scale = (image.size / state.size()).max(1); + let buffer = MemoryRenderBuffer::from_slice( + &image.pixels_rgba, + Fourcc::Argb8888, + (image.width as i32, image.height as i32), + actual_scale as i32, + Transform::Normal, + None, + ); + state.image_cache.push(CachedFrame { + key, + image, + buffer, + unmagnified: size_px == unmagnified_px, + }); + state.image_cache.len() - 1 + } + }; let (frame, pointer_image) = { - let (_, frame, buffer) = state - .image_cache - .iter() - .find(|(cached, _, _)| *cached == key) - .unwrap(); - (frame.clone(), buffer.clone()) + let entry = &mut state.image_cache[index]; + entry.unmagnified |= size_px == unmagnified_px; + (entry.image.clone(), entry.buffer.clone()) }; let actual_scale = (frame.size / state.size()).max(1); diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 0baa3713..009c98dc 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -1614,6 +1614,29 @@ impl Common { a11y_keyboard_monitor.refresh(); } self.image_copy_capture_state.cleanup(); + self.cleanup_cursor_images(); + } + + /// Release the enlarged cursor frames a finished shake or zoom left behind. + fn cleanup_cursor_images(&mut self) { + let shell = self.shell.read(); + let zoomed = shell.zoom_state.as_ref().is_some_and(|zoom_state| { + shell + .outputs() + .any(|output| zoom_state.animating_level(output) > 1.0) + }); + let now = Instant::now(); + for seat in shell.seats.iter() { + if let Some(cursor_state) = seat + .user_data() + .get::() + { + cursor_state + .lock() + .unwrap() + .drop_magnified_frames(now, zoomed); + } + } } pub fn refresh_idle_inhibit(&mut self) {