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 the size it would load has more frames,
64    ///   or larger ones, 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(), size)?;
102        let frames: Vec<Frame> = images
103            .into_iter()
104            .map(|image| Frame {
105                pixels: image.pixels,
106                width: image.width,
107                height: image.height,
108                xhot: image.xhot,
109                yhot: image.yhot,
110                delay: Duration::from_millis(u64::from(image.delay_ms)),
111            })
112            .collect();
113
114        Self::assemble(frames)
115    }
116
117    /// Build a static cursor from caller-supplied pixels.
118    ///
119    /// The in-process fallback for when no cursor theme is installed. Pixels
120    /// are premultiplied `0xAARRGGBB`, row-major, and are copied.
121    ///
122    /// # Errors
123    ///
124    /// [`CursorError::InvalidImage`] for a zero or oversize dimension, or a
125    /// pixel count that does not match the dimensions. A caller bug, surfaced
126    /// here rather than as a surprise at render time.
127    pub fn from_argb(
128        pixels: &[u32],
129        width: u32,
130        height: u32,
131        xhot: u32,
132        yhot: u32,
133    ) -> Result<Self, CursorError> {
134        let limits = Limits::default();
135        if width == 0 || height == 0 || width > limits.max_dim || height > limits.max_dim {
136            return Err(CursorError::InvalidImage);
137        }
138        if pixels.len() != width as usize * height as usize {
139            return Err(CursorError::InvalidImage);
140        }
141        Self::assemble(vec![Frame {
142            pixels: pixels.to_vec(),
143            width,
144            height,
145            xhot,
146            yhot,
147            delay: Duration::ZERO,
148        }])
149    }
150
151    /// Build a cursor from frames the caller already has.
152    ///
153    /// The many-frame form of [`Cursor::from_argb`], for a caller generating
154    /// cursors rather than loading them -- a compositor drawing its own
155    /// spinner, or a test. Frames play in the order given.
156    ///
157    /// # Errors
158    ///
159    /// - [`CursorError::NoImages`] for an empty set.
160    /// - [`CursorError::InvalidImage`] if any frame's pixel count disagrees
161    ///   with its dimensions, or a dimension is zero or past the limit.
162    /// - [`CursorError::TooLarge`] for more frames than the loader accepts.
163    pub fn from_frames(frames: Vec<Frame>) -> Result<Self, CursorError> {
164        let limits = Limits::default();
165        for frame in &frames {
166            if frame.width == 0
167                || frame.height == 0
168                || frame.width > limits.max_dim
169                || frame.height > limits.max_dim
170            {
171                return Err(CursorError::InvalidImage);
172            }
173            if frame.pixels.len() != frame.width as usize * frame.height as usize {
174                return Err(CursorError::InvalidImage);
175            }
176        }
177        if frames.len() > limits.max_frames {
178            return Err(CursorError::TooLarge);
179        }
180        Self::assemble(frames)
181    }
182
183    /// Assemble from frames, computing the cycle.
184    fn assemble(frames: Vec<Frame>) -> Result<Self, CursorError> {
185        if frames.is_empty() {
186            return Err(CursorError::NoImages);
187        }
188        let cycle = frames.iter().map(|frame| frame.delay).sum();
189        Ok(Self { frames, cycle })
190    }
191
192    /// The first frame, which is what a static cursor shows.
193    #[must_use]
194    pub fn first(&self) -> &Frame {
195        // `from_frames` refuses an empty set, so there is always one.
196        &self.frames[0]
197    }
198
199    /// Every frame, in play order.
200    #[must_use]
201    pub fn frames(&self) -> &[Frame] {
202        &self.frames
203    }
204
205    /// How long one full loop takes. Zero for a static cursor.
206    #[must_use]
207    pub const fn cycle(&self) -> Duration {
208        self.cycle
209    }
210
211    /// Whether this cursor actually animates.
212    ///
213    /// More than one frame is not enough: some themes ship multi-frame packs
214    /// with a zero delay on every frame, which would otherwise loop instantly
215    /// and flicker.
216    #[must_use]
217    pub fn is_animated(&self) -> bool {
218        self.frames.len() > 1 && !self.cycle.is_zero()
219    }
220
221    /// The frame showing at `elapsed` since the cursor started displaying.
222    ///
223    /// Deterministic and total: a static cursor, or one whose frames all
224    /// declare no delay, always answers with the first frame.
225    #[must_use]
226    pub fn frame_at(&self, elapsed: Duration) -> &Frame {
227        let index = self.frame_index_at(elapsed);
228        self.frames.get(index).unwrap_or_else(|| self.first())
229    }
230
231    /// Which frame is showing at `elapsed`.
232    ///
233    /// The index rather than the frame, so a caller redrawing into a buffer
234    /// can tell that nothing has changed and skip the work. A cursor ticking
235    /// at 60 Hz through 100 ms frames redraws on one tick in six.
236    #[must_use]
237    pub fn frame_index_at(&self, elapsed: Duration) -> usize {
238        if !self.is_animated() {
239            return 0;
240        }
241        // Both are non-zero here, so the remainder is well defined.
242        let into_cycle = Duration::from_nanos(
243            u64::try_from(elapsed.as_nanos() % self.cycle.as_nanos()).unwrap_or(0),
244        );
245
246        let mut accumulated = Duration::ZERO;
247        for (index, frame) in self.frames.iter().enumerate() {
248            accumulated += frame.delay;
249            if into_cycle < accumulated {
250                return index;
251            }
252        }
253        // Unreachable while the delays sum to the cycle, but the last frame is
254        // the right answer if they ever stop agreeing.
255        self.frames.len().saturating_sub(1)
256    }
257}