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}