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