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}