drmkit_cursor/stage.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Writing a cursor's position onto its plane.
5
6use drmkit_core::{AtomicRequest, PropertyStore};
7
8use crate::CursorError;
9use crate::sprite::Rotation;
10
11/// Where the plane's top-left corner goes, given where the pointer is.
12///
13/// The hotspot is the pixel that has to land under the pointer -- the tip of
14/// the arrow, the center of a crosshair -- so the plane is offset back by it.
15/// Getting this wrong is invisible in a screenshot and obvious the moment
16/// anyone clicks: the sprite looks right and the click lands somewhere else.
17///
18/// The result is signed and may be negative, which is correct: a cursor near
19/// the top-left of the screen has its buffer hanging off the edge.
20#[must_use]
21pub fn plane_origin(pointer: (i32, i32), hotspot: (u32, u32)) -> (i32, i32) {
22 let back = |coordinate: i32, offset: u32| {
23 i32::try_from(offset).map_or(coordinate, |offset| coordinate.saturating_sub(offset))
24 };
25 (back(pointer.0, hotspot.0), back(pointer.1, hotspot.1))
26}
27
28/// How far to move the sprite up and left inside its buffer so the plane need
29/// not start above or left of the CRTC (drm-cxx#384).
30///
31/// Some drivers -- the SA8155P's -- refuse a negative `CRTC_X`/`CRTC_Y`, and
32/// the usual fix, trimming the source, is refused by others: i915 and amdgpu
33/// take no source offset on a cursor plane. Moving the pixels works on all of
34/// them. The plane goes to the edge and the sprite is drawn shifted by what
35/// would have hung off it.
36///
37/// At most the hotspot, so the hotspot never leaves the buffer: a pointer
38/// that is itself off the top or left of the CRTC keeps a negative position,
39/// as before.
40#[must_use]
41pub fn edge_shift(pointer: (i32, i32), hotspot: (u32, u32)) -> (u32, u32) {
42 let (x, y) = plane_origin(pointer, hotspot);
43 let shift = |origin: i32, hotspot: u32| {
44 u32::try_from(-i64::from(origin)).map_or(0, |overhang| overhang.min(hotspot))
45 };
46 (shift(x, hotspot.0), shift(y, hotspot.1))
47}
48
49/// What to write to the plane's `rotation` property.
50///
51/// The requested rotation when the plane advertises it, and `ROTATE_0`
52/// otherwise -- because in that case the pixels were already turned in
53/// software, and asking the hardware to turn them again would turn them twice.
54///
55/// Written every commit rather than when it changes: a plane that went dark
56/// during a hide, or a descriptor replaced across a session resume, has lost
57/// whatever the kernel knew, and re-arming is cheaper than tracking that.
58#[must_use]
59pub fn hardware_rotation(requested: Rotation, supported_mask: u64) -> u64 {
60 let want = requested.drm_mask();
61 if supported_mask & want != 0 {
62 want
63 } else {
64 Rotation::None.drm_mask()
65 }
66}
67
68/// The state a cursor plane is put into for one frame.
69///
70/// Distinct from [`crate::sprite::Placement`], which says where the sprite
71/// landed inside the buffer. This says where the buffer lands on the screen.
72#[derive(Debug, Clone, Copy)]
73#[allow(
74 clippy::exhaustive_structs,
75 reason = "built by callers, with no default to fill a new field from"
76)]
77pub struct PlaneState {
78 /// The plane being written.
79 pub plane_id: u32,
80 /// The CRTC it feeds.
81 pub crtc_id: u32,
82 /// The framebuffer to scan out.
83 pub fb_id: u32,
84 /// Buffer dimensions. Source and destination match: a cursor plane is not
85 /// asked to scale.
86 pub width: u32,
87 /// As [`PlaneState::width`].
88 pub height: u32,
89 /// Where the pointer is on the CRTC.
90 pub pointer_x: i32,
91 /// As [`PlaneState::pointer_x`].
92 pub pointer_y: i32,
93 /// The hotspot in buffer coordinates, from
94 /// [`compose`](crate::sprite::compose).
95 ///
96 /// `None` before a cursor has been drawn, which is a real state: the
97 /// buffer exists so the size can be probed, but nothing is in it yet.
98 pub hotspot: Option<(u32, u32)>,
99 /// How the sprite is turned.
100 pub rotation: Rotation,
101 /// The rotations the plane can do in hardware, as a `DRM_MODE_ROTATE_*`
102 /// mask. Zero when it has no `rotation` property.
103 ///
104 /// Supplied rather than read back from the property, because `rotation` is
105 /// a bitmask and a property store reports ranges. Guessing here would make
106 /// every rotation look supported and turn the sprite twice: once in
107 /// software, once by the hardware.
108 pub rotation_mask: u64,
109}
110
111/// Write `placement` into `request`.
112///
113/// Every property, every commit. Drivers accept partial writes, but a plane
114/// that went dark during a hide needs all of it re-armed to come back, and
115/// writing the lot is cheaper than tracking which fields a given transition
116/// dirtied.
117///
118/// `rotation`, `HOTSPOT_X` and `HOTSPOT_Y` are optional and skipped when the
119/// plane does not carry them. The hotspot properties are a hint for
120/// virtualized drivers, which need to know where inside the sprite the pointer
121/// actually is; before anything has been drawn they are left alone rather than
122/// clobbered with a stale zero.
123///
124/// Returns how many properties were written.
125///
126/// # Errors
127///
128/// [`CursorError::MissingProperty`] if the plane lacks one of the properties
129/// every plane is required to have. That is a broken driver rather than
130/// anything a caller did.
131pub fn stage(
132 request: &mut AtomicRequest,
133 props: &PropertyStore,
134 placement: &PlaneState,
135) -> Result<usize, CursorError> {
136 let (x, y) = plane_origin(
137 (placement.pointer_x, placement.pointer_y),
138 placement.hotspot.unwrap_or((0, 0)),
139 );
140
141 let mut written = 0;
142 let mut put = |name: &str, value: u64| -> Result<(), CursorError> {
143 let id = props
144 .property_id(placement.plane_id, name)
145 .map_err(|_| CursorError::MissingProperty)?;
146 request
147 .add_property(placement.plane_id, id, value)
148 .map_err(|_| CursorError::MissingProperty)?;
149 written += 1;
150 Ok(())
151 };
152
153 put("FB_ID", u64::from(placement.fb_id))?;
154 put("CRTC_ID", u64::from(placement.crtc_id))?;
155 // Signed, carried in the unsigned slot as a 64-bit two's complement: the
156 // kernel range-checks the value as an `i64`, so a 32-bit one reads as a
157 // large positive number and the commit is refused.
158 put("CRTC_X", i64::from(x).cast_unsigned())?;
159 put("CRTC_Y", i64::from(y).cast_unsigned())?;
160 put("CRTC_W", u64::from(placement.width))?;
161 put("CRTC_H", u64::from(placement.height))?;
162 put("SRC_X", 0)?;
163 put("SRC_Y", 0)?;
164 // The source rectangle is 16.16 fixed point; the CRTC rectangle is plain
165 // pixels. Sending either in the other's units is a cursor that fills the
166 // screen or vanishes.
167 put("SRC_W", u64::from(placement.width) << 16)?;
168 put("SRC_H", u64::from(placement.height) << 16)?;
169
170 if let Ok(id) = props.property_id(placement.plane_id, "rotation") {
171 let value = hardware_rotation(placement.rotation, placement.rotation_mask);
172 if request.add_property(placement.plane_id, id, value).is_err() {
173 return Err(CursorError::MissingProperty);
174 }
175 written += 1;
176 }
177
178 if let Some((hotspot_x, hotspot_y)) = placement.hotspot {
179 for (name, value) in [("HOTSPOT_X", hotspot_x), ("HOTSPOT_Y", hotspot_y)] {
180 if let Ok(id) = props.property_id(placement.plane_id, name) {
181 if request
182 .add_property(placement.plane_id, id, u64::from(value))
183 .is_err()
184 {
185 return Err(CursorError::MissingProperty);
186 }
187 written += 1;
188 }
189 }
190 }
191
192 Ok(written)
193}