Skip to main content

drmkit_cursor/
cursor.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! A loaded cursor: pixels, hotspot, and animation timing.
5//!
6//! Pure CPU. No descriptors, no KMS. Rasterizing one onto a plane is somebody
7//! else's job; this holds the frames and answers which of them is showing.
8
9use std::time::Duration;
10
11use crate::CursorError;
12use crate::theme::{Theme, ThemeResolution};
13use crate::xcursor::{self, Limits};
14
15/// Substituted when a caller asks for size zero.
16///
17/// Zero is a foot-gun: libxcursor reads it as 24 px, which is tiny against a
18/// real cursor plane -- 64 px on i915, 256 px on amdgpu. 64 is the smallest
19/// commonly supported plane size, so the sprite reaches the buffer's edges
20/// instead of floating in the middle of it.
21const DEFAULT_SIZE: u32 = 64;
22
23/// One frame of a cursor.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[allow(
26    clippy::exhaustive_structs,
27    reason = "built by callers, with no default to fill a new field from"
28)]
29pub struct Frame {
30    /// Premultiplied `0xAARRGGBB`, row-major, `width * height` of them.
31    pub pixels: Vec<u32>,
32    /// Width in pixels.
33    pub width: u32,
34    /// Height in pixels.
35    pub height: u32,
36    /// Where the pointer actually points, in pixels from the left edge.
37    pub xhot: u32,
38    /// Where the pointer actually points, in pixels from the top edge.
39    pub yhot: u32,
40    /// How long this frame shows. Zero for a static cursor.
41    pub delay: Duration,
42}
43
44/// A loaded cursor, immutable once built.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Cursor {
47    frames: Vec<Frame>,
48    cycle: Duration,
49}
50
51impl Cursor {
52    /// Load from a resolved theme entry at the nearest available size.
53    ///
54    /// Cursor files are multi-resolution packs, so this picks the nominal size
55    /// closest to `requested_size` and takes every frame filed under it. A
56    /// `requested_size` of zero means the default nominal size.
57    ///
58    /// # Errors
59    ///
60    /// - [`CursorError::Io`] if the file cannot be read.
61    /// - [`CursorError::Malformed`] if it is not a cursor file, is truncated,
62    ///   or contradicts itself.
63    /// - [`CursorError::TooLarge`] if it declares more frames or larger frames
64    ///   than the loader accepts.
65    /// - [`CursorError::NoImages`] if it parses but holds no images.
66    pub fn load(resolved: &ThemeResolution, requested_size: u32) -> Result<Self, CursorError> {
67        let bytes = std::fs::read(&resolved.source).map_err(|e| CursorError::Io(e.to_string()))?;
68        Self::from_bytes(&bytes, requested_size)
69    }
70
71    /// Resolve a name against `theme` and load it.
72    ///
73    /// # Errors
74    ///
75    /// As [`Theme::resolve`] and [`Cursor::load`].
76    pub fn load_named(
77        theme: &Theme,
78        cursor_name: &str,
79        preferred_theme: &str,
80        requested_size: u32,
81    ) -> Result<Self, CursorError> {
82        let resolved = theme.resolve(cursor_name, preferred_theme)?;
83        Self::load(&resolved, requested_size)
84    }
85
86    /// Load from the bytes of a cursor file.
87    ///
88    /// The seam the fuzz target drives, so it exercises the same bounds the
89    /// real loader does.
90    ///
91    /// # Errors
92    ///
93    /// As [`Cursor::load`], less the I/O.
94    pub fn from_bytes(bytes: &[u8], requested_size: u32) -> Result<Self, CursorError> {
95        let size = if requested_size == 0 {
96            DEFAULT_SIZE
97        } else {
98            requested_size
99        };
100
101        let images = xcursor::parse(bytes, Limits::default())?;
102        let chosen = xcursor::nearest_size(&images, size).ok_or(CursorError::NoImages)?;
103
104        let frames: Vec<Frame> = images
105            .into_iter()
106            .filter(|image| image.nominal_size == chosen)
107            .map(|image| Frame {
108                pixels: image.pixels,
109                width: image.width,
110                height: image.height,
111                xhot: image.xhot,
112                yhot: image.yhot,
113                delay: Duration::from_millis(u64::from(image.delay_ms)),
114            })
115            .collect();
116
117        Self::assemble(frames)
118    }
119
120    /// Build a static cursor from caller-supplied pixels.
121    ///
122    /// The in-process fallback for when no cursor theme is installed. Pixels
123    /// are premultiplied `0xAARRGGBB`, row-major, and are copied.
124    ///
125    /// # Errors
126    ///
127    /// [`CursorError::InvalidImage`] for a zero or oversize dimension, or a
128    /// pixel count that does not match the dimensions. A caller bug, surfaced
129    /// here rather than as a surprise at render time.
130    pub fn from_argb(
131        pixels: &[u32],
132        width: u32,
133        height: u32,
134        xhot: u32,
135        yhot: u32,
136    ) -> Result<Self, CursorError> {
137        let limits = Limits::default();
138        if width == 0 || height == 0 || width > limits.max_dim || height > limits.max_dim {
139            return Err(CursorError::InvalidImage);
140        }
141        if pixels.len() != width as usize * height as usize {
142            return Err(CursorError::InvalidImage);
143        }
144        Self::assemble(vec![Frame {
145            pixels: pixels.to_vec(),
146            width,
147            height,
148            xhot,
149            yhot,
150            delay: Duration::ZERO,
151        }])
152    }
153
154    /// Build a cursor from frames the caller already has.
155    ///
156    /// The many-frame form of [`Cursor::from_argb`], for a caller generating
157    /// cursors rather than loading them -- a compositor drawing its own
158    /// spinner, or a test. Frames play in the order given.
159    ///
160    /// # Errors
161    ///
162    /// - [`CursorError::NoImages`] for an empty set.
163    /// - [`CursorError::InvalidImage`] if any frame's pixel count disagrees
164    ///   with its dimensions, or a dimension is zero or past the limit.
165    pub fn from_frames(frames: Vec<Frame>) -> Result<Self, CursorError> {
166        let limits = Limits::default();
167        for frame in &frames {
168            if frame.width == 0
169                || frame.height == 0
170                || frame.width > limits.max_dim
171                || frame.height > limits.max_dim
172            {
173                return Err(CursorError::InvalidImage);
174            }
175            if frame.pixels.len() != frame.width as usize * frame.height as usize {
176                return Err(CursorError::InvalidImage);
177            }
178        }
179        if frames.len() > limits.max_frames {
180            return Err(CursorError::TooLarge);
181        }
182        Self::assemble(frames)
183    }
184
185    /// Assemble from frames, computing the cycle.
186    fn assemble(frames: Vec<Frame>) -> Result<Self, CursorError> {
187        if frames.is_empty() {
188            return Err(CursorError::NoImages);
189        }
190        let cycle = frames.iter().map(|frame| frame.delay).sum();
191        Ok(Self { frames, cycle })
192    }
193
194    /// The first frame, which is what a static cursor shows.
195    #[must_use]
196    pub fn first(&self) -> &Frame {
197        // `from_frames` refuses an empty set, so there is always one.
198        &self.frames[0]
199    }
200
201    /// Every frame, in play order.
202    #[must_use]
203    pub fn frames(&self) -> &[Frame] {
204        &self.frames
205    }
206
207    /// How long one full loop takes. Zero for a static cursor.
208    #[must_use]
209    pub const fn cycle(&self) -> Duration {
210        self.cycle
211    }
212
213    /// Whether this cursor actually animates.
214    ///
215    /// More than one frame is not enough: some themes ship multi-frame packs
216    /// with a zero delay on every frame, which would otherwise loop instantly
217    /// and flicker.
218    #[must_use]
219    pub fn is_animated(&self) -> bool {
220        self.frames.len() > 1 && !self.cycle.is_zero()
221    }
222
223    /// The frame showing at `elapsed` since the cursor started displaying.
224    ///
225    /// Deterministic and total: a static cursor, or one whose frames all
226    /// declare no delay, always answers with the first frame.
227    #[must_use]
228    pub fn frame_at(&self, elapsed: Duration) -> &Frame {
229        let index = self.frame_index_at(elapsed);
230        self.frames.get(index).unwrap_or_else(|| self.first())
231    }
232
233    /// Which frame is showing at `elapsed`.
234    ///
235    /// The index rather than the frame, so a caller redrawing into a buffer
236    /// can tell that nothing has changed and skip the work. A cursor ticking
237    /// at 60Hz against a 100ms animation redraws on three frames in eighteen.
238    #[must_use]
239    pub fn frame_index_at(&self, elapsed: Duration) -> usize {
240        if !self.is_animated() {
241            return 0;
242        }
243        // Both are non-zero here, so the remainder is well defined.
244        let into_cycle = Duration::from_nanos(
245            u64::try_from(elapsed.as_nanos() % self.cycle.as_nanos()).unwrap_or(0),
246        );
247
248        let mut accumulated = Duration::ZERO;
249        for (index, frame) in self.frames.iter().enumerate() {
250            accumulated += frame.delay;
251            if into_cycle < accumulated {
252                return index;
253            }
254        }
255        // Unreachable while the delays sum to the cycle, but the last frame is
256        // the right answer if they ever stop agreeing.
257        self.frames.len().saturating_sub(1)
258    }
259}