diff options
| author | srdusr <[email protected]> | 2025-10-03 22:59:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-10-03 22:59:00 +0200 |
| commit | 4eb36873d4ca4b0faf0e54dbeab8f5f6450dd704 (patch) | |
| tree | 657c89f7398f98b0305b9cd69889c232608f3027 | |
| parent | d7031dd060ec12e6de334518aa75122c65794047 (diff) | |
| download | srdwm-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.rs | 6 | ||||
| -rw-r--r-- | crates/core/src/manager/mod.rs | 21 | ||||
| -rw-r--r-- | crates/ctl/src/main.rs | 12 | ||||
| -rw-r--r-- | crates/platform/src/ipc/dispatch.rs | 9 | ||||
| -rw-r--r-- | crates/platform/src/ipc/types.rs | 2 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/wayland/src/udev/mod.rs | 49 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 27 | ||||
| -rw-r--r-- | crates/wayland/src/udev/session.rs | 31 | ||||
| -rw-r--r-- | docs/TODO.md | 13 |
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 -- |