feat: scalable cursor support

This commit is contained in:
Hojjat 2026-07-31 14:46:52 -06:00 • committed by Victoria Brekenfeld
parent 8304b18886
commit 01b79c3eae
3 changed files with 262 additions and 45 deletions

5
Cargo.lock generated
View file

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

View file

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

View file

@ -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<Image>),
/// Scalable SVG frames, rasterized on demand.
Svg(Vec<SvgFrame>),
}
#[derive(Debug, Clone)]
pub struct Cursor {
icons: Vec<Image>,
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<Item = &Image> {
// 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<usize> {
// 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<Vec<Image>, Error
Err(Error::NoDefaultCursor)
}
/// A frame entry in a `cursors_scalable/<shape>/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<Vec<SvgFrame>> {
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/<shape>` 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<Vec<SvgFrame>> {
let metadata = std::fs::read(dir.join("metadata.json")).ok()?;
let metas: Vec<SvgCursorMeta> = 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<R> where R: ImportAll + ImportMem + AsGlowRenderer, R::TextureId: Send;
Static=MemoryRenderBufferRenderElement<R>,
@ -266,7 +460,9 @@ pub struct CursorStateInner {
cursors: HashMap<CursorIcon, Cursor>,
current_image: Option<Image>,
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<RegistrationToken>,
@ -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<R>(
}
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::<i32, BufferCoords>::from((frame.xhot as i32, frame.yhot as i32))
.to_logical(
@ -558,7 +773,7 @@ pub fn draw_cursor<R>(
MemoryRenderBufferRenderElement::from_buffer(
renderer,
location.to_physical(scale),
pointer_image,
&pointer_image,
None,
None,
None,