feat: scalable cursor support
This commit is contained in:
parent
8304b18886
commit
01b79c3eae
3 changed files with 262 additions and 45 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
|
@ -817,6 +817,7 @@ dependencies = [
|
||||||
"profiling",
|
"profiling",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"regex",
|
"regex",
|
||||||
|
"resvg 0.45.1",
|
||||||
"ron 0.12.0",
|
"ron 0.12.0",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
|
|
@ -6893,9 +6894,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xcursor"
|
name = "xcursor"
|
||||||
version = "0.3.10"
|
version = "0.3.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
|
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xdg"
|
name = "xdg"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ log-panics = { version = "2", features = ["with-backtrace"] }
|
||||||
ordered-float = "5.1"
|
ordered-float = "5.1"
|
||||||
png = "0.18"
|
png = "0.18"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
resvg = "0.45"
|
||||||
ron = "0.12"
|
ron = "0.12"
|
||||||
rust-embed = { version = "8.11", features = ["debug-embed"] }
|
rust-embed = { version = "8.11", features = ["debug-embed"] }
|
||||||
sanitize-filename = "0.6.0"
|
sanitize-filename = "0.6.0"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ use crate::{
|
||||||
wayland::handlers::compositor::FRAME_TIME_FILTER,
|
wayland::handlers::compositor::FRAME_TIME_FILTER,
|
||||||
};
|
};
|
||||||
use keyframe::{ease, functions::EaseInOutCubic};
|
use keyframe::{ease, functions::EaseInOutCubic};
|
||||||
|
use resvg::{tiny_skia, usvg};
|
||||||
|
use serde::Deserialize;
|
||||||
use smithay::{
|
use smithay::{
|
||||||
backend::{
|
backend::{
|
||||||
allocator::Fourcc,
|
allocator::Fourcc,
|
||||||
|
|
@ -52,14 +54,43 @@ use xcursor::{
|
||||||
|
|
||||||
static FALLBACK_CURSOR_DATA: &[u8] = include_bytes!("../../../resources/cursor.rgba");
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Cursor {
|
pub struct Cursor {
|
||||||
icons: Vec<Image>,
|
kind: CursorKind,
|
||||||
size: u32,
|
size: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cursor {
|
impl Cursor {
|
||||||
pub fn load(theme: &CursorTheme, shape: CursorIcon, size: u32) -> 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)
|
let icons = load_icon(theme, shape)
|
||||||
.map_err(|err| warn!(?err, "Unable to load xcursor, using fallback cursor"))
|
.map_err(|err| warn!(?err, "Unable to load xcursor, using fallback cursor"))
|
||||||
.or_else(|_| load_icon(theme, CursorIcon::Default))
|
.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 {
|
pub fn get_image(&self, scale: u32, millis: u32) -> Image {
|
||||||
let size = self.size * scale;
|
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> {
|
/// Rasterize a scalable cursor frame at nominal pixel size `size`. Per the KDE
|
||||||
// Follow the nominal size of the cursor to choose the nearest
|
/// 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
|
let nearest_image = images
|
||||||
.iter()
|
.iter()
|
||||||
.min_by_key(|image| u32::abs_diff(size, image.size))
|
.min_by_key(|image| u32::abs_diff(size, image.size))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let (width, height) = (nearest_image.width, nearest_image.height);
|
||||||
|
|
||||||
images.iter().filter(move |image| {
|
images
|
||||||
image.width == nearest_image.width && image.height == nearest_image.height
|
.iter()
|
||||||
})
|
.enumerate()
|
||||||
|
.filter(|(_, image)| image.width == width && image.height == height)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frame(mut millis: u32, size: u32, images: &[Image]) -> Image {
|
fn xcursor_frame_index(mut millis: u32, size: u32, images: &[Image]) -> usize {
|
||||||
let total = nearest_images(size, images).fold(0, |acc, image| acc + image.delay);
|
let indices = nearest_image_indices(size, images);
|
||||||
|
let total: u32 = indices.iter().map(|&i| images[i].delay).sum();
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
millis = 0;
|
return indices[0];
|
||||||
} else {
|
|
||||||
millis %= total;
|
|
||||||
}
|
}
|
||||||
|
millis %= total;
|
||||||
|
|
||||||
for img in nearest_images(size, images) {
|
for &i in &indices {
|
||||||
if millis <= img.delay {
|
if millis <= images[i].delay {
|
||||||
return img.clone();
|
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)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
|
@ -175,6 +296,79 @@ fn load_icon(theme: &CursorTheme, shape: CursorIcon) -> Result<Vec<Image>, Error
|
||||||
Err(Error::NoDefaultCursor)
|
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! {
|
render_elements! {
|
||||||
pub CursorRenderElement<R> where R: ImportAll + ImportMem + AsGlowRenderer, R::TextureId: Send;
|
pub CursorRenderElement<R> where R: ImportAll + ImportMem + AsGlowRenderer, R::TextureId: Send;
|
||||||
Static=MemoryRenderBufferRenderElement<R>,
|
Static=MemoryRenderBufferRenderElement<R>,
|
||||||
|
|
@ -266,7 +460,9 @@ pub struct CursorStateInner {
|
||||||
|
|
||||||
cursors: HashMap<CursorIcon, Cursor>,
|
cursors: HashMap<CursorIcon, Cursor>,
|
||||||
current_image: Option<Image>,
|
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,
|
hidden: bool,
|
||||||
idle_timer: Option<RegistrationToken>,
|
idle_timer: Option<RegistrationToken>,
|
||||||
|
|
@ -320,9 +516,11 @@ impl CursorStateInner {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_named_cursor(&mut self, shape: CursorIcon) -> &Cursor {
|
pub fn get_named_cursor(&mut self, shape: CursorIcon) -> &Cursor {
|
||||||
|
let cursor_theme = &self.cursor_theme;
|
||||||
|
let cursor_size = self.cursor_size;
|
||||||
self.cursors
|
self.cursors
|
||||||
.entry(shape)
|
.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 {
|
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 integer_scale = (scale.x.max(scale.y) * buffer_scale).ceil() as u32;
|
||||||
let frame = state
|
let size_px = state.size() * integer_scale;
|
||||||
.get_named_cursor(current_cursor)
|
|
||||||
.get_image(integer_scale, time.as_millis());
|
|
||||||
let actual_scale = (frame.size / state.size()).max(1);
|
|
||||||
|
|
||||||
let pointer_images = &mut state.image_cache;
|
// Pick the frame to display without rasterizing, so a cache hit avoids
|
||||||
let maybe_image = pointer_images
|
// any SVG rendering. The `&Cursor` borrow is scoped to this block.
|
||||||
.iter()
|
let frame_idx = {
|
||||||
.find_map(|(image, texture)| if image == &frame { Some(texture) } else { None });
|
let cursor = state.get_named_cursor(current_cursor);
|
||||||
let pointer_image = match maybe_image {
|
cursor.frame_index(size_px, time.as_millis())
|
||||||
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()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
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))
|
let hotspot = Point::<i32, BufferCoords>::from((frame.xhot as i32, frame.yhot as i32))
|
||||||
.to_logical(
|
.to_logical(
|
||||||
|
|
@ -558,7 +773,7 @@ pub fn draw_cursor<R>(
|
||||||
MemoryRenderBufferRenderElement::from_buffer(
|
MemoryRenderBufferRenderElement::from_buffer(
|
||||||
renderer,
|
renderer,
|
||||||
location.to_physical(scale),
|
location.to_physical(scale),
|
||||||
pointer_image,
|
&pointer_image,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue