fix(gamescope/cursor): follow gamescope's own cursor verdict — the corner-parked pointer
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m23s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m36s
apple / screenshots (push) Successful in 6m34s
deb / build-publish (push) Successful in 9m41s
deb / build-publish-host (push) Successful in 10m13s
arch / build-publish (push) Successful in 12m43s
android / android (push) Successful in 16m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m41s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m4s
ci / rust (push) Successful in 29m44s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m23s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m36s
apple / screenshots (push) Successful in 6m34s
deb / build-publish (push) Successful in 9m41s
deb / build-publish-host (push) Successful in 10m13s
arch / build-publish (push) Successful in 12m43s
android / android (push) Successful in 16m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m41s
docker / deploy-docs (push) Successful in 25s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m4s
ci / rust (push) Successful in 29m44s
gamescope hides its pointer by WARPING the X pointer to the root's bottom-right corner pixel; it does not swap in a transparent X cursor, so XFixesGetCursorImage keeps handing back the last opaque arrow. The XFixes source picked its display by "whichever pointer moved", and a parked pointer never moves again — so it froze on the parked Xwayland and composited that arrow at (w-1, h-1): a sliver of cursor welded to the corner of the stream for the rest of the session while the real pointer went undrawn. Reported on-glass in every game (a game grabs the pointer, so the hide is permanent): "part of cursor shows up on bottom right ... isn't where it really is". Follow GAMESCOPE_CURSOR_VISIBLE_FEEDBACK instead — gamescope publishes it on every nested Xwayland root: 1 on the server whose pointer it is drawing, 0 on the others and 0 on all of them once the pointer is hidden. It answers both questions correctly for a STATIC pointer, which motion cannot: which Xwayland owns it, and whether to draw it at all. Read at connect, re-read on its root PropertyNotify (event drain now discriminates CursorNotify vs PropertyNotify) with a 250 ms resync as the self-heal. A pointer gamescope draws nowhere is published visible:false, not dropped — the encode loop overwrites the frame's overlay from this slot and strips invisible ones, so a None would leave the last visible overlay standing on repeat frames. Honouring the atom also gives the stream gamescope's own idle auto-hide, which this source never had. The pointer-motion heuristic stays as the fallback for a gamescope that publishes no verdict (logged at session start via cursor_feedback=false), so nothing regresses to a cursorless stream. Measured on a live 1920x1080 Steam Gaming Mode session (RTX 5070 Ti, gamescope c31743d+, --xwayland-count 2 --hide-cursor-delay 3000): idle 3 s => pointer (1919,1079) + feedback 0; real evdev motion => pointer live + feedback 1. End-to-end against that session the source now publishes visible=false while parked and tracks the live pointer within one 250 ms sample of motion. cargo clippy -p pf-capture --lib -D warnings and the 5 new pick_active tests are green at cargo 1.96.1 (CI parity); cargo fmt --all --check clean.
This commit is contained in:
@@ -10,20 +10,31 @@
|
|||||||
//!
|
//!
|
||||||
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
||||||
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
||||||
//! inactive display's pointer is frozen. So the source connects to ALL of them and each tick
|
//! inactive display's pointer is frozen. So the source connects to ALL of them and publishes from
|
||||||
//! follows the one whose pointer actually moved (gamescope routes input to the focused surface, so
|
//! the one gamescope is actually drawing the pointer on; it reads that display's shape too, since
|
||||||
//! exactly one moves at a time). It reads that display's shape too, since each Xwayland has its own
|
//! each Xwayland has its own current cursor. This is why a single-display read froze the pointer
|
||||||
//! current cursor. This is why a single-display read froze the pointer the moment a game on the
|
//! the moment a game on the OTHER Xwayland took focus.
|
||||||
//! OTHER Xwayland took focus.
|
|
||||||
//!
|
//!
|
||||||
//! Two X sources per display, split by cost (Sunshine's split):
|
//! Three X sources per display, split by cost (Sunshine's split, plus gamescope's own verdict):
|
||||||
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
||||||
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. It also
|
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth.
|
||||||
//! doubles as the focus signal (the display whose pointer moves is the active one).
|
//! * **Shape / hotspot** — `XFixesGetCursorImage`, refreshed only after an XFixes `CursorNotify`
|
||||||
//! * **Shape / hotspot / visibility** — `XFixesGetCursorImage`, refreshed only after an XFixes
|
//! (a real cursor change). A fully-transparent image reads as hidden.
|
||||||
//! `CursorNotify` (a real cursor change). A game hiding the pointer IS a cursor change → the
|
//! * **Visibility + focus** — [`GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`](GS_CURSOR_FEEDBACK) on the
|
||||||
//! image comes back fully transparent → `visible: false`, which the encode loop strips before
|
//! root, read at connect and re-read on its `PropertyNotify`.
|
||||||
//! any blend path draws it (so a grabbed pointer shows nothing, matching native gamescope).
|
//!
|
||||||
|
//! **Why the feedback atom and not pointer motion.** gamescope hides its pointer by WARPING the X
|
||||||
|
//! pointer to the root's bottom-right corner pixel — it does NOT swap in a transparent X cursor, so
|
||||||
|
//! `XFixesGetCursorImage` keeps handing back the last opaque arrow. The original "follow whichever
|
||||||
|
//! display's pointer moved" heuristic therefore stuck to the parked display (a parked pointer never
|
||||||
|
//! moves again) and composited that arrow at `(w-1, h-1)`: a sliver of cursor welded to the corner
|
||||||
|
//! of the stream for the rest of the session, while the real pointer went undrawn. Reported
|
||||||
|
//! on-glass as "part of cursor shows up on bottom right … isn't where it really is", in every game
|
||||||
|
//! (a game grabs the pointer, so the hide is permanent). Measured on a live 1920x1080 Gaming Mode
|
||||||
|
//! session: real motion ⇒ pointer live + feedback `1`; 3 s idle (`--hide-cursor-delay 3000`) ⇒
|
||||||
|
//! pointer `(1919, 1079)` + feedback `0`. The atom answers BOTH questions correctly for a static
|
||||||
|
//! pointer, which motion cannot: which Xwayland owns it, and whether to draw it at all — so
|
||||||
|
//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had.
|
||||||
|
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
@@ -35,7 +46,11 @@ use pf_frame::CursorOverlay;
|
|||||||
use x11rb::connection::Connection;
|
use x11rb::connection::Connection;
|
||||||
use x11rb::errors::ReplyError;
|
use x11rb::errors::ReplyError;
|
||||||
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
||||||
use x11rb::protocol::xproto::{ConnectionExt as _, QueryPointerReply, Window};
|
use x11rb::protocol::xproto::{
|
||||||
|
Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, QueryPointerReply,
|
||||||
|
Window,
|
||||||
|
};
|
||||||
|
use x11rb::protocol::Event;
|
||||||
use x11rb::rust_connection::RustConnection;
|
use x11rb::rust_connection::RustConnection;
|
||||||
|
|
||||||
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
||||||
@@ -47,6 +62,17 @@ static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
|||||||
/// and must out-run a 240 fps session or the pointer stutters.
|
/// and must out-run a 240 fps session or the pointer stutters.
|
||||||
const POLL: Duration = Duration::from_millis(4);
|
const POLL: Duration = Duration::from_millis(4);
|
||||||
|
|
||||||
|
/// gamescope's own pointer verdict, published on EVERY nested Xwayland's root: `1` on the server
|
||||||
|
/// whose pointer gamescope is currently drawing, `0` on the others — and `0` on all of them once
|
||||||
|
/// the pointer is hidden (a game grabbed it, or `--hide-cursor-delay` fired). See the module docs
|
||||||
|
/// for why this, and not pointer motion, is the signal this source follows.
|
||||||
|
const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK";
|
||||||
|
|
||||||
|
/// Self-heal cadence for the feedback re-read: `PropertyNotify` drives it, this only covers a
|
||||||
|
/// missed/coalesced event (and a gamescope that publishes the atom after we connected). One
|
||||||
|
/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll.
|
||||||
|
const FEEDBACK_RESYNC: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
||||||
/// X connections — so it lives exactly as long as the capturer that owns it.
|
/// X connections — so it lives exactly as long as the capturer that owns it.
|
||||||
pub(super) struct XFixesCursorSource {
|
pub(super) struct XFixesCursorSource {
|
||||||
@@ -68,7 +94,9 @@ impl XFixesCursorSource {
|
|||||||
let mut displays = Vec::new();
|
let mut displays = Vec::new();
|
||||||
for (dpy, xauth) in targets {
|
for (dpy, xauth) in targets {
|
||||||
match connect(&dpy, xauth.as_deref()) {
|
match connect(&dpy, xauth.as_deref()) {
|
||||||
Ok((conn, root)) => displays.push(XDisplay::new(dpy, conn, root)),
|
Ok((conn, root, feedback)) => {
|
||||||
|
displays.push(XDisplay::new(dpy, conn, root, feedback))
|
||||||
|
}
|
||||||
Err(e) => tracing::warn!(
|
Err(e) => tracing::warn!(
|
||||||
dpy = %dpy,
|
dpy = %dpy,
|
||||||
error = %e,
|
error = %e,
|
||||||
@@ -84,9 +112,13 @@ impl XFixesCursorSource {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||||
|
let feedback = displays.iter().any(|d| d.gs_visible.is_some());
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
displays = ?names,
|
displays = ?names,
|
||||||
"gamescope cursor: XFixes source live — following the focused Xwayland's pointer"
|
cursor_feedback = feedback,
|
||||||
|
"gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \
|
||||||
|
pointer on (cursor_feedback=false ⇒ this gamescope publishes no \
|
||||||
|
GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)"
|
||||||
);
|
);
|
||||||
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
@@ -111,10 +143,15 @@ impl Drop for XFixesCursorSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the X connection, negotiate XFixes, and select cursor-change events — returning the
|
/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events —
|
||||||
/// connection + root window. `RustConnection` reads `XAUTHORITY` from the env at connect time only,
|
/// returning the connection, root window and this display's initial
|
||||||
/// so set it under the lock (the host isn't a gamescope child), connect, then restore.
|
/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None`
|
||||||
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Window), String> {
|
/// when gamescope publishes no such property here). `RustConnection` reads `XAUTHORITY` from the
|
||||||
|
/// env at connect time only, so set it under the lock (the host isn't a gamescope child), connect,
|
||||||
|
/// then restore.
|
||||||
|
type Connected = (RustConnection, Window, (Atom, Option<bool>));
|
||||||
|
|
||||||
|
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<Connected, String> {
|
||||||
let (conn, screen_num) = {
|
let (conn, screen_num) = {
|
||||||
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
let prev = std::env::var_os("XAUTHORITY");
|
let prev = std::env::var_os("XAUTHORITY");
|
||||||
@@ -148,8 +185,41 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Windo
|
|||||||
.map_err(ReplyError::from)
|
.map_err(ReplyError::from)
|
||||||
.and_then(|c| c.check())
|
.and_then(|c| c.check())
|
||||||
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
||||||
|
|
||||||
|
// …and whenever gamescope republishes its cursor verdict. Interned with `only_if_exists=false`
|
||||||
|
// so we hold a matchable atom id even on a gamescope that sets the property later; a failure to
|
||||||
|
// select PROPERTY_CHANGE is NOT fatal — the resync re-read still tracks it, just at 250 ms.
|
||||||
|
let feedback_atom = conn
|
||||||
|
.intern_atom(false, GS_CURSOR_FEEDBACK.as_bytes())
|
||||||
|
.map_err(ReplyError::from)
|
||||||
|
.and_then(|c| c.reply())
|
||||||
|
.map(|r| r.atom)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if let Ok(c) = conn.change_window_attributes(
|
||||||
|
root,
|
||||||
|
&ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE),
|
||||||
|
) {
|
||||||
|
let _ = c.check();
|
||||||
|
}
|
||||||
let _ = conn.flush();
|
let _ = conn.flush();
|
||||||
Ok((conn, root))
|
let feedback = read_cursor_feedback(&conn, root, feedback_atom);
|
||||||
|
Ok((conn, root, (feedback_atom, feedback)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or
|
||||||
|
/// unreadable (not a gamescope that publishes it) — the caller then falls back to the pointer-motion
|
||||||
|
/// heuristic rather than blanking the cursor, so an older gamescope keeps today's behaviour.
|
||||||
|
fn read_cursor_feedback(conn: &RustConnection, root: Window, atom: Atom) -> Option<bool> {
|
||||||
|
if atom == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let reply = conn
|
||||||
|
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1)
|
||||||
|
.ok()?
|
||||||
|
.reply()
|
||||||
|
.ok()?;
|
||||||
|
let value = reply.value32()?.next()?;
|
||||||
|
Some(value != 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One gamescope Xwayland the source tracks.
|
/// One gamescope Xwayland the source tracks.
|
||||||
@@ -163,12 +233,22 @@ struct XDisplay {
|
|||||||
shape: Shape,
|
shape: Shape,
|
||||||
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
||||||
need_shape: bool,
|
need_shape: bool,
|
||||||
|
/// Interned [`GS_CURSOR_FEEDBACK`] atom (`0` = intern failed — treated as absent).
|
||||||
|
feedback_atom: Atom,
|
||||||
|
/// gamescope's verdict for THIS display: `Some(true)` = it is drawing the pointer here,
|
||||||
|
/// `Some(false)` = it is not, `None` = this gamescope publishes no verdict at all.
|
||||||
|
gs_visible: Option<bool>,
|
||||||
/// The X connection died (game/Xwayland exited) — skip it.
|
/// The X connection died (game/Xwayland exited) — skip it.
|
||||||
dead: bool,
|
dead: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XDisplay {
|
impl XDisplay {
|
||||||
fn new(name: String, conn: RustConnection, root: Window) -> Self {
|
fn new(
|
||||||
|
name: String,
|
||||||
|
conn: RustConnection,
|
||||||
|
root: Window,
|
||||||
|
(feedback_atom, gs_visible): (Atom, Option<bool>),
|
||||||
|
) -> Self {
|
||||||
XDisplay {
|
XDisplay {
|
||||||
name,
|
name,
|
||||||
conn,
|
conn,
|
||||||
@@ -176,9 +256,20 @@ impl XDisplay {
|
|||||||
last_pos: None,
|
last_pos: None,
|
||||||
shape: Shape::default(),
|
shape: Shape::default(),
|
||||||
need_shape: true,
|
need_shape: true,
|
||||||
|
feedback_atom,
|
||||||
|
gs_visible,
|
||||||
dead: false,
|
dead: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-read gamescope's verdict, keeping a previously-seen one if the read fails (a transient
|
||||||
|
/// failure must not look like "this gamescope has no feedback" and re-arm the motion heuristic).
|
||||||
|
fn resync_feedback(&mut self) {
|
||||||
|
let fresh = read_cursor_feedback(&self.conn, self.root, self.feedback_atom);
|
||||||
|
if fresh.is_some() || self.gs_visible.is_none() {
|
||||||
|
self.gs_visible = fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached cursor shape for one display.
|
/// Cached cursor shape for one display.
|
||||||
@@ -209,20 +300,35 @@ fn run(
|
|||||||
let mut out_serial = 0u64;
|
let mut out_serial = 0u64;
|
||||||
let mut last_key = (usize::MAX, u64::MAX);
|
let mut last_key = (usize::MAX, u64::MAX);
|
||||||
let mut warned_image = false;
|
let mut warned_image = false;
|
||||||
|
let mut last_resync = std::time::Instant::now();
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
// 1) Poll every display's pointer; note which moved since last tick (the focus signal).
|
// A missed/coalesced PropertyNotify would otherwise strand the verdict — re-read on a slow
|
||||||
|
// cadence so the source always converges (also picks the atom up if gamescope adds it late).
|
||||||
|
let resync = last_resync.elapsed() >= FEEDBACK_RESYNC;
|
||||||
|
if resync {
|
||||||
|
last_resync = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Poll every display's pointer; note which moved since last tick (the fallback focus
|
||||||
|
// signal, used only when this gamescope publishes no cursor verdict).
|
||||||
let mut active_moved = false;
|
let mut active_moved = false;
|
||||||
let mut other_moved: Option<usize> = None;
|
let mut other_moved: Option<usize> = None;
|
||||||
for (i, d) in displays.iter_mut().enumerate() {
|
for (i, d) in displays.iter_mut().enumerate() {
|
||||||
if d.dead {
|
if d.dead {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Drain pending events; the only ones selected are CursorNotify, so ANY event means
|
// Drain pending events. Two kinds are selected: XFixes CursorNotify (the shape
|
||||||
// "re-read this display's shape". poll_for_event never blocks.
|
// changed) and root PropertyNotify (gamescope republished its cursor verdict, among
|
||||||
|
// the many other properties it keeps on the root — hence the atom match).
|
||||||
|
let mut need_feedback = resync;
|
||||||
loop {
|
loop {
|
||||||
match d.conn.poll_for_event() {
|
match d.conn.poll_for_event() {
|
||||||
Ok(Some(_)) => d.need_shape = true,
|
Ok(Some(Event::XfixesCursorNotify(_))) => d.need_shape = true,
|
||||||
|
Ok(Some(Event::PropertyNotify(ev))) => {
|
||||||
|
need_feedback |= d.feedback_atom != 0 && ev.atom == d.feedback_atom;
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
d.dead = true;
|
d.dead = true;
|
||||||
@@ -230,6 +336,9 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if need_feedback && !d.dead {
|
||||||
|
d.resync_feedback();
|
||||||
|
}
|
||||||
match fetch_pointer(&d.conn, d.root) {
|
match fetch_pointer(&d.conn, d.root) {
|
||||||
Ok(p) if p.same_screen => {
|
Ok(p) if p.same_screen => {
|
||||||
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
||||||
@@ -248,13 +357,11 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Switch focus: sticky to the active display while it moves (no flapping); otherwise
|
// 2) Pick the display to publish from, and decide whether a pointer should be drawn at all.
|
||||||
// follow another display that moved. If the active one died, fall to any live display.
|
let states: Vec<(bool, Option<bool>)> =
|
||||||
if !active_moved {
|
displays.iter().map(|d| (d.dead, d.gs_visible)).collect();
|
||||||
if let Some(j) = other_moved {
|
let hidden_by_gamescope;
|
||||||
active = j;
|
(active, hidden_by_gamescope) = pick_active(&states, active, active_moved, other_moved);
|
||||||
}
|
|
||||||
}
|
|
||||||
if displays.get(active).is_none_or(|d| d.dead) {
|
if displays.get(active).is_none_or(|d| d.dead) {
|
||||||
match displays.iter().position(|d| !d.dead) {
|
match displays.iter().position(|d| !d.dead) {
|
||||||
Some(k) => active = k,
|
Some(k) => active = k,
|
||||||
@@ -283,7 +390,11 @@ fn run(
|
|||||||
|
|
||||||
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
||||||
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
||||||
|
// A pointer gamescope is not drawing is published `visible: false`, NOT dropped: the
|
||||||
|
// encode loop overwrites the frame's overlay from this slot and strips invisible ones, so
|
||||||
|
// a `None` here would leave the last visible overlay standing on repeat frames.
|
||||||
let d = &displays[active];
|
let d = &displays[active];
|
||||||
|
let drawn = d.shape.visible && !hidden_by_gamescope;
|
||||||
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||||
(Some((px, py)), false) => {
|
(Some((px, py)), false) => {
|
||||||
let key = (active, d.shape.serial);
|
let key = (active, d.shape.serial);
|
||||||
@@ -301,7 +412,7 @@ fn run(
|
|||||||
serial: out_serial,
|
serial: out_serial,
|
||||||
hot_x: d.shape.hot_x,
|
hot_x: d.shape.hot_x,
|
||||||
hot_y: d.shape.hot_y,
|
hot_y: d.shape.hot_y,
|
||||||
visible: d.shape.visible,
|
visible: drawn,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -314,6 +425,39 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which display to publish from, and whether gamescope is drawing NO pointer right now —
|
||||||
|
/// `(active, hidden)`. `states` is one `(dead, gs_visible)` per display, in `displays` order.
|
||||||
|
///
|
||||||
|
/// PREFERRED: gamescope's own verdict. It is authoritative for a STATIC pointer, which is exactly
|
||||||
|
/// the case motion cannot read — a game grabs the pointer, gamescope parks it in the bottom-right
|
||||||
|
/// corner, and "follow whichever moved" then never switches again (see the module docs).
|
||||||
|
///
|
||||||
|
/// FALLBACK, only when NO live display publishes the verdict: the original motion heuristic —
|
||||||
|
/// sticky to the active display while its pointer moves (no flapping), else follow another that
|
||||||
|
/// moved. `hidden` is never asserted on this path, so an older gamescope keeps today's behaviour.
|
||||||
|
fn pick_active(
|
||||||
|
states: &[(bool, Option<bool>)],
|
||||||
|
active: usize,
|
||||||
|
active_moved: bool,
|
||||||
|
other_moved: Option<usize>,
|
||||||
|
) -> (usize, bool) {
|
||||||
|
let live = |&(dead, _): &(bool, Option<bool>)| !dead;
|
||||||
|
if states.iter().filter(|s| live(s)).any(|(_, v)| v.is_some()) {
|
||||||
|
return match states.iter().position(|s| live(s) && s.1 == Some(true)) {
|
||||||
|
// gamescope is drawing the pointer here — publish from it.
|
||||||
|
Some(i) => (i, false),
|
||||||
|
// Drawing none of them (a game grabbed it, or the idle auto-hide fired). Keep `active`
|
||||||
|
// so the shape cache and last position stay warm for the re-show; the caller publishes
|
||||||
|
// the overlay `visible: false` instead of dropping it.
|
||||||
|
None => (active, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match other_moved {
|
||||||
|
Some(j) if !active_moved => (j, false),
|
||||||
|
_ => (active, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
||||||
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
||||||
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
||||||
@@ -364,3 +508,60 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::pick_active;
|
||||||
|
|
||||||
|
/// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's.
|
||||||
|
const BPM: usize = 0;
|
||||||
|
const GAME: usize = 1;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follows_the_display_gamescope_draws_on() {
|
||||||
|
// Verdict beats motion: gamescope says the game's Xwayland owns the pointer, so we publish
|
||||||
|
// from it even though only BPM's (parked) pointer looks like it moved.
|
||||||
|
let states = [(false, Some(false)), (false, Some(true))];
|
||||||
|
assert_eq!(pick_active(&states, BPM, false, Some(BPM)), (GAME, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE REGRESSION: gamescope hides the pointer by warping it to the root's bottom-right corner
|
||||||
|
/// and leaves the opaque arrow as the X cursor. Nothing moves ever again, so the motion
|
||||||
|
/// heuristic stayed on the parked display and composited that arrow at (w-1, h-1) — a sliver of
|
||||||
|
/// cursor welded to the corner of the stream for the whole session, in every game.
|
||||||
|
#[test]
|
||||||
|
fn a_pointer_gamescope_draws_nowhere_is_hidden_not_parked() {
|
||||||
|
let states = [(false, Some(false)), (false, Some(false))];
|
||||||
|
// `active` is kept (shape cache + last position stay warm for the re-show) but hidden.
|
||||||
|
assert_eq!(pick_active(&states, GAME, false, None), (GAME, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn re_show_returns_to_the_drawing_display() {
|
||||||
|
let states = [(false, Some(true)), (false, Some(false))];
|
||||||
|
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dead_displays_verdict_is_ignored() {
|
||||||
|
// The game's Xwayland exited mid-session with a stale `Some(true)`; BPM is the live one.
|
||||||
|
let states = [(false, Some(true)), (true, Some(true))];
|
||||||
|
assert_eq!(pick_active(&states, GAME, false, None), (BPM, false));
|
||||||
|
// …and a dead display's `Some` must not count as "this gamescope publishes a verdict",
|
||||||
|
// which would blank the cursor forever on a gamescope that publishes none.
|
||||||
|
let states = [(false, None), (true, Some(false))];
|
||||||
|
assert_eq!(pick_active(&states, BPM, false, Some(GAME)), (GAME, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_verdict_falls_back_to_the_motion_heuristic() {
|
||||||
|
let none = [(false, None), (false, None)];
|
||||||
|
// Sticky while the active display's own pointer moves…
|
||||||
|
assert_eq!(pick_active(&none, BPM, true, Some(GAME)), (BPM, false));
|
||||||
|
// …otherwise follow the one that moved…
|
||||||
|
assert_eq!(pick_active(&none, BPM, false, Some(GAME)), (GAME, false));
|
||||||
|
// …and never assert `hidden` on this path (that would regress an older gamescope to a
|
||||||
|
// cursorless stream).
|
||||||
|
assert_eq!(pick_active(&none, BPM, false, None), (BPM, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user