Skip to main content

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}