Skip to main content

pixelflux/x11/
cursor.rs

1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Out-of-band X11 cursor delivery, the X11 counterpart of the Wayland compositor's cursor
8//! callback: one process-wide monitor thread (the X pointer is global, however many captures
9//! run) parks in `wait_for_event` on its own connection for XFixes `DisplayCursorNotify` and
10//! hands each new cursor to the Python callback as `(msg_type, png_bytes, hot_x, hot_y)` —
11//! the same payload the Wayland backend sends, so a consumer needs one handler for both.
12//! Keeping the cursor out of the framebuffer means pointer motion over static content never
13//! dirties the damage hash or re-encodes video; `capture_cursor` compositing stays available
14//! as the opt-in alternative.
15//!
16//! The thread blocks — no polling. Stop and replay requests wake it by sending a
17//! ClientMessage to a private InputOnly window from a short-lived connection, which also
18//! keeps every request on the monitor's own connection single-threaded. The SeqCst pairs on
19//! `stop`/`wake_win` close the startup race where a stop lands before the wake window
20//! exists: whichever side loses the ordering still observes the other's store.
21
22use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering};
23use std::sync::{Arc, Mutex};
24use std::thread::JoinHandle;
25
26use pyo3::prelude::*;
27use pyo3::types::PyBytes;
28use x11rb::connection::Connection;
29use x11rb::protocol::xfixes::{
30    ConnectionExt as XfixesExt, CursorNotifyMask, GetCursorImageReply,
31};
32use x11rb::protocol::xproto::{
33    ClientMessageEvent, ConnectionExt as XprotoExt, CreateWindowAux, EventMask, WindowClass,
34    CLIENT_MESSAGE_EVENT,
35};
36use x11rb::protocol::Event;
37use x11rb::rust_connection::RustConnection;
38
39/// The Python cursor callback from `set_cursor_callback`, shared by every X11 capture.
40static CALLBACK: Mutex<Option<Py<PyAny>>> = Mutex::new(None);
41/// Longest delivered cursor edge; larger images are downscaled (`<= 0` = uncapped).
42static SIZE_CAP: AtomicI32 = AtomicI32::new(32);
43/// Deliver the current cursor on the next wake (a callback registered mid-run).
44static REPLAY: AtomicBool = AtomicBool::new(false);
45
46struct Monitor {
47    stop: Arc<AtomicBool>,
48    wake_win: Arc<AtomicU32>,
49    /// Closed (by drop) when the thread exits; lets `release` bound its join.
50    done_rx: std::sync::mpsc::Receiver<()>,
51    join: JoinHandle<()>,
52}
53
54/// The monitor and the number of running X11 captures that keep it alive.
55struct Slot {
56    users: usize,
57    monitor: Option<Monitor>,
58}
59
60static SLOT: Mutex<Slot> = Mutex::new(Slot { users: 0, monitor: None });
61
62/// Register/replace the callback and re-deliver the current cursor to it.
63pub fn set_callback(cb: Py<PyAny>) {
64    *CALLBACK.lock().unwrap() = Some(cb);
65    let slot = SLOT.lock().unwrap();
66    if let Some(m) = slot.monitor.as_ref() {
67        REPLAY.store(true, Ordering::Release);
68        wake(&m.wake_win);
69    }
70}
71
72pub fn set_size_cap(cap: i32) {
73    SIZE_CAP.store(cap, Ordering::Relaxed);
74}
75
76/// An X11 capture started: the first one in spawns the monitor.
77pub fn acquire(size_cap: i32) {
78    SIZE_CAP.store(size_cap, Ordering::Relaxed);
79    let mut slot = SLOT.lock().unwrap();
80    slot.users += 1;
81    if slot.monitor.is_none() {
82        let stop = Arc::new(AtomicBool::new(false));
83        let wake_win = Arc::new(AtomicU32::new(0));
84        let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
85        let (tstop, twin) = (stop.clone(), wake_win.clone());
86        match std::thread::Builder::new()
87            .name("pxf-x11-cursor".into())
88            .spawn(move || {
89                let _done = done_tx;
90                monitor_thread(tstop, twin);
91            }) {
92            Ok(join) => slot.monitor = Some(Monitor { stop, wake_win, done_rx, join }),
93            Err(e) => eprintln!("[x11] cursor monitor spawn failed: {e}"),
94        }
95    }
96}
97
98/// An X11 capture stopped: the last one out stops and joins the monitor. Runs detached
99/// from the GIL — the thread may be blocked attaching to deliver a cursor — and the join
100/// is bounded: a thread that misses the wake (X server wedged mid-shutdown) is abandoned
101/// with a warning rather than hanging the caller; it exits on its own next event. A stop
102/// issued from inside the cursor callback runs on the monitor thread itself, where a join
103/// can only time out: it signals and detaches instead, and the loop observes the stop flag
104/// once the callback unwinds.
105pub fn release(py: Python<'_>) {
106    let monitor = {
107        let mut slot = SLOT.lock().unwrap();
108        slot.users = slot.users.saturating_sub(1);
109        if slot.users == 0 {
110            slot.monitor.take()
111        } else {
112            None
113        }
114    };
115    if let Some(m) = monitor {
116        if m.join.thread().id() == std::thread::current().id() {
117            m.stop.store(true, Ordering::SeqCst);
118            wake(&m.wake_win);
119            return;
120        }
121        py.detach(move || {
122            m.stop.store(true, Ordering::SeqCst);
123            wake(&m.wake_win);
124            match m.done_rx.recv_timeout(std::time::Duration::from_secs(2)) {
125                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
126                    eprintln!("[x11] cursor monitor did not stop in time; detaching");
127                }
128                _ => {
129                    let _ = m.join.join();
130                }
131            }
132        });
133    }
134}
135
136/// Interpreter teardown: drop the Python callback (the caller holds the GIL, so the decref
137/// is immediate) and signal the thread without joining — `PY_SHUTDOWN` already gates it off
138/// Python, and a normal capture stop reaps it via `release`.
139pub fn shutdown() {
140    *CALLBACK.lock().unwrap() = None;
141    let slot = SLOT.lock().unwrap();
142    if let Some(m) = slot.monitor.as_ref() {
143        m.stop.store(true, Ordering::SeqCst);
144        wake(&m.wake_win);
145    }
146}
147
148/// Wake the parked monitor: a ClientMessage to its private window (SendEvent with an empty
149/// mask delivers to the window's creator) from a short-lived connection. The send is
150/// round-tripped (`check`) so a failure has a name, and the whole wake is retried once. A
151/// zero window means setup hasn't finished; the thread re-checks its flags before first
152/// parking, so the wake can be skipped.
153fn wake(wake_win: &AtomicU32) {
154    let win = wake_win.load(Ordering::SeqCst);
155    if win == 0 {
156        return;
157    }
158    let mut last_err = String::new();
159    for attempt in 0..2 {
160        if attempt > 0 {
161            std::thread::sleep(std::time::Duration::from_millis(100));
162        }
163        let conn = match x11rb::connect(None) {
164            Ok((c, _)) => c,
165            Err(e) => {
166                last_err = format!("connect: {e}");
167                continue;
168            }
169        };
170        let ev = ClientMessageEvent {
171            response_type: CLIENT_MESSAGE_EVENT,
172            format: 32,
173            sequence: 0,
174            window: win,
175            type_: u32::from(x11rb::protocol::xproto::AtomEnum::PRIMARY),
176            data: [0u32; 5].into(),
177        };
178        match conn
179            .send_event(false, win, EventMask::NO_EVENT, ev)
180            .map_err(|e| e.to_string())
181            .and_then(|c| c.check().map_err(|e| e.to_string()))
182        {
183            Ok(()) => return,
184            Err(e) => last_err = format!("send_event: {e}"),
185        }
186    }
187    eprintln!("[x11] cursor monitor wake failed: {last_err}");
188}
189
190/// The last payload handed to Python, retained so a replay (late callback registration)
191/// re-sends it without touching the server — mirroring the Wayland backend, and immune to
192/// XFixes' transient "cursor not displayed" fetch errors.
193type Payload = (&'static str, Vec<u8>, i32, i32);
194
195fn monitor_thread(stop: Arc<AtomicBool>, wake_win: Arc<AtomicU32>) {
196    let (conn, screen_num) = match x11rb::connect(None) {
197        Ok(v) => v,
198        Err(e) => {
199            eprintln!("[x11] cursor monitor: connect failed: {e}");
200            return;
201        }
202    };
203    let root = conn.setup().roots[screen_num].root;
204    if let Err(e) = setup(&conn, root, &wake_win) {
205        eprintln!("[x11] cursor monitor unavailable: {e}");
206        return;
207    }
208    if stop.load(Ordering::SeqCst) {
209        return;
210    }
211    let mut last: Option<Payload> = fetch_payload(&conn);
212    deliver(last.as_ref());
213    // A registration that raced setup may have requested a replay before the wake
214    // window existed (its wake was skipped). The deliver above satisfied it only if
215    // the callback was already visible and the fetch produced a payload, so consume
216    // the flag and re-deliver rather than assume: an unconditional clear leaves that
217    // callback cursor-less until the next real cursor change. From here on the wake
218    // window exists, so later requests always reach the loop below.
219    if REPLAY.swap(false, Ordering::AcqRel) {
220        if last.is_none() {
221            last = fetch_payload(&conn);
222        }
223        deliver(last.as_ref());
224    }
225    loop {
226        let event = match conn.wait_for_event() {
227            Ok(ev) => ev,
228            Err(e) => {
229                eprintln!("[x11] cursor monitor: connection lost: {e}");
230                return;
231            }
232        };
233        let mut changed = matches!(event, Event::XfixesCursorNotify(_));
234        // Coalesce a queued burst into one fetch of the current image.
235        while let Ok(Some(ev)) = conn.poll_for_event() {
236            changed |= matches!(ev, Event::XfixesCursorNotify(_));
237        }
238        if stop.load(Ordering::Relaxed) {
239            return;
240        }
241        let replay = REPLAY.swap(false, Ordering::AcqRel);
242        if changed {
243            // A failed fetch keeps the retained payload (consumers keep the last
244            // cursor); the change is dropped, matching the python monitor's skip.
245            if let Some(p) = fetch_payload(&conn) {
246                last = Some(p);
247                deliver(last.as_ref());
248            } else if replay {
249                deliver(last.as_ref());
250            }
251        } else if replay {
252            if last.is_none() {
253                last = fetch_payload(&conn);
254            }
255            deliver(last.as_ref());
256        }
257    }
258}
259
260fn setup(conn: &RustConnection, root: u32, wake_win: &AtomicU32) -> Result<(), String> {
261    conn.xfixes_query_version(5, 0)
262        .map_err(|e| format!("xfixes_query_version: {e}"))?
263        .reply()
264        .map_err(|e| format!("XFixes unavailable: {e}"))?;
265    conn.xfixes_select_cursor_input(root, CursorNotifyMask::DISPLAY_CURSOR)
266        .map_err(|e| format!("select_cursor_input: {e}"))?
267        .check()
268        .map_err(|e| format!("select_cursor_input failed: {e}"))?;
269    let win = conn
270        .generate_id()
271        .map_err(|e| format!("generate_id: {e}"))?;
272    conn.create_window(
273        0,
274        win,
275        root,
276        0,
277        0,
278        1,
279        1,
280        0,
281        WindowClass::INPUT_ONLY,
282        0,
283        &CreateWindowAux::new(),
284    )
285    .map_err(|e| format!("create_window: {e}"))?
286    .check()
287    .map_err(|e| format!("wake window: {e}"))?;
288    wake_win.store(win, Ordering::SeqCst);
289    Ok(())
290}
291
292/// Fetch and convert the current cursor. `None` on a fetch failure (XFixes returns an
293/// error while the cursor is not displayed, e.g. a blanked screen) or an encode failure —
294/// mirroring the Wayland gate where only real payloads or an explicit hide go downstream.
295fn fetch_payload(conn: &RustConnection) -> Option<Payload> {
296    let img = conn.xfixes_get_cursor_image().ok()?.reply().ok()?;
297    let (msg_type, png, hot_x, hot_y) = cursor_to_png(&img, SIZE_CAP.load(Ordering::Relaxed));
298    if png.is_empty() && msg_type != "hide" {
299        return None;
300    }
301    Some((msg_type, png, hot_x, hot_y))
302}
303
304/// Hand a payload to the Python callback (skips quietly with no callback, no payload, or
305/// a finalizing interpreter).
306fn deliver(payload: Option<&Payload>) {
307    let (msg_type, png, hot_x, hot_y) = match payload {
308        Some(p) => p,
309        None => return,
310    };
311    if crate::PY_SHUTDOWN.load(Ordering::Relaxed) {
312        return;
313    }
314    if CALLBACK.lock().unwrap().is_none() {
315        return;
316    }
317    Python::attach(|py| {
318        let cb = {
319            CALLBACK
320                .lock()
321                .unwrap()
322                .as_ref()
323                .map(|c| c.clone_ref(py))
324        };
325        if let Some(cb) = cb {
326            let py_bytes = PyBytes::new(py, png);
327            if let Err(e) = cb.call1(py, (*msg_type, py_bytes, *hot_x, *hot_y)) {
328                e.print(py);
329            }
330        }
331    });
332}
333
334/// XFixes ARGB image -> callback payload: cropped to the visible bounding box (hotspot
335/// re-based to it), downscaled so the longest edge fits `cap`, un-premultiplied, and
336/// PNG-encoded. Scaling runs in premultiplied space — per-channel filtering is only linear
337/// there, and fully transparent texels cannot bleed dark fringes into edges — and straight
338/// alpha is produced last, since that is what PNG carries. A fully transparent image is an
339/// intentional pointer hide.
340fn cursor_to_png(img: &GetCursorImageReply, cap: i32) -> (&'static str, Vec<u8>, i32, i32) {
341    let w = img.width as usize;
342    let h = img.height as usize;
343    if w == 0 || h == 0 || img.cursor_image.len() < w * h {
344        return ("hide", Vec::new(), 0, 0);
345    }
346    let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
347    for y in 0..h {
348        for x in 0..w {
349            if img.cursor_image[y * w + x] != 0 {
350                x0 = x0.min(x);
351                y0 = y0.min(y);
352                x1 = x1.max(x);
353                y1 = y1.max(y);
354            }
355        }
356    }
357    if x0 > x1 || y0 > y1 {
358        return ("hide", Vec::new(), 0, 0);
359    }
360    let (cw, ch) = (x1 - x0 + 1, y1 - y0 + 1);
361    let mut rgba = Vec::with_capacity(cw * ch * 4);
362    for y in y0..=y1 {
363        for x in x0..=x1 {
364            let p = img.cursor_image[y * w + x];
365            rgba.extend_from_slice(&[(p >> 16) as u8, (p >> 8) as u8, p as u8, (p >> 24) as u8]);
366        }
367    }
368    let mut hot_x = img.xhot as i32 - x0 as i32;
369    let mut hot_y = img.yhot as i32 - y0 as i32;
370    let mut image = match image::RgbaImage::from_raw(cw as u32, ch as u32, rgba) {
371        Some(i) => i,
372        None => return ("error", Vec::new(), 0, 0),
373    };
374    if cap > 0 && (cw > cap as usize || ch > cap as usize) {
375        let scale = cap as f32 / cw.max(ch) as f32;
376        let nw = ((cw as f32 * scale) as u32).max(1);
377        let nh = ((ch as f32 * scale) as u32).max(1);
378        image = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::Lanczos3);
379        hot_x = (hot_x as f32 * scale) as i32;
380        hot_y = (hot_y as f32 * scale) as i32;
381    }
382    crate::unpremultiply_rgba(&mut image);
383    // A hotspot can lie outside the visible bbox (its neighborhood was cropped as
384    // fully transparent). Consumers treat the hotspot as an offset INTO the
385    // bitmap, so clamp to the cropped bounds instead of emitting off-image
386    // coordinates.
387    let hot_x = hot_x.clamp(0, image.width() as i32 - 1);
388    let hot_y = hot_y.clamp(0, image.height() as i32 - 1);
389    let mut png = Vec::new();
390    match image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) {
391        Ok(()) => ("png", png, hot_x, hot_y),
392        Err(_) => ("error", Vec::new(), 0, 0),
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn reply(w: u16, h: u16, xhot: u16, yhot: u16, pixels: Vec<u32>) -> GetCursorImageReply {
401        GetCursorImageReply {
402            sequence: 0,
403            length: 0,
404            x: 0,
405            y: 0,
406            width: w,
407            height: h,
408            xhot,
409            yhot,
410            cursor_serial: 1,
411            cursor_image: pixels,
412        }
413    }
414
415    /// A fully transparent cursor is an intentional hide, matching the python path's
416    /// empty-bbox -> empty-curdata behavior that blanks the client cursor.
417    #[test]
418    fn transparent_cursor_hides() {
419        let (t, data, _, _) = cursor_to_png(&reply(4, 4, 0, 0, vec![0; 16]), 32);
420        assert_eq!(t, "hide");
421        assert!(data.is_empty());
422        let (t, _, _, _) = cursor_to_png(&reply(0, 0, 0, 0, vec![]), 32);
423        assert_eq!(t, "hide");
424    }
425
426    /// The image is cropped to its visible bbox and the hotspot re-based to the crop:
427    /// a 2x2 visible block at (1,1)..(2,2) with hotspot (2,2) yields a 2x2 PNG with
428    /// hotspot (1,1).
429    #[test]
430    fn crop_rebases_hotspot() {
431        let mut px = vec![0u32; 16];
432        for (x, y) in [(1, 1), (2, 1), (1, 2), (2, 2)] {
433            px[y * 4 + x] = 0xFF00_0000;
434        }
435        let (t, data, hx, hy) = cursor_to_png(&reply(4, 4, 2, 2, px), 32);
436        assert_eq!(t, "png");
437        assert!(!data.is_empty());
438        assert_eq!((hx, hy), (1, 1));
439    }
440
441    /// A hotspot whose neighborhood was cropped away as transparent is clamped into
442    /// the emitted bitmap — consumers use it as an offset INTO the image, and the
443    /// Wayland path never emits out-of-bounds hotspots either.
444    #[test]
445    fn out_of_bbox_hotspot_clamped() {
446        let mut px = vec![0u32; 16];
447        let (x, y) = (2usize, 1usize);
448        px[y * 4 + x] = 0xFF00_0000;
449        // Visible pixel at (2,1) only. Hotspot (0,0): rebased (-2,-1) -> (0,0).
450        let (t, _, hx, hy) = cursor_to_png(&reply(4, 4, 0, 0, px.clone()), 32);
451        assert_eq!(t, "png");
452        assert_eq!((hx, hy), (0, 0));
453        // Hotspot (3,3): rebased (1,2) past the 1x1 crop -> clamps to (0,0).
454        let (_, _, hx, hy) = cursor_to_png(&reply(4, 4, 3, 3, px), 32);
455        assert_eq!((hx, hy), (0, 0));
456    }
457
458    /// Premultiplied color becomes straight alpha in the PNG: a half-alpha pixel stored
459    /// as ARGB (128,64,32,16) decodes to RGBA (128,64,32) at alpha 128. selkies'
460    /// `unpremultiply_rgba` (display_utils) mirrors the same integer rounding so the seed
461    /// and live paths hash a cursor to the same content handle.
462    #[test]
463    fn fractional_alpha_unpremultiplied() {
464        let (t, data, _, _) = cursor_to_png(&reply(2, 2, 0, 0, vec![0x8040_2010; 4]), 32);
465        assert_eq!(t, "png");
466        let img = image::load_from_memory(&data).unwrap().to_rgba8();
467        assert_eq!(img.get_pixel(0, 0).0, [128, 64, 32, 128]);
468        // Binary alpha passes through untouched.
469        let (_, data, _, _) = cursor_to_png(&reply(1, 1, 0, 0, vec![0xFF10_2030]), 32);
470        let img = image::load_from_memory(&data).unwrap().to_rgba8();
471        assert_eq!(img.get_pixel(0, 0).0, [0x10, 0x20, 0x30, 0xFF]);
472    }
473
474    /// An oversized cursor is downscaled so its longest edge fits the cap, with the
475    /// hotspot scaled by the same factor.
476    #[test]
477    fn oversized_cursor_capped() {
478        let (t, data, hx, hy) = cursor_to_png(&reply(64, 64, 32, 32, vec![0xFFFF_FFFF; 64 * 64]), 16);
479        assert_eq!(t, "png");
480        let img = image::load_from_memory(&data).unwrap();
481        assert_eq!((img.width(), img.height()), (16, 16));
482        assert_eq!((hx, hy), (8, 8));
483        // Uncapped passes through at native size.
484        let (_, data, _, _) = cursor_to_png(&reply(64, 64, 32, 32, vec![0xFFFF_FFFF; 64 * 64]), 0);
485        let img = image::load_from_memory(&data).unwrap();
486        assert_eq!((img.width(), img.height()), (64, 64));
487    }
488}