diff --git a/Cargo.lock b/Cargo.lock index c4db0004..007567b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -937,6 +937,7 @@ dependencies = [ "profiling", "rand 0.10.0", "regex", + "resvg 0.45.1", "ron 0.12.0", "rust-embed", "rustix 1.1.4", @@ -7645,9 +7646,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 86bb5159..145f1248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,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/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index e5e73883..853ba447 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(), } } @@ -195,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/backend/render/cursor.rs b/src/backend/render/cursor.rs index da01b270..8e14d1be 100644 --- a/src/backend/render/cursor.rs +++ b/src/backend/render/cursor.rs @@ -8,6 +8,9 @@ use crate::{ utils::prelude::*, wayland::handlers::compositor::FRAME_TIME_FILTER, }; +use keyframe::{ease, functions::EaseInOutCubic}; +use resvg::{tiny_skia, usvg}; +use serde::Deserialize; use smithay::{ backend::{ allocator::Fourcc, @@ -38,7 +41,7 @@ use smithay::{ wayland::compositor::{get_role, with_states}, }; use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, io::Read, sync::Mutex, time::{Duration, Instant}, @@ -51,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)) @@ -75,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)] @@ -174,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, @@ -265,11 +460,62 @@ pub struct CursorStateInner { cursors: HashMap, current_image: Option, - image_cache: Vec<(Image, MemoryRenderBuffer)>, + image_cache: Vec, 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, + 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. +#[derive(Clone, Copy)] +struct PathSample { + position: Point, + 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. +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; +/// 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); + +/// 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 { @@ -282,14 +528,141 @@ 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 { 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. + 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,10 +697,34 @@ 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, + rest_started: None, } } } +/// 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, @@ -363,32 +760,53 @@ pub fn draw_cursor( return; } - 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 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); - 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, + // 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. + 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( - &frame.pixels_rgba, + &image.pixels_rgba, Fourcc::Argb8888, - (frame.width as i32, frame.height as i32), + (image.width as i32, image.height as i32), actual_scale as i32, Transform::Normal, None, ); - pointer_images.push((frame.clone(), buffer)); - pointer_images.last().map(|(_, i)| i).unwrap() + state.image_cache.push(CachedFrame { + key, + image, + buffer, + unmagnified: size_px == unmagnified_px, + }); + state.image_cache.len() - 1 } }; + let (frame, pointer_image) = { + 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); + let hotspot = Point::::from((frame.xhot as i32, frame.yhot as i32)) .to_logical( actual_scale as i32, @@ -402,7 +820,7 @@ pub fn draw_cursor( MemoryRenderBufferRenderElement::from_buffer( renderer, location.to_physical(scale), - pointer_image, + &pointer_image, None, None, 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/actions.rs b/src/input/actions.rs index a4932597..8a5ef84c 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,14 @@ 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); + let new_level = if new_level < 1.01 { 1.0 } else { new_level }; shell.trigger_zoom( seat, Some(&output), 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..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) { @@ -2286,6 +2309,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 { diff --git a/src/shell/zoom.rs b/src/shell/zoom.rs index 8363510a..25a16e9c 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); } @@ -829,7 +829,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); } 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); }