Skip to main content

screen_app/
cursor_capture.rs

1//! Cursor telemetry capture (ED.17 / M-EDIT).
2//!
3//! Records where the cursor was during a recording so the editor can drive
4//! the cursor overlay (ED.19) and auto-zoom (ED.17's already-tested
5//! [`auto_zoom_segments`](edit::telemetry::auto_zoom_segments)) consumer.
6//!
7//! ## What this captures, and what it doesn't
8//!
9//! The **cursor position track** is captured by polling the global cursor
10//! location — `CGEventCreate(NULL)` + `CGEventGetLocation`, which read the
11//! current pointer position with **no Input-Monitoring permission** and no
12//! event tap. A background thread samples at ~60 Hz; at stop the timestamped
13//! samples are resampled onto the project frame grid ([`samples_to_track`]).
14//!
15//! The **click log** (for click ripples + auto-zoom) needs a `CGEventTap`,
16//! which *does* require Input-Monitoring permission and a `CFRunLoop`
17//! callback — that is its sibling module
18//! [`click_capture`](crate::click_capture) (ISS-16, resolved). This module
19//! ships the no-permission position half.
20//!
21//! The pure parts ([`normalize_cursor_to_frame`], [`samples_to_track`]) are
22//! exhaustively unit-tested; the macOS poller thread is runtime-only.
23
24use std::time::Duration;
25
26use edit::CursorSample;
27
28/// Normalize a global cursor `point` to `[0, 1]²` within the captured display
29/// `rect` (`(origin_x, origin_y, width, height)`), top-left origin — the
30/// [`CursorSample`] convention. Points outside the rect clamp to the edge; a
31/// zero-size axis maps to `0.0`. Pure.
32#[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/// Resample timestamped cursor samples (`(elapsed_since_start, x, y)`, sorted
54/// by time) onto the project frame grid: each sample's frame is
55/// `floor(elapsed_secs · project_fps)`, and the latest sample at a frame
56/// wins (one [`CursorSample`] per frame). Pure.
57#[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            // Collapse consecutive samples that land on the same frame —
70            // keep the latest position at that frame.
71            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/// The main display's bounds in CG points (`(origin_x, origin_y, width,
82/// height)`) — the rect [`CursorPoller`] normalizes the global cursor against.
83/// On non-macOS (no capture) returns a 1080p placeholder. The captured
84/// display is assumed to be the main one (multi-display targeting is a
85/// refinement — see ISS-17).
86#[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/// Parse a `CGDirectDisplayID` out of a recording's screen-source id
105/// (`"display-<id>"`). Returns `None` for the primary display (`None` / `""`),
106/// a window source (`"window-..."`), or a malformed id. Pure.
107#[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/// Bounds (CG points) of the *captured* display, from the recording's
113/// screen-source id (ISS-17). `"display-<id>"` → that display's
114/// [`main_display_bounds`]-style rect; primary / window / malformed → the main
115/// display (window-source framing is a further refinement). Non-macOS: the
116/// 1080p placeholder.
117#[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        // Keep `source_id` used + the parse path exercised off macOS.
135        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    /// Polls the global cursor position on a background thread for the
152    /// duration of a recording (ED.17). No Input-Monitoring permission:
153    /// `CGEventCreate(NULL)` returns an event populated with the current
154    /// pointer state, and `CGEventGetLocation` reads it.
155    pub struct CursorPoller {
156        stop: Arc<AtomicBool>,
157        handle: Option<JoinHandle<Vec<(Duration, f32, f32)>>>,
158    }
159
160    impl CursorPoller {
161        /// Start polling at ~60 Hz, normalizing each sample to `rect`
162        /// (`(origin_x, origin_y, width, height)` in CG points).
163        #[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                    // `CGEvent::new(None)` == CGEventCreate(NULL): an event
172                    // carrying the current mouse location (no permission).
173                    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        /// Stop polling and return the timestamped samples (sorted by time).
189        /// Feed them to [`super::samples_to_track`] to get the project track.
190        #[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            // If `stop()` wasn't called, signal + join so the thread doesn't
203            // outlive the recording (mirrors the capture workers' drop).
204            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    /// Non-macOS stub: cursor capture is macOS-first (ED.17). The editor
217    /// simply gets no cursor track on other platforms.
218    pub struct CursorPoller;
219
220    impl CursorPoller {
221        /// No-op start — no cursor capture off macOS.
222        #[must_use]
223        pub fn start(_rect: (f64, f64, f64, f64)) -> Self {
224            Self
225        }
226
227        /// No-op stop — always an empty track off macOS.
228        #[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        // 1920×1080 display at origin (0,0).
244        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        // A secondary display at origin (1920, 0).
259        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        // Zero-size axis maps to 0, never NaN/inf.
273        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        // 30 fps → 1 frame per 1/30 s. Two samples in frame 0, one in frame 1.
280        let samples = [
281            (Duration::from_millis(0), 0.1, 0.1),
282            (Duration::from_millis(10), 0.2, 0.2), // still frame 0 (< 33ms)
283            (Duration::from_millis(40), 0.5, 0.6), // frame 1
284        ];
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        // Latest sample at frame 0 wins.
289        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        // ISS-17: "display-<CGDirectDisplayID>" → the id; everything else →
302        // None (so the caller falls back to the main display).
303        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}