Skip to main content

pixelflux/wayland/
cursor.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Wayland cursor shape resolution: converts a `CursorImageStatus` into a PNG image buffer
8//! suitable for the Python cursor callback. Uses `xcursor` to load the system cursor theme
9//! and render the appropriate frame at the current scale.
10
11use image::{ImageBuffer, Rgba, RgbaImage};
12use pyo3::prelude::*;
13use pyo3::types::PyBytes;
14use std::collections::HashMap;
15use std::io::Cursor as IoCursor;
16use std::io::Read;
17use std::time::Duration;
18use xcursor::{
19    CursorTheme,
20    parser::{Image, parse_xcursor},
21};
22
23/// One unit of cursor-callback work handed from the calloop thread to the `wl-cursor`
24/// worker. The calloop resolves everything renderer/surface-affine (SHM copies, dmabuf
25/// readbacks, hotspots); the worker does the PNG encode, the cache, and the GIL-bound Python
26/// call so none of that latency lands on the input/render thread. One channel keeps cursor
27/// updates ordered with callback (re)registration.
28pub enum CursorJob {
29    /// `None` withdraws the callback, releasing what it holds.
30    SetCallback(Option<Py<PyAny>>),
31    /// Reload the worker's theme handle at a new pixel size; later `Named` jobs render at it.
32    SetSize(i32),
33    /// Cap the longest delivered cursor edge in pixels; larger sprites are downscaled with
34    /// the hotspot. `<= 0` delivers them uncapped.
35    SetSizeCap(i32),
36    Named {
37        name: &'static str,
38    },
39    Hide,
40    /// wl_shm cursor sprite: raw pool bytes plus the sub-image descriptor.
41    Shm {
42        hash: u64,
43        width: i32,
44        height: i32,
45        stride: i32,
46        offset: i32,
47        opaque: bool,
48        bytes: Vec<u8>,
49        hot_x: i32,
50        hot_y: i32,
51    },
52    /// dmabuf cursor sprite already read back to tightly-mapped RGBA on the calloop.
53    Gles {
54        hash: u64,
55        width: i32,
56        height: i32,
57        bytes: Vec<u8>,
58        hot_x: i32,
59        hot_y: i32,
60    },
61}
62
63/// Spawn the cursor delivery worker; returns its job channel. The worker owns its own
64/// theme handle, the PNG cache, and the Python callback until one replaces or withdraws it
65/// (like the compositor thread itself); `PY_SHUTDOWN` gates every Python call.
66pub fn spawn_cursor_worker(cursor_size: i32, size_cap: i32) -> std::sync::mpsc::Sender<CursorJob> {
67    let (tx, rx) = std::sync::mpsc::channel::<CursorJob>();
68    let _ = std::thread::Builder::new()
69        .name("wl-cursor".into())
70        .spawn(move || {
71            let mut helper = Cursor::load(cursor_size);
72            let mut cap = size_cap;
73            let mut cache: HashMap<(u64, i32), CappedSprite> = HashMap::new();
74            let mut callback: Option<Py<PyAny>> = None;
75            while let Ok(job) = rx.recv() {
76                let (msg_type, data, hot_x, hot_y): (&str, Vec<u8>, i32, i32) = match job {
77                    CursorJob::SetCallback(cb) => {
78                        // Attached for the swap: dropping the old callback without the
79                        // GIL defers its decref to this thread's next attach, which a
80                        // withdrawal means may never come.
81                        Python::attach(|_| callback = cb);
82                        continue;
83                    }
84                    CursorJob::SetSize(size) => {
85                        helper = Cursor::load(size);
86                        continue;
87                    }
88                    CursorJob::SetSizeCap(c) => {
89                        cap = c;
90                        continue;
91                    }
92                    CursorJob::Named { name } => match helper.get_sprite(name) {
93                        Some((img, x, y)) => match cap_and_encode(img, cap) {
94                            Some(sprite) => {
95                                let (sx, sy) = scaled_hotspot(&sprite, x as i32, y as i32);
96                                ("png", sprite.png, sx, sy)
97                            }
98                            None => ("error", Vec::new(), 0, 0),
99                        },
100                        None => ("error", Vec::new(), 0, 0),
101                    },
102                    CursorJob::Hide => ("hide", Vec::new(), 0, 0),
103                    CursorJob::Shm {
104                        hash,
105                        width,
106                        height,
107                        stride,
108                        offset,
109                        opaque,
110                        bytes,
111                        hot_x,
112                        hot_y,
113                    } => capped_job(&mut cache, hash, cap, hot_x, hot_y, || {
114                        decode_shm_cursor(width, height, stride, offset, opaque, &bytes)
115                    }),
116                    CursorJob::Gles {
117                        hash,
118                        width,
119                        height,
120                        bytes,
121                        hot_x,
122                        hot_y,
123                    } => capped_job(&mut cache, hash, cap, hot_x, hot_y, || {
124                        decode_gles_cursor(width, height, &bytes)
125                    }),
126                };
127                // A sprite whose pixels could not be read yields empty data; suppressing it
128                // preserves the consumer's last cursor instead of blanking it (only an
129                // intentional hide passes with no payload).
130                if data.is_empty() && msg_type != "hide" {
131                    continue;
132                }
133                if crate::PY_SHUTDOWN.load(std::sync::atomic::Ordering::Relaxed) {
134                    continue;
135                }
136                if let Some(ref cb) = callback {
137                    Python::attach(|py| {
138                        let py_bytes = PyBytes::new(py, &data);
139                        let _ = cb.call1(py, (msg_type, py_bytes, hot_x, hot_y));
140                    });
141                }
142            }
143        });
144    tx
145}
146
147/// A cursor sprite encoded at its delivered size: the straight-alpha PNG, the factor the
148/// source was scaled by, and the payload's dimensions. Hotspots are per-job rather than a
149/// property of the pixels, so they are scaled against `scale` instead of being stored here.
150struct CappedSprite {
151    png: Vec<u8>,
152    scale: f64,
153    width: u32,
154    height: u32,
155}
156
157/// Sprites retained before arbitrary eviction; an evicted sprite re-encodes on next appearance.
158const SPRITE_CACHE_MAX: usize = 100;
159
160/// Apply the size cap to a **premultiplied** sprite and encode it as a straight-alpha PNG.
161///
162/// `cap <= 0`, or a sprite already within it, is encoded at its source size. Resizing happens
163/// while the pixels are still premultiplied — filtering straight alpha pulls the (zero) color
164/// of transparent texels into the edges and leaves a dark fringe, and Lanczos3 overshoot has no
165/// headroom to land in — which is the order the X11 XFixes path uses.
166fn cap_and_encode(mut img: RgbaImage, cap: i32) -> Option<CappedSprite> {
167    let (w, h) = (img.width(), img.height());
168    if w == 0 || h == 0 {
169        return None;
170    }
171    let longest = w.max(h);
172    let mut scale = 1.0;
173    if cap > 0 && longest > cap as u32 {
174        scale = cap as f64 / longest as f64;
175        let nw = ((w as f64 * scale).round() as u32).max(1);
176        let nh = ((h as f64 * scale).round() as u32).max(1);
177        img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Lanczos3);
178    }
179    crate::unpremultiply_rgba(&mut img);
180    let mut png = Vec::new();
181    img.write_to(&mut IoCursor::new(&mut png), image::ImageFormat::Png)
182        .ok()?;
183    Some(CappedSprite {
184        png,
185        scale,
186        width: img.width(),
187        height: img.height(),
188    })
189}
190
191/// Scale a source hotspot onto a delivered sprite. Consumers treat the hotspot as an offset
192/// INTO the bitmap, so the rounded coordinate is clamped to the payload rather than allowed to
193/// land one pixel past its right or bottom edge.
194fn scaled_hotspot(sprite: &CappedSprite, hot_x: i32, hot_y: i32) -> (i32, i32) {
195    (
196        ((hot_x as f64 * sprite.scale).round() as i32).clamp(0, sprite.width as i32 - 1),
197        ((hot_y as f64 * sprite.scale).round() as i32).clamp(0, sprite.height as i32 - 1),
198    )
199}
200
201/// Cache wrapper for sprite jobs, keyed by `(sprite hash, cap)` so a repeat of a sprite already
202/// delivered under the same cap is a plain clone of its PNG; a miss decodes the sprite, caps it,
203/// and encodes it once. Keying on the cap keeps a live `SetSizeCap` from serving stale sizes.
204fn capped_job(
205    cache: &mut HashMap<(u64, i32), CappedSprite>,
206    hash: u64,
207    cap: i32,
208    hot_x: i32,
209    hot_y: i32,
210    decode: impl FnOnce() -> Option<RgbaImage>,
211) -> (&'static str, Vec<u8>, i32, i32) {
212    use std::collections::hash_map::Entry;
213    let key = (hash, cap);
214    if cache.len() >= SPRITE_CACHE_MAX
215        && !cache.contains_key(&key)
216        && let Some(&evict) = cache.keys().next()
217    {
218        cache.remove(&evict);
219    }
220    let sprite = match cache.entry(key) {
221        Entry::Occupied(e) => e.into_mut(),
222        // An unreadable sprite yields an empty payload, which the worker suppresses so the
223        // consumer keeps the cursor it already has.
224        Entry::Vacant(e) => match decode().and_then(|img| cap_and_encode(img, cap)) {
225            Some(sprite) => e.insert(sprite),
226            None => return ("png", Vec::new(), 0, 0),
227        },
228    };
229    let (sx, sy) = scaled_hotspot(sprite, hot_x, hot_y);
230    ("png", sprite.png.clone(), sx, sy)
231}
232
233/// Longest side of a cursor surface still read: an application's own pointer can be
234/// well past what is delivered (the cap downscales it), but a surface this large is not
235/// a cursor at all and its copy is skipped.
236const MAX_SPRITE_SIDE: i32 = 1024;
237
238/// Read a wl_shm BGRA/XRGB sprite sub-image into a premultiplied RGBA image, the form
239/// `cap_and_encode` must resize before it unpremultiplies. Stride/offset are clamped
240/// non-negative with checked arithmetic so a garbage descriptor skips pixels instead of
241/// panicking; a sprite past `MAX_SPRITE_SIDE` is ignored.
242fn decode_shm_cursor(
243    width: i32,
244    height: i32,
245    stride: i32,
246    offset: i32,
247    opaque: bool,
248    raw_bytes: &[u8],
249) -> Option<RgbaImage> {
250    if width <= 0
251        || height <= 0
252        || width > MAX_SPRITE_SIDE
253        || height > MAX_SPRITE_SIDE
254        || raw_bytes.is_empty()
255    {
256        return None;
257    }
258    let mut img_buf = ImageBuffer::<Rgba<u8>, Vec<u8>>::new(width as u32, height as u32);
259    let stride_usize = stride.max(0) as usize;
260    let base_offset = offset.max(0) as usize;
261    for y in 0..(height as u32) {
262        for x in 0..(width as u32) {
263            let offset = (y as usize)
264                .checked_mul(stride_usize)
265                .and_then(|row| base_offset.checked_add(row))
266                .and_then(|o| o.checked_add((x as usize) * 4));
267            let offset = match offset {
268                Some(o) => o,
269                None => continue,
270            };
271            if offset
272                .checked_add(4)
273                .is_some_and(|end| end <= raw_bytes.len())
274            {
275                let alpha = if opaque { 255 } else { raw_bytes[offset + 3] };
276                img_buf.put_pixel(
277                    x,
278                    y,
279                    Rgba([
280                        raw_bytes[offset + 2],
281                        raw_bytes[offset + 1],
282                        raw_bytes[offset],
283                        alpha,
284                    ]),
285                );
286            }
287        }
288    }
289    Some(img_buf)
290}
291
292/// Read a dmabuf sprite's RGBA readback (stride recovered from the mapping length) into a
293/// premultiplied RGBA image.
294fn decode_gles_cursor(width: i32, height: i32, raw_bytes: &[u8]) -> Option<RgbaImage> {
295    if width <= 0
296        || height <= 0
297        || width > MAX_SPRITE_SIDE
298        || height > MAX_SPRITE_SIDE
299        || raw_bytes.is_empty()
300    {
301        return None;
302    }
303    let stride =
304        super::frontend::rgba_readback_stride(raw_bytes.len(), height as usize, width as usize);
305    let mut img_buf = ImageBuffer::<Rgba<u8>, Vec<u8>>::new(width as u32, height as u32);
306    for y in 0..(height as u32) {
307        for x in 0..(width as u32) {
308            let offset = (y as usize * stride) + (x as usize * 4);
309            if offset + 4 <= raw_bytes.len() {
310                img_buf.put_pixel(
311                    x,
312                    y,
313                    Rgba([
314                        raw_bytes[offset],
315                        raw_bytes[offset + 1],
316                        raw_bytes[offset + 2],
317                        raw_bytes[offset + 3],
318                    ]),
319                );
320            }
321        }
322    }
323    Some(img_buf)
324}
325
326/// The loaded XCursor theme, held for the whole capture so cursor lookups stay cheap.
327///
328/// The default cursor's frames are parsed once and kept here because they are consulted on nearly
329/// every frame; the theme handle is retained alongside them so the rarer named cursors (`hand1`,
330/// `text`, …) can still be resolved on demand. Everything is sized to the one resolved pixel size.
331pub struct Cursor {
332    icons: Vec<Image>,
333    theme: CursorTheme,
334    size: u32,
335}
336
337impl Cursor {
338    /// Load the theme named by `XCURSOR_THEME` (default `"default"`) at the requested size.
339    ///
340    /// `size_override` comes from `CaptureSettings.cursor_size` (selkies `--cursor-size` /
341    /// `XCURSOR_SIZE`); a value `<= 0` falls back to 24. When the theme's default icon cannot be
342    /// loaded, a 16×16 solid-red placeholder stands in so the caller always has a valid image.
343    pub fn load(size_override: i32) -> Cursor {
344        let name = std::env::var("XCURSOR_THEME").unwrap_or_else(|_| "default".into());
345        let size: u32 = if size_override > 0 {
346            size_override as u32
347        } else {
348            24
349        };
350
351        let theme = CursorTheme::load(&name);
352        let icons = load_icon(&theme, "default").unwrap_or_else(|_| {
353            let size = 16;
354            let mut pixels = Vec::with_capacity((size * size * 4) as usize);
355            for _ in 0..(size * size) {
356                pixels.extend_from_slice(&[255, 0, 0, 255]);
357            }
358
359            vec![Image {
360                size,
361                width: size,
362                height: size,
363                xhot: 0,
364                yhot: 0,
365                delay: 1,
366                pixels_rgba: pixels,
367                pixels_argb: vec![],
368            }]
369        });
370
371        Cursor { icons, theme, size }
372    }
373
374    /// The default cursor's animation frame for `time`, at the theme size times `scale`.
375    pub fn get_image(&self, scale: u32, time: Duration) -> Image {
376        let size = self.size * scale;
377        frame(time.as_millis() as u32, size, &self.icons)
378    }
379
380    /// A named cursor's animation frame for `time`, or `None` when the theme lacks it.
381    pub fn get_image_by_name(&self, name: &str, scale: u32, time: Duration) -> Option<Image> {
382        let icons = load_icon(&self.theme, name).ok()?;
383        let size = self.size * scale;
384        Some(frame(time.as_millis() as u32, size, &icons))
385    }
386
387    /// A named cursor icon as a premultiplied RGBA image plus its hotspot (x, y), ready for
388    /// `cap_and_encode` to size and convert to the straight alpha web clients want. Xcursor
389    /// stores premultiplied color, which is also what the compositing paths (`get_image*`)
390    /// need for blending, so it is carried through unconverted.
391    pub fn get_sprite(&self, name: &str) -> Option<(RgbaImage, u32, u32)> {
392        let icons = load_icon(&self.theme, name).ok()?;
393        let image_data = nearest_images(self.size, &icons).next()?;
394
395        let img_buf: RgbaImage = ImageBuffer::from_raw(
396            image_data.width,
397            image_data.height,
398            image_data.pixels_rgba.clone(),
399        )?;
400
401        Some((img_buf, image_data.xhot, image_data.yhot))
402    }
403}
404
405/// All frames of the theme variant whose pixel size is closest to `size`. XCursor files
406/// bundle the same cursor at several sizes, and choosing the nearest one avoids scaling a
407/// mismatched bitmap into a blurry or aliased cursor.
408fn nearest_images(size: u32, images: &[Image]) -> impl Iterator<Item = &Image> {
409    let nearest_image = images
410        .iter()
411        .min_by_key(|image| (size as i32 - image.size as i32).abs())
412        .unwrap();
413    images.iter().filter(move |image| {
414        image.width == nearest_image.width && image.height == nearest_image.height
415    })
416}
417
418/// Pick which animation frame to show for the elapsed time, so animated cursors (a spinner,
419/// a progress ring) actually advance instead of freezing on frame zero; it maps the time onto the
420/// frames by cycling their cumulative delays.
421fn frame(mut millis: u32, size: u32, images: &[Image]) -> Image {
422    let total = nearest_images(size, images).fold(0, |acc, image| acc + image.delay);
423    if total == 0 {
424        return nearest_images(size, images).next().unwrap().clone();
425    }
426    millis %= total;
427    for img in nearest_images(size, images) {
428        if millis < img.delay {
429            return img.clone();
430        }
431        millis -= img.delay;
432    }
433    unreachable!()
434}
435
436/// Parse the named icon from the theme's cursor file into its frames.
437///
438/// Empty parses are rejected so `nearest_images`'s `min_by_key().unwrap()` cannot panic on a
439/// cursor file that has a valid header but zero images.
440fn load_icon(theme: &CursorTheme, name: &str) -> Result<Vec<Image>, String> {
441    let icon_path = theme.load_icon(name).ok_or("Icon not found")?;
442    let mut cursor_file = std::fs::File::open(icon_path).map_err(|e| e.to_string())?;
443    let mut cursor_data = Vec::new();
444    cursor_file
445        .read_to_end(&mut cursor_data)
446        .map_err(|e| e.to_string())?;
447    let imgs = parse_xcursor(&cursor_data).ok_or("Failed to parse".to_string())?;
448    if imgs.is_empty() {
449        return Err("Cursor file has no images".to_string());
450    }
451    Ok(imgs)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn a_large_cursor_surface_is_read_and_capped() {
460        // An application's 256px pointer as a wl_shm surface: read whole, then
461        // brought down to the cap; a surface too large to be a cursor is skipped.
462        let side = 256;
463        let raw = vec![0xffu8; (side * side * 4) as usize];
464        let img = decode_shm_cursor(side, side, side * 4, 0, false, &raw).unwrap();
465        assert_eq!((img.width(), img.height()), (256, 256));
466        let sprite = cap_and_encode(img, 128).unwrap();
467        let png = image::load_from_memory(&sprite.png).unwrap();
468        assert_eq!((png.width(), png.height()), (128, 128));
469        assert!(decode_shm_cursor(2048, 2048, 2048 * 4, 0, false, &raw).is_none());
470        assert!(decode_gles_cursor(2048, 2048, &raw).is_none());
471    }
472
473    #[test]
474    fn cap_keeps_a_sprite_within_it_and_shrinks_one_past_it() {
475        let whole = RgbaImage::from_pixel(128, 128, image::Rgba([0, 0, 0, 255]));
476        let sprite = cap_and_encode(whole, 128).unwrap();
477        let img = image::load_from_memory(&sprite.png).unwrap();
478        assert_eq!((img.width(), img.height()), (128, 128));
479        let big = RgbaImage::from_pixel(256, 64, image::Rgba([0, 0, 0, 255]));
480        let sprite = cap_and_encode(big, 128).unwrap();
481        let img = image::load_from_memory(&sprite.png).unwrap();
482        assert_eq!((img.width(), img.height()), (128, 32));
483    }
484}