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