1use std::time::Duration;
25
26use edit::CursorSample;
27
28#[must_use]
33#[allow(
34 clippy::cast_possible_truncation,
35 reason = "the normalized result is in [0, 1], well within f32 precision"
36)]
37pub fn normalize_cursor_to_frame(point: (f64, f64), rect: (f64, f64, f64, f64)) -> (f32, f32) {
38 let (px, py) = point;
39 let (rx, ry, rw, rh) = rect;
40 let nx = if rw > 0.0 {
41 ((px - rx) / rw).clamp(0.0, 1.0)
42 } else {
43 0.0
44 };
45 let ny = if rh > 0.0 {
46 ((py - ry) / rh).clamp(0.0, 1.0)
47 } else {
48 0.0
49 };
50 (nx as f32, ny as f32)
51}
52
53#[must_use]
58#[allow(
59 clippy::cast_possible_truncation,
60 clippy::cast_sign_loss,
61 reason = "elapsed·fps is a non-negative frame index well under 2^52; the f64→u64 cast is floor of a clamped-non-negative value"
62)]
63pub fn samples_to_track(samples: &[(Duration, f32, f32)], project_fps: u32) -> Vec<CursorSample> {
64 let fps = f64::from(project_fps.max(1));
65 let mut out: Vec<CursorSample> = Vec::new();
66 for &(t, x, y) in samples {
67 let frame = (t.as_secs_f64() * fps).max(0.0) as u64;
68 match out.last_mut() {
69 Some(last) if last.frame == frame => {
72 last.x = x;
73 last.y = y;
74 }
75 _ => out.push(CursorSample::new(frame, x, y)),
76 }
77 }
78 out
79}
80
81#[must_use]
87pub fn main_display_bounds() -> (f64, f64, f64, f64) {
88 #[cfg(target_os = "macos")]
89 {
90 let bounds = objc2_core_graphics::CGDisplayBounds(objc2_core_graphics::CGMainDisplayID());
91 (
92 bounds.origin.x,
93 bounds.origin.y,
94 bounds.size.width,
95 bounds.size.height,
96 )
97 }
98 #[cfg(not(target_os = "macos"))]
99 {
100 (0.0, 0.0, 1920.0, 1080.0)
101 }
102}
103
104#[must_use]
108pub fn parse_display_id(source_id: Option<&str>) -> Option<u32> {
109 source_id?.strip_prefix("display-")?.parse::<u32>().ok()
110}
111
112#[must_use]
118pub fn display_bounds_for_source(source_id: Option<&str>) -> (f64, f64, f64, f64) {
119 #[cfg(target_os = "macos")]
120 {
121 if let Some(id) = parse_display_id(source_id) {
122 let bounds = objc2_core_graphics::CGDisplayBounds(id);
123 return (
124 bounds.origin.x,
125 bounds.origin.y,
126 bounds.size.width,
127 bounds.size.height,
128 );
129 }
130 main_display_bounds()
131 }
132 #[cfg(not(target_os = "macos"))]
133 {
134 let _ = parse_display_id(source_id);
136 main_display_bounds()
137 }
138}
139
140#[cfg(target_os = "macos")]
141mod imp {
142 use std::sync::Arc;
143 use std::sync::atomic::{AtomicBool, Ordering};
144 use std::thread::JoinHandle;
145 use std::time::{Duration, Instant};
146
147 use objc2_core_graphics::CGEvent;
148
149 use super::normalize_cursor_to_frame;
150
151 pub struct CursorPoller {
156 stop: Arc<AtomicBool>,
157 handle: Option<JoinHandle<Vec<(Duration, f32, f32)>>>,
158 }
159
160 impl CursorPoller {
161 #[must_use]
164 pub fn start(rect: (f64, f64, f64, f64)) -> Self {
165 let stop = Arc::new(AtomicBool::new(false));
166 let stop_thread = Arc::clone(&stop);
167 let handle = std::thread::spawn(move || {
168 let mut samples: Vec<(Duration, f32, f32)> = Vec::new();
169 let start = Instant::now();
170 while !stop_thread.load(Ordering::Relaxed) {
171 if let Some(event) = CGEvent::new(None) {
174 let p = CGEvent::location(Some(&event));
175 let (x, y) = normalize_cursor_to_frame((p.x, p.y), rect);
176 samples.push((start.elapsed(), x, y));
177 }
178 std::thread::sleep(Duration::from_millis(16));
179 }
180 samples
181 });
182 Self {
183 stop,
184 handle: Some(handle),
185 }
186 }
187
188 #[must_use]
191 pub fn stop(mut self) -> Vec<(Duration, f32, f32)> {
192 self.stop.store(true, Ordering::Relaxed);
193 self.handle
194 .take()
195 .and_then(|h| h.join().ok())
196 .unwrap_or_default()
197 }
198 }
199
200 impl Drop for CursorPoller {
201 fn drop(&mut self) {
202 self.stop.store(true, Ordering::Relaxed);
205 if let Some(h) = self.handle.take() {
206 let _ = h.join();
207 }
208 }
209 }
210}
211
212#[cfg(not(target_os = "macos"))]
213mod imp {
214 use std::time::Duration;
215
216 pub struct CursorPoller;
219
220 impl CursorPoller {
221 #[must_use]
223 pub fn start(_rect: (f64, f64, f64, f64)) -> Self {
224 Self
225 }
226
227 #[must_use]
229 pub fn stop(self) -> Vec<(Duration, f32, f32)> {
230 Vec::new()
231 }
232 }
233}
234
235pub use imp::CursorPoller;
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn normalize_maps_rect_to_unit_square() {
243 let rect = (0.0, 0.0, 1920.0, 1080.0);
245 let (cx, cy) = normalize_cursor_to_frame((960.0, 540.0), rect);
246 assert!((cx - 0.5).abs() < 1e-4 && (cy - 0.5).abs() < 1e-4, "centre");
247 let (tx, ty) = normalize_cursor_to_frame((0.0, 0.0), rect);
248 assert!(tx.abs() < 1e-4 && ty.abs() < 1e-4, "top-left origin");
249 let (bx, by) = normalize_cursor_to_frame((1920.0, 1080.0), rect);
250 assert!(
251 (bx - 1.0).abs() < 1e-4 && (by - 1.0).abs() < 1e-4,
252 "bottom-right"
253 );
254 }
255
256 #[test]
257 fn normalize_is_relative_to_a_non_zero_origin() {
258 let rect = (1920.0, 0.0, 1280.0, 720.0);
260 let (cx, cy) = normalize_cursor_to_frame((1920.0 + 640.0, 360.0), rect);
261 assert!((cx - 0.5).abs() < 1e-4 && (cy - 0.5).abs() < 1e-4);
262 }
263
264 #[test]
265 fn normalize_clamps_outside_and_survives_zero_size() {
266 let rect = (0.0, 0.0, 100.0, 100.0);
267 let (lx, ly) = normalize_cursor_to_frame((-50.0, 250.0), rect);
268 assert!(
269 lx.abs() < 1e-6 && (ly - 1.0).abs() < 1e-6,
270 "clamped to edges"
271 );
272 let (zx, zy) = normalize_cursor_to_frame((10.0, 10.0), (0.0, 0.0, 0.0, 100.0));
274 assert!(zx.abs() < 1e-6 && (zy - 0.1).abs() < 1e-6);
275 }
276
277 #[test]
278 fn samples_to_track_resamples_onto_the_frame_grid() {
279 let samples = [
281 (Duration::from_millis(0), 0.1, 0.1),
282 (Duration::from_millis(10), 0.2, 0.2), (Duration::from_millis(40), 0.5, 0.6), ];
285 let track = samples_to_track(&samples, 30);
286 assert_eq!(track.len(), 2, "two distinct frames");
287 assert_eq!(track[0].frame, 0);
288 assert!((track[0].x - 0.2).abs() < 1e-6 && (track[0].y - 0.2).abs() < 1e-6);
290 assert_eq!(track[1].frame, 1);
291 assert!((track[1].x - 0.5).abs() < 1e-6);
292 }
293
294 #[test]
295 fn samples_to_track_empty_is_empty() {
296 assert!(samples_to_track(&[], 30).is_empty());
297 }
298
299 #[test]
300 fn parse_display_id_handles_the_source_id_forms() {
301 assert_eq!(parse_display_id(Some("display-69733382")), Some(69_733_382));
304 assert_eq!(parse_display_id(Some("display-1")), Some(1));
305 assert_eq!(parse_display_id(None), None, "primary display");
306 assert_eq!(parse_display_id(Some("")), None);
307 assert_eq!(parse_display_id(Some("window-42")), None, "window source");
308 assert_eq!(parse_display_id(Some("display-")), None, "malformed");
309 assert_eq!(parse_display_id(Some("display-abc")), None, "non-numeric");
310 }
311}