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