srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-10-03 22:59:00 +0200
committersrdusr <[email protected]>2025-10-03 22:59:00 +0200
commit4eb36873d4ca4b0faf0e54dbeab8f5f6450dd704 (patch)
tree657c89f7398f98b0305b9cd69889c232608f3027
parentd7031dd060ec12e6de334518aa75122c65794047 (diff)
downloadsrdwm-4eb36873d4ca4b0faf0e54dbeab8f5f6450dd704.tar.gz
srdwm-4eb36873d4ca4b0faf0e54dbeab8f5f6450dd704.zip
Make the secondary-cursor sprite opt-in and expire stale entries
Live report: a second cursor appeared uninvited and unusably (frozen, uncontrollable) on screen. Multi-cursor Phase 1 rendered one sprite per physical libinput pointer device that had ever reported a position, with no way to turn it off and no expiry - so a phantom device (a real mouse's side-button/scroll cluster enumerating as its own HID path is a common case) that reports once and never moves again left a frozen ghost sprite with nothing to control or dismiss it. Adds general.multi_cursor (default false, live-settable via `srd set multi_cursor <bool>`) and keys secondary_cursors to (Point, Instant) so both the recording side (udev/session.rs) and the render side (udev/render.rs) drop any entry older than SECONDARY_CURSOR_TIMEOUT (1.5s). The "agent controls a window without interrupting the user" use case this report also raised was never gated on this flag - that's Multi-cursor Phase 2's pinned virtual-pointer delivery, which never shows a visible cursor at all.
-rw-r--r--crates/config/src/engine/support.rs6
-rw-r--r--crates/core/src/manager/mod.rs21
-rw-r--r--crates/ctl/src/main.rs12
-rw-r--r--crates/platform/src/ipc/dispatch.rs9
-rw-r--r--crates/platform/src/ipc/types.rs2
-rw-r--r--crates/srdwm/src/main.rs2
-rw-r--r--crates/wayland/src/udev/mod.rs49
-rw-r--r--crates/wayland/src/udev/render.rs27
-rw-r--r--crates/wayland/src/udev/session.rs31
-rw-r--r--docs/TODO.md13
10 files changed, 147 insertions, 25 deletions
diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs
index 97c499f..5574bee 100644
--- a/crates/config/src/engine/support.rs
+++ b/crates/config/src/engine/support.rs
@@ -159,6 +159,12 @@ pub(super) fn default_config() -> HashMap<String, ConfigValue> {
// completely unaffected - see `WindowManager::phone_mode`'s own doc
// comment.
set("general.phone_mode", Bool(false));
+ // `false`: an extra cursor sprite per other physical pointer device is
+ // opt-in, not automatic - see `WindowManager::multi_cursor_enabled`'s
+ // own doc comment for the real, reported reason (a phantom libinput
+ // device from otherwise-ordinary hardware showing up as an
+ // uncontrollable frozen ghost cursor).
+ set("general.multi_cursor", Bool(false));
// Real desktop icons (Home/Computer/Trash plus `~/Desktop`'s own
// contents) on by default - see `WindowManager::desktop_icons_
// enabled`'s own doc comment for why this, unlike `general.gpu` just
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
index c82bced..bb22533 100644
--- a/crates/core/src/manager/mod.rs
+++ b/crates/core/src/manager/mod.rs
@@ -232,6 +232,26 @@ pub struct WindowManager {
/// without touching config - `udev::platform::connect` attempts the
/// probe if *either* this or the env var says to.
pub gpu_enabled: bool,
+ /// Read from `general.multi_cursor` - `false` by default. Gates
+ /// whether the udev backend renders one extra cursor sprite per
+ /// *other* physical pointer device that's recently moved (`UdevState::
+ /// secondary_cursors`, "Multi-cursor Phase 1"). Off by default because
+ /// live use found the un-gated version actively confusing rather than
+ /// useful: real hardware routinely reports what is really one mouse
+ /// as more than one distinct libinput device (a side-button/scroll
+ /// cluster on its own HID path, concretely), so an always-on second
+ /// sprite showed up uninvited and, since nothing else ever moved that
+ /// phantom device again, sat frozen on screen with no way to control
+ /// or dismiss it - reported live as exactly that: "I see two cursors
+ /// and can't even control the other one". The two scenarios this
+ /// feature actually exists for are unaffected by this being off:
+ /// genuinely using two input devices at once is now something to
+ /// opt into rather than be surprised by, and "an agent controls a
+ /// window without interrupting me" is Multi-cursor Phase 2's own job
+ /// (`crates/wayland/src/virtual_pointer.rs`'s pinned delivery), which
+ /// never shows a visible cursor at all - it was never blocked on
+ /// this flag to begin with.
+ pub multi_cursor_enabled: bool,
/// Read from `general.phone_mode` - `false` by default. Optional
/// single-app-at-a-time placement policy for a phone-shaped display:
/// see `add_window`'s own use of this (a new window defaults to
@@ -488,6 +508,7 @@ impl WindowManager {
resize_margin: RESIZE_MARGIN,
rounded_corners_enabled: None,
gpu_enabled: false,
+ multi_cursor_enabled: false,
phone_mode: false,
desktop_icons_enabled: true,
desktop_icons_all_monitors: true,
diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs
index d714405..3cf3bfd 100644
--- a/crates/ctl/src/main.rs
+++ b/crates/ctl/src/main.rs
@@ -178,14 +178,14 @@ fn build_request(args: &[String]) -> Result<String, String> {
// as booleans at all, not a string it then has to reject.
Some("set") => {
let key = args.get(1).ok_or(
- "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/decoration_mode)",
+ "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/multi_cursor/decoration_mode)",
)?;
let raw = args.get(2).ok_or("set needs a value")?;
let value = match key.as_str() {
"border_width" | "corner_radius" | "gap_inner" | "gap_outer" => {
raw.parse::<u64>().map_err(|_| format!("{key} needs a numeric value"))?.to_string()
}
- "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" | "phone_mode" => match raw.as_str() {
+ "shadows" | "rounded_corners" | "animations" | "night_light" | "reading_mode" | "phone_mode" | "multi_cursor" => match raw.as_str() {
"true" | "false" => raw.clone(),
_ => return Err(format!("{key} needs 'true' or 'false'")),
},
@@ -401,6 +401,7 @@ fn print_usage() {
eprintln!(" srd set night_light <true|false>");
eprintln!(" srd set reading_mode <true|false>");
eprintln!(" srd set phone_mode <true|false>");
+ eprintln!(" srd set multi_cursor <true|false>");
eprintln!(" srd set decoration_mode <server|client>");
}
@@ -479,6 +480,13 @@ mod tests {
}
#[test]
+ fn set_multi_cursor_accepts_only_true_or_false() {
+ assert_eq!(build_request(&args(&["set", "multi_cursor", "true"])).unwrap(), r#"{"cmd":"set","key":"multi_cursor","value":true}"#);
+ assert_eq!(build_request(&args(&["set", "multi_cursor", "false"])).unwrap(), r#"{"cmd":"set","key":"multi_cursor","value":false}"#);
+ assert!(build_request(&args(&["set", "multi_cursor", "maybe"])).is_err());
+ }
+
+ #[test]
fn set_phone_mode_accepts_only_true_or_false() {
assert_eq!(build_request(&args(&["set", "phone_mode", "true"])).unwrap(), r#"{"cmd":"set","key":"phone_mode","value":true}"#);
assert_eq!(build_request(&args(&["set", "phone_mode", "false"])).unwrap(), r#"{"cmd":"set","key":"phone_mode","value":false}"#);
diff --git a/crates/platform/src/ipc/dispatch.rs b/crates/platform/src/ipc/dispatch.rs
index 5a969c7..313a79c 100644
--- a/crates/platform/src/ipc/dispatch.rs
+++ b/crates/platform/src/ipc/dispatch.rs
@@ -27,6 +27,7 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<Wi
night_light: wm.color_filter == srdwm_core::ColorFilter::NightLight,
reading_mode: wm.color_filter == srdwm_core::ColorFilter::ReadingMode,
phone_mode: wm.phone_mode,
+ multi_cursor: wm.multi_cursor_enabled,
};
(serde_json::to_vec(&settings).unwrap_or_default(), false)
}
@@ -518,6 +519,14 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc<std::cell::RefCell<Windo
wm.borrow_mut().phone_mode = v;
(ok(), true)
}
+ // `srd set multi_cursor <bool>` - live equivalent of `general.
+ // multi_cursor`. See `WindowManager::multi_cursor_enabled`'s own
+ // doc comment for why this is opt-in rather than always-on.
+ "multi_cursor" => {
+ let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("multi_cursor needs a boolean value"), false) };
+ wm.borrow_mut().multi_cursor_enabled = v;
+ (ok(), true)
+ }
"blur" => (err("blur is not supported - no GPU shader path on this compositor's software renderer yet"), false),
// The two ported Hyprland `decoration:screen_shader` scripts --
// mutually exclusive by construction (`srdwm_core::ColorFilter` is
diff --git a/crates/platform/src/ipc/types.rs b/crates/platform/src/ipc/types.rs
index 08c2d7e..1dc95f6 100644
--- a/crates/platform/src/ipc/types.rs
+++ b/crates/platform/src/ipc/types.rs
@@ -148,6 +148,8 @@ pub(crate) struct SettingsResponse {
/// without a second, separate way to ask "is this a phone-shaped
/// session".
pub(crate) phone_mode: bool,
+ /// `WindowManager::multi_cursor_enabled`'s own doc comment.
+ pub(crate) multi_cursor: bool,
}
/// `"keyboard_layout"`'s one-shot reply shape - the active XKB layout's
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs
index 74fc565..a3c9bf4 100644
--- a/crates/srdwm/src/main.rs
+++ b/crates/srdwm/src/main.rs
@@ -173,6 +173,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
let rounded_corners = engine.get("general.rounded_corners").and_then(|v| v.as_bool());
let gpu = engine.get_bool("general.gpu", false);
let phone_mode = engine.get_bool("general.phone_mode", false);
+ let multi_cursor = engine.get_bool("general.multi_cursor", false);
let desktop_icons = engine.get_bool("general.desktop_icons", true);
let desktop_icons_all_monitors = engine.get_bool("general.desktop_icons_all_monitors", true);
let reserve_top = engine.get_f64("general.reserve_top", 0.0).max(0.0) as u32;
@@ -308,6 +309,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
wm.rounded_corners_enabled = rounded_corners;
wm.gpu_enabled = gpu;
wm.phone_mode = phone_mode;
+ wm.multi_cursor_enabled = multi_cursor;
wm.desktop_icons_enabled = desktop_icons;
wm.desktop_icons_all_monitors = desktop_icons_all_monitors;
wm.reserve_top = reserve_top;
diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs
index 5ed96e4..b0a2639 100644
--- a/crates/wayland/src/udev/mod.rs
+++ b/crates/wayland/src/udev/mod.rs
@@ -107,6 +107,17 @@ pub(crate) struct DrmBuffer {
image: Image<'static, 'static>,
}
+/// How long a secondary-cursor entry (`UdevState::secondary_cursors`) is
+/// trusted after its own device's last real motion event before it's
+/// treated as stale and pruned/skipped - see that field's own doc
+/// comment for the frozen-ghost-cursor bug this exists to close. Short
+/// enough that a genuinely idle second device's sprite actually
+/// disappears at a human-noticeable timescale (not "eventually, whenever
+/// something else happens to touch this map"), generous enough that
+/// briefly pausing mid-gesture with a real second device doesn't flicker
+/// its own cursor away and back.
+pub(crate) const SECONDARY_CURSOR_TIMEOUT: Duration = Duration::from_millis(1500);
+
/// One connector+CRTC pair srdwm scans out to - i.e. one physical monitor.
///
/// Each head owns its own scanout buffers, damage tracker and flip state,
@@ -209,20 +220,30 @@ pub(crate) struct UdevState {
/// monitors; clamped to the union of all head rectangles.
pub(crate) pointer_pos: Point<f64, Logical>,
/// Multi-cursor mode, Phase 1: every physical pointer/trackpad's own
- /// last-known position, keyed by its real libinput device identity
- /// (`smithay::backend::input::Event::device()`, confirmed `Device:
- /// PartialEq + Eq + Hash` by reading smithay's own trait definition).
- /// Purely a *visual* addition - `pointer_pos` above is still the one
- /// position that actually drives clicks/drags/hit-testing, updated by
- /// whichever device moved most recently exactly as before, so nothing
- /// about existing interactive behaviour changes. This is what lets a
- /// mouse and a trackpad each show their own live cursor sprite instead
- /// of only the most-recently-moved device having a visible pointer at
- /// all - see `docs/TODO.md`'s "Multi-cursor" plan for what later
- /// phases would still need (per-device *interaction*, not just
- /// per-device *rendering*, and the real `wl_seat` ecosystem wall a
- /// second seat runs into for arbitrary client content).
- pub(crate) secondary_cursors: HashMap<smithay::reexports::input::Device, Point<f64, Logical>>,
+ /// last-known position *and when it was last actually recorded*, keyed
+ /// by its real libinput device identity (`smithay::backend::input::
+ /// Event::device()`, confirmed `Device: PartialEq + Eq + Hash` by
+ /// reading smithay's own trait definition). Purely a *visual*
+ /// addition - `pointer_pos` above is still the one position that
+ /// actually drives clicks/drags/hit-testing, updated by whichever
+ /// device moved most recently exactly as before, so nothing about
+ /// existing interactive behaviour changes.
+ ///
+ /// Gated behind `WindowManager::multi_cursor_enabled` (off by
+ /// default) and the timestamp both exist for the same real, reported
+ /// bug: real hardware routinely reports what is genuinely one mouse
+ /// as more than one distinct libinput device (a side-button/scroll
+ /// cluster on its own HID path, concretely) - the first motion event
+ /// from that phantom device seeded a permanent entry here, rendered
+ /// every frame forever after at wherever the pointer happened to be
+ /// at that one moment, since nothing ever moved that specific device
+ /// identity again. Reported live as "I see two cursors and can't even
+ /// control the other one" - a frozen, uncontrollable ghost, exactly
+ /// what an unpruned entry here looks like. `render_udev_frame` now
+ /// skips (and `handle_libinput_event` now prunes) any entry older
+ /// than `SECONDARY_CURSOR_TIMEOUT`, so only a device that has *itself*
+ /// moved recently ever shows a sprite.
+ pub(crate) secondary_cursors: HashMap<smithay::reexports::input::Device, (Point<f64, Logical>, Instant)>,
/// A clone of the same `LibSeatSession` `platform.rs` opened the DRM
/// device with (`LibSeatSession` is cheaply `Clone` - see its own
/// derive - all clones share the same underlying seat connection).
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index 4bfe04d..dc84a2e 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -321,13 +321,28 @@ impl CompState {
// (`cursor_status`/`cursor_buffers`) rather than each
// device getting its own - a real visual distinction
// between devices is a later-phase refinement, not needed
- // to prove multiple live positions render at all.
- let active_device = udev.secondary_cursors.iter().find(|&(_, &p)| p == pointer_pos).map(|(d, _)| d.clone());
- for (device, &pos) in &udev.secondary_cursors {
- if Some(device) == active_device.as_ref() {
- continue;
+ // to prove multiple live positions render at all. Gated
+ // on `general.multi_cursor` (off by default) and on each
+ // entry's own recency: a device that reported a position
+ // once and then never moved again - the real, reported
+ // live bug - stops rendering after `SECONDARY_CURSOR_
+ // TIMEOUT` instead of sitting frozen on screen forever.
+ if self.wm.borrow().multi_cursor_enabled {
+ let now = std::time::Instant::now();
+ let active_device = udev
+ .secondary_cursors
+ .iter()
+ .find(|&(_, &(p, _))| p == pointer_pos)
+ .map(|(d, _)| d.clone());
+ for (device, &(pos, seen)) in &udev.secondary_cursors {
+ if Some(device) == active_device.as_ref() {
+ continue;
+ }
+ if now.duration_since(seen) >= super::SECONDARY_CURSOR_TIMEOUT {
+ continue;
+ }
+ custom_elements.extend(crate::cursor::render_elements(&cursor_status, &cursor_buffers, &mut udev.renderer, pos, origin, hsize));
}
- custom_elements.extend(crate::cursor::render_elements(&cursor_status, &cursor_buffers, &mut udev.renderer, pos, origin, hsize));
}
// Night light/reading mode - a translucent full-output
// overlay, pushed right after the cursor so it colours
diff --git a/crates/wayland/src/udev/session.rs b/crates/wayland/src/udev/session.rs
index c80592e..e2f6e2b 100644
--- a/crates/wayland/src/udev/session.rs
+++ b/crates/wayland/src/udev/session.rs
@@ -319,6 +319,29 @@ pub(crate) fn register_udev_monitor(handle: &LoopHandle<'static, CompState>, sea
Ok(())
}
+/// Records `device`'s own live position for Multi-cursor Phase 1's
+/// secondary-cursor rendering, and prunes every entry (not just this
+/// device's own) older than `SECONDARY_CURSOR_TIMEOUT` - see
+/// `UdevState::secondary_cursors`'s own doc comment for why both the
+/// config gate and the pruning exist. A no-op, and clears the map
+/// outright, when the feature is off: toggling `general.multi_cursor`
+/// off live must not leave a stale sprite rendering from before the
+/// toggle (`render_udev_frame` also checks this same flag, but clearing
+/// here too keeps the map itself from silently growing while the
+/// feature is disabled).
+fn record_secondary_cursor(state: &mut CompState, device: smithay::reexports::input::Device, pos: Point<f64, Logical>) {
+ if !state.wm.borrow().multi_cursor_enabled {
+ if let Some(udev) = state.udev.as_mut() {
+ udev.secondary_cursors.clear();
+ }
+ return;
+ }
+ let Some(udev) = state.udev.as_mut() else { return };
+ let now = Instant::now();
+ udev.secondary_cursors.insert(device, (pos, now));
+ udev.secondary_cursors.retain(|_, (_, seen)| now.duration_since(*seen) < SECONDARY_CURSOR_TIMEOUT);
+}
+
fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputBackend>) {
match event {
InputEvent::Keyboard { event } => handle_keyboard_key_event(state, &event),
@@ -339,8 +362,10 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB
// cursors`'s own doc comment): records this specific physical
// device's own position too, purely for rendering its own
// cursor sprite - `pos`/`handle_pointer_position` below are
- // still the one interactive position, unchanged.
- udev.secondary_cursors.insert(event.device(), pos);
+ // still the one interactive position, unchanged. Opt-in via
+ // `general.multi_cursor` (`record_secondary_cursor` is a
+ // no-op, and clears any stale entries, when it's off).
+ record_secondary_cursor(state, event.device(), pos);
handle_pointer_position(state, pos, event.time_msec());
}
// Absolute-positioning devices (a touchscreen, a drawing tablet,
@@ -368,7 +393,7 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB
udev.pointer_pos.x = (pos.x + min_x).clamp(min_x, (max_x - 1.0).max(min_x));
udev.pointer_pos.y = (pos.y + min_y).clamp(min_y, (max_y - 1.0).max(min_y));
let pos = udev.pointer_pos;
- udev.secondary_cursors.insert(event.device(), pos);
+ record_secondary_cursor(state, event.device(), pos);
handle_pointer_position(state, pos, event.time_msec());
}
InputEvent::PointerButton { event } => {
diff --git a/docs/TODO.md b/docs/TODO.md
index d06cc54..a4c8d14 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -13,6 +13,19 @@ that has the full story. Keep this list current as items close or open;
update the source doc's own entry too, don't let this drift into a
second stale copy the way `PANEL_SUPPORT_TODO.md` did.
+## Real bug, root-caused and fixed: an uncontrollable "ghost" secondary cursor, on by default (2026-08-27)
+
+Reported live: "I see two cursors on screen and I can't even control the other one/shouldn't really auto show. It's more of like if I use two inputs at same time or for agents to use one without interrupting me." Multi-cursor Phase 1 (`UdevState::secondary_cursors`) drew one extra cursor sprite per physical libinput pointer device that had ever reported a position, unconditionally, with no way to turn it off and no way to know which physical device it belonged to.
+
+Two separate problems, both real:
+
+- **On by default with no opt-out.** Nothing about the two use cases this feature exists for - deliberately using two input devices at once, or an agent driving a window without disturbing the user's own pointer - wants a second sprite appearing uninvited. Added `general.multi_cursor` (`WindowManager::multi_cursor_enabled`, default `false`), live-settable via `srd set multi_cursor <true|false>` and readable via `srd get`/the `"settings"` IPC command, same as every other runtime toggle in this codebase. Off by default: the sprite now only ever appears if explicitly turned on.
+- **A stale entry rendered forever.** Real hardware routinely reports what is physically one mouse as more than one distinct libinput device - a side-button/scroll cluster enumerating on its own HID path is a real, common case, not a hypothetical - so a second, phantom device reports a position exactly once and then never moves again. `secondary_cursors` had no expiry, so that phantom's sprite sat frozen on screen indefinitely with nothing to control or dismiss it - exactly the reported symptom. `secondary_cursors` is now keyed to `(Point, Instant)` instead of a bare `Point`; `record_secondary_cursor` (`udev/session.rs`) prunes every entry older than the new `SECONDARY_CURSOR_TIMEOUT` (1500ms) on each recorded motion, and the render loop (`udev/render.rs`) independently skips any entry that's aged out since the last prune. A device has to have moved within the last 1.5 seconds to draw a sprite at all.
+
+The "agent controls a window without interrupting me" use case this report also asked about was never gated on this flag to begin with - that's Multi-cursor Phase 2's own job (`virtual_pointer.rs`'s pinned delivery via `zwlr_virtual_pointer_unstable_v1`), which delivers input directly to one pinned window/surface and never shows a visible cursor sprite at all, regardless of `general.multi_cursor`.
+
+Full workspace build/test/clippy clean. Not yet live-verified - needs a restart, which the user does on their own initiative per standing policy; nothing here was tested against a real second input device.
+
## Titlebar/decoration research: the requested system already exists; one real, unverified gap found (2026-08-27)
Asked directly for "different titlebars/decorations, non-traffic-light ones and right side... deep research... especially firefox/chrome". Read the actual code rather than assuming a gap: this is already a complete, working, documented system --