drmkit_cursor/sprite.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Composing a cursor frame into a plane buffer.
5//!
6//! A cursor plane is a fixed size the driver chooses -- 64x64 on i915, 256x256
7//! on amdgpu -- and a themed cursor is whatever size the theme ships. So a
8//! frame has to be rotated if the display is, shrunk if it is too big,
9//! centered, and the hotspot has to move with it. All of that is arithmetic,
10//! and all of it is here, away from anything that needs a device.
11//!
12//! The hotspot comes back from the same call that writes the pixels.
13//! The reference computes it separately, from state the blit left behind, so
14//! the two can disagree if they are called out of order; returning them
15//! together makes that unrepresentable.
16
17use crate::cursor::Frame;
18
19/// Half a unit in the 16.16 fixed point the shrink ratio uses.
20const HALF_16_16: u64 = 1 << 15;
21
22/// How the sprite is turned before it reaches the plane.
23///
24/// Reflections are not here. No driver has asked for them, and a flag nobody
25/// sets is a branch nobody tests.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27#[allow(
28 clippy::exhaustive_enums,
29 reason = "a closed set: there is no further variant to add"
30)]
31pub enum Rotation {
32 /// Upright.
33 #[default]
34 None,
35 /// A quarter turn clockwise.
36 Quarter,
37 /// Half a turn.
38 Half,
39 /// Three quarters clockwise.
40 ThreeQuarter,
41}
42
43impl Rotation {
44 /// The `DRM_MODE_ROTATE_*` bit for this rotation.
45 ///
46 /// Restated rather than pulled from a binding: these are UAPI constants,
47 /// and the whole point of asking is to compare against a plane's supported
48 /// mask before deciding whether the hardware can do the turn.
49 #[must_use]
50 pub const fn drm_mask(self) -> u64 {
51 match self {
52 Self::None => 1 << 0,
53 Self::Quarter => 1 << 1,
54 Self::Half => 1 << 2,
55 Self::ThreeQuarter => 1 << 3,
56 }
57 }
58
59 /// Whether this rotation exchanges width and height.
60 #[must_use]
61 pub const fn swaps_axes(self) -> bool {
62 matches!(self, Self::Quarter | Self::ThreeQuarter)
63 }
64
65 /// The size of `(width, height)` after turning.
66 const fn rotated_size(self, width: u32, height: u32) -> (u32, u32) {
67 if self.swaps_axes() {
68 (height, width)
69 } else {
70 (width, height)
71 }
72 }
73}
74
75/// Where the sprite ended up in the buffer.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub struct Placement {
79 /// Size of the sprite as written, after rotation and any shrink.
80 pub width: u32,
81 /// As [`Placement::width`].
82 pub height: u32,
83 /// Top-left corner of the sprite within the buffer.
84 pub x: u32,
85 /// As [`Placement::x`].
86 pub y: u32,
87 /// The hotspot, in buffer coordinates.
88 ///
89 /// This is what goes in `HOTSPOT_X`, and what a position has to be
90 /// adjusted by: the plane is placed so the hotspot lands on the pointer,
91 /// not so its corner does.
92 pub hotspot_x: u32,
93 /// As [`Placement::hotspot_x`].
94 pub hotspot_y: u32,
95}
96
97/// One pixel of the rotated view of `frame`.
98///
99/// `(rx, ry)` are coordinates in the rotated image. The formulas are inverse
100/// rotations -- they map where a pixel is going back to where it came from --
101/// which is what lets rotation and scaling compose without an intermediate
102/// buffer.
103fn sample_rotated(frame: &Frame, rotation: Rotation, rx: u32, ry: u32) -> u32 {
104 let (sx, sy) = match rotation {
105 Rotation::None => (rx, ry),
106 Rotation::Quarter => (ry, frame.height - 1 - rx),
107 Rotation::Half => (frame.width - 1 - rx, frame.height - 1 - ry),
108 Rotation::ThreeQuarter => (frame.width - 1 - ry, rx),
109 };
110 frame.pixels[(sy * frame.width + sx) as usize]
111}
112
113/// The region of a plane buffer a sprite is being written into.
114struct Target<'a> {
115 pixels: &'a mut [u32],
116 /// Index of the region's top-left pixel within `pixels`.
117 offset: usize,
118 stride_px: usize,
119 width: u32,
120 height: u32,
121}
122
123/// Area-weighted box downscale of the rotated frame into `target`.
124///
125/// Pixels are premultiplied, so averaging the components straight is correct
126/// -- there is no un-premultiply detour, and doing one would be wrong.
127///
128/// Fixed point 24.8 throughout: each destination pixel's preimage is a box in
129/// source space, and every source pixel it touches contributes the fraction of
130/// itself that falls inside. Integer arithmetic only, so the result does not
131/// depend on the floating-point mode of whoever is calling.
132fn box_downscale(frame: &Frame, rotation: Rotation, rotated: (u32, u32), target: &mut Target<'_>) {
133 let (rotated_w, rotated_h) = rotated;
134 let fx = (u64::from(rotated_w) << 8) / u64::from(target.width);
135 let fy = (u64::from(rotated_h) << 8) / u64::from(target.height);
136
137 for dy in 0..target.height {
138 let top = u64::from(dy) * fy;
139 let bottom = top + fy;
140 let first_row = top >> 8;
141 let last_row = (bottom + 0xff) >> 8;
142
143 for dx in 0..target.width {
144 let left = u64::from(dx) * fx;
145 let right = left + fx;
146 let first_col = left >> 8;
147 let last_col = (right + 0xff) >> 8;
148
149 let mut acc = [0u64; 4];
150 let mut total = 0u64;
151 for sy in first_row..last_row {
152 let covered_h = bottom.min((sy + 1) << 8) - top.max(sy << 8);
153 for sx in first_col..last_col {
154 let covered_w = right.min((sx + 1) << 8) - left.max(sx << 8);
155 let weight = covered_w * covered_h;
156
157 // Clamped because the ceilings above can reach one past
158 // the last source pixel when the box ends exactly on a
159 // boundary; that column contributes zero width anyway.
160 let pixel = sample_rotated(
161 frame,
162 rotation,
163 u32::try_from(sx).unwrap_or(0).min(rotated_w - 1),
164 u32::try_from(sy).unwrap_or(0).min(rotated_h - 1),
165 );
166 for (channel, shift) in [24, 16, 8, 0].into_iter().enumerate() {
167 acc[channel] += weight * u64::from((pixel >> shift) & 0xff);
168 }
169 total += weight;
170 }
171 }
172
173 if total == 0 {
174 continue;
175 }
176 let half = total / 2;
177 let mut out = 0u32;
178 for (channel, shift) in [24, 16, 8, 0].into_iter().enumerate() {
179 let value = u32::try_from((acc[channel] + half) / total).unwrap_or(0xff) & 0xff;
180 out |= value << shift;
181 }
182 let at = target.offset + dy as usize * target.stride_px + dx as usize;
183 target.pixels[at] = out;
184 }
185 }
186}
187
188/// Compose `frame` into a cursor plane buffer, and say where it landed.
189///
190/// The buffer is wiped first. A frame smaller than the buffer must not leave
191/// the previous frame showing around its edges, and a rotation can move the
192/// sprite off its own former footprint entirely -- either would ghost on
193/// screen.
194///
195/// A frame larger than the buffer on either axis is shrunk, both axes by the
196/// same ratio so it stays proportional. That is not a rare path: a theme picks
197/// the nearest size it ships, not the nearest size at or below what was asked
198/// for, so a 64-pixel plane routinely gets a 72-pixel sprite.
199///
200/// # Panics
201///
202/// If `dst` is too small for `dst_h` rows of `stride_px`, if `stride_px` is
203/// less than `dst_w`, or if `frame` is inconsistent -- fewer pixels than
204/// `width * height`, or a zero dimension. These are caller errors; a loaded
205/// [`Cursor`](crate::Cursor) cannot cause them.
206pub fn compose(
207 frame: &Frame,
208 rotation: Rotation,
209 dst: &mut [u32],
210 dst_w: u32,
211 dst_h: u32,
212 stride_px: usize,
213) -> Placement {
214 assert!(
215 dst.len() >= dst_h as usize * stride_px,
216 "destination holds {} pixels, needs {}",
217 dst.len(),
218 dst_h as usize * stride_px
219 );
220
221 for y in 0..dst_h as usize {
222 let row = y * stride_px;
223 dst[row..row + dst_w as usize].fill(0);
224 }
225
226 let (rotated_w, rotated_h) = rotation.rotated_size(frame.width, frame.height);
227 let shrink = rotated_w > dst_w || rotated_h > dst_h;
228
229 let (blit_w, blit_h) = if shrink {
230 // The more aggressive of the two ratios drives both axes, so a sprite
231 // that overflows on one axis only still keeps its proportions.
232 let rx = (u64::from(dst_w) << 16) / u64::from(rotated_w);
233 let ry = (u64::from(dst_h) << 16) / u64::from(rotated_h);
234 let ratio = rx.min(ry);
235 // Rounded, not truncated: a 72 -> 64 shrink has to land on 64. Losing
236 // the last row and column to truncation would leave a black border
237 // for nothing.
238 let scaled = |extent: u32| {
239 let value =
240 u32::try_from(((u64::from(extent) * ratio) + HALF_16_16) >> 16).unwrap_or(1);
241 value.max(1)
242 };
243 (scaled(rotated_w).min(dst_w), scaled(rotated_h).min(dst_h))
244 } else {
245 (rotated_w.min(dst_w), rotated_h.min(dst_h))
246 };
247
248 let x_off = if dst_w > blit_w {
249 (dst_w - blit_w) / 2
250 } else {
251 0
252 };
253 let y_off = if dst_h > blit_h {
254 (dst_h - blit_h) / 2
255 } else {
256 0
257 };
258 let base = y_off as usize * stride_px + x_off as usize;
259
260 if shrink {
261 box_downscale(
262 frame,
263 rotation,
264 (rotated_w, rotated_h),
265 &mut Target {
266 pixels: dst,
267 offset: base,
268 stride_px,
269 width: blit_w,
270 height: blit_h,
271 },
272 );
273 } else {
274 for y in 0..blit_h {
275 for x in 0..blit_w {
276 let at = base + y as usize * stride_px + x as usize;
277 dst[at] = sample_rotated(frame, rotation, x, y);
278 }
279 }
280 }
281
282 let (hotspot_x, hotspot_y) = hotspot_in_buffer(frame, rotation, blit_w, blit_h, x_off, y_off);
283
284 Placement {
285 width: blit_w,
286 height: blit_h,
287 x: x_off,
288 y: y_off,
289 hotspot_x,
290 hotspot_y,
291 }
292}
293
294/// Where the hotspot ends up after the same rotation, shrink and centring the
295/// pixels got.
296///
297/// Scaling truncates here where the pixels round. That is the reference's
298/// behavior and it is the conservative direction: the hotspot stays inside
299/// the sprite rather than stepping one pixel past its edge.
300fn hotspot_in_buffer(
301 frame: &Frame,
302 rotation: Rotation,
303 blit_w: u32,
304 blit_h: u32,
305 x_off: u32,
306 y_off: u32,
307) -> (u32, u32) {
308 let (mut hx, mut hy) = match rotation {
309 Rotation::None => (frame.xhot, frame.yhot),
310 Rotation::Quarter => (frame.height.saturating_sub(1 + frame.yhot), frame.xhot),
311 Rotation::Half => (
312 frame.width.saturating_sub(1 + frame.xhot),
313 frame.height.saturating_sub(1 + frame.yhot),
314 ),
315 Rotation::ThreeQuarter => (frame.yhot, frame.width.saturating_sub(1 + frame.xhot)),
316 };
317 let (rotated_w, rotated_h) = rotation.rotated_size(frame.width, frame.height);
318
319 if blit_w != 0 && blit_w != rotated_w && rotated_w != 0 {
320 hx = u32::try_from(u64::from(hx) * u64::from(blit_w) / u64::from(rotated_w)).unwrap_or(0);
321 }
322 if blit_h != 0 && blit_h != rotated_h && rotated_h != 0 {
323 hy = u32::try_from(u64::from(hy) * u64::from(blit_h) / u64::from(rotated_h)).unwrap_or(0);
324 }
325
326 (hx + x_off, hy + y_off)
327}