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