From 7295ae70f98a4e27969c9d397e472019b982e41d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 21 Jul 2026 23:52:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(client/input):=20desktop=20(absolute)=20mo?= =?UTF-8?q?use=20mode=20=E2=80=94=20remote-desktop=20sweep=20M1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New physical-mouse model beside capture: Desktop leaves the pointer uncaptured and sends absolute positions through the letterbox (MouseMoveAbs — every host injector already consumes it). Capture stays the default and the game model. - pf-client-core: MouseMode (capture|desktop) persisted in Settings, default capture so existing stores are unchanged. - pf-presenter: Capture grows a desktop model — latest-wins pending abs position coalesced per loop iteration (same 1000 Hz discipline as relative), flushed before clicks/keys/wheel so they land where the cursor is; Ctrl+Alt+Shift+M flips the model live; local cursor stays hidden over the window (the host's composited cursor is the one you see until the M2 cursor channel); Windows keyboard grab only engages for capture (a desktop stream is something you Alt-Tab away from). - gamescope gating: its EIS is relative-only, so desktop mode is pinned off there (resolved_compositor), with a log note. - Settings surfaces: GTK row (dynamic caption), WinUI combo, console-UI row + step test, capture-hint line. Verified: fmt + clippy -D warnings + tests (33/40/19) on Linux .21; clippy -D warnings for all five crates incl. punktfunk-client-windows native on .173. Co-Authored-By: Claude Fable 5 --- clients/linux/src/ui_settings.rs | 31 +++++ clients/session/src/console.rs | 1 + clients/session/src/main.rs | 1 + clients/windows/src/app/settings.rs | 18 +++ crates/pf-client-core/src/trust.rs | 57 +++++++++ crates/pf-console-ui/src/screens/settings.rs | 44 ++++++- crates/pf-presenter/src/input.rs | 66 +++++++++- crates/pf-presenter/src/run.rs | 119 +++++++++++++++---- 8 files changed, 311 insertions(+), 26 deletions(-) diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index f71a6124..e616b431 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -69,6 +69,14 @@ const TOUCH_MODE_CAPTIONS: &[&str] = &[ "The cursor jumps to your finger — a tap clicks there", "Real multi-touch reaches the host — for touch-native apps", ]; +/// Physical-mouse model values (persisted) + labels + dynamic captions — same idiom as +/// the touch rows. Ctrl+Alt+Shift+M flips the model live in-stream. +const MOUSE_MODES: &[&str] = &["capture", "desktop"]; +const MOUSE_MODE_LABELS: &[&str] = &["Capture (games)", "Desktop (absolute)"]; +const MOUSE_MODE_CAPTIONS: &[&str] = &[ + "Pointer locks to the stream — relative motion, best for games", + "Pointer moves freely in and out — best for remote desktop work", +]; /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. const APP_LICENSE: &str = concat!( @@ -542,6 +550,20 @@ pub fn show( set_row_subtitle(&w, TOUCH_MODE_CAPTIONS[i]); }); } + let mouse_row = ChoiceRow::new( + &dialog, + inline, + "Mouse input", + MOUSE_MODE_CAPTIONS[0], + MOUSE_MODE_LABELS, + ); + { + let w = mouse_row.widget().clone(); + mouse_row.connect_changed(move |i| { + let i = (i as usize).min(MOUSE_MODE_CAPTIONS.len() - 1); + set_row_subtitle(&w, MOUSE_MODE_CAPTIONS[i]); + }); + } let inhibit_row = adw::SwitchRow::builder() .title("Capture system shortcuts") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured") @@ -718,6 +740,12 @@ pub fn show( touch_row.set_selected(touch_i as u32); // set_selected never fires the changed hook, so seed the dynamic caption directly. set_row_subtitle(touch_row.widget(), TOUCH_MODE_CAPTIONS[touch_i]); + let mouse_i = MOUSE_MODES + .iter() + .position(|&m| m == s.mouse_mode) + .unwrap_or(0); + mouse_row.set_selected(mouse_i as u32); + set_row_subtitle(mouse_row.widget(), MOUSE_MODE_CAPTIONS[mouse_i]); let comp_i = COMPOSITORS .iter() .position(|&c| c == s.compositor) @@ -788,6 +816,7 @@ pub fn show( touch_group.add(touch_row.widget()); // Group titles are Pango markup — the ampersand must be an entity. let kbm_group = group("Keyboard & mouse", ""); + kbm_group.add(mouse_row.widget()); kbm_group.add(&inhibit_row); kbm_group.add(&invert_row); input.add(&touch_group); @@ -867,6 +896,8 @@ pub fn show( } s.touch_mode = TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string(); + s.mouse_mode = + MOUSE_MODES[(mouse_row.selected() as usize).min(MOUSE_MODES.len() - 1)].to_string(); s.forward_pad = chosen_pin.borrow().clone(); s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] .to_string(); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 35358386..227eb321 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -158,6 +158,7 @@ pub fn run(target: Option<&str>) -> u8 { v => v, }, touch_mode: settings_at_start.touch_mode(), + mouse_mode: settings_at_start.mouse_mode(), invert_scroll: settings_at_start.invert_scroll, json_status, on_connected: Some(Box::new(move |fingerprint: [u8; 32]| { diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index a4a1a2bf..3205e47a 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -429,6 +429,7 @@ mod session_main { v => v, }, touch_mode: settings.touch_mode(), + mouse_mode: settings.mouse_mode(), invert_scroll: settings.invert_scroll, json_status: true, on_connected: Some(Box::new(|fingerprint: [u8; 32]| { diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 57d727d1..47910b03 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -90,6 +90,13 @@ const TOUCH_MODES: &[(&str, &str)] = &[ ("pointer", "Direct pointer"), ("touch", "Touch passthrough"), ]; +/// Physical-mouse presets: `(stored value, display label)` — capture (pointer lock, +/// relative, for games) vs desktop (uncaptured absolute pointer, for remote desktop +/// work). Ctrl+Alt+Shift+M flips the model live in-stream. +const MOUSE_MODES: &[(&str, &str)] = &[ + ("capture", "Capture (games)"), + ("desktop", "Desktop (absolute)"), +]; /// Host compositor presets: `(stored value, display label)`. Advisory — the host falls back to /// auto-detect when the choice is unavailable. Only meaningful against a Linux host. const COMPOSITORS: &[(&str, &str)] = &[ @@ -394,6 +401,10 @@ pub(crate) fn settings_page( let touch_combo = setting_combo(ctx, "Touch input", touch_names, touch_i, |s, i| { s.touch_mode = TOUCH_MODES[i].0.to_string(); }); + let (mouse_names, mouse_i) = presets(MOUSE_MODES, |v| *v == s.mouse_mode); + let mouse_combo = setting_combo(ctx, "Mouse input", mouse_names, mouse_i, |s, i| { + s.mouse_mode = MOUSE_MODES[i].0.to_string(); + }); let invert_scroll_toggle = setting_toggle(ctx, "Invert scroll direction", s.invert_scroll, |s, on| { s.invert_scroll = on @@ -542,6 +553,13 @@ pub(crate) fn settings_page( out.extend(group( Some("Keyboard & mouse"), vec![ + described( + mouse_combo, + "Capture locks the pointer to the stream and sends relative motion — \ + best for games. Desktop leaves the pointer free to enter and leave \ + the stream and sends absolute positions — best for remote desktop \ + work. Ctrl+Alt+Shift+M switches live.", + ), described( shortcuts_toggle, "Alt+Tab, the Windows key and friends reach the host while the stream \ diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 7adf116f..bd556bca 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -456,6 +456,48 @@ impl TouchMode { } } +/// How a physical mouse drives the host — the desktop-sweep mouse model +/// (design/remote-desktop-sweep.md M1). Stored stringly in [`Settings::mouse_mode`] so the +/// file stays readable; parsed with [`MouseMode::from_name`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MouseMode { + /// Pointer lock (relative deltas, hidden cursor) — the game model, and the default: + /// the only cursor you see is the host's. + Capture, + /// Absolute pointer, uncaptured: the cursor enters and leaves the stream freely and + /// motion goes on the wire as absolute positions through the letterbox. The remote + /// desktop model. Requires a host injector with absolute support (not gamescope). + Desktop, +} + +impl MouseMode { + /// Cycle/picker order (also the settings pickers' option order). + pub const ALL: [MouseMode; 2] = [MouseMode::Capture, MouseMode::Desktop]; + + /// Parse the persisted name, defaulting to `Capture` for unset/unknown values. + pub fn from_name(s: &str) -> MouseMode { + match s { + "desktop" => MouseMode::Desktop, + _ => MouseMode::Capture, + } + } + + /// The persisted name (the inverse of [`from_name`](Self::from_name)). + pub fn as_name(self) -> &'static str { + match self { + MouseMode::Capture => "capture", + MouseMode::Desktop => "desktop", + } + } + + pub fn label(self) -> &'static str { + match self { + MouseMode::Capture => "Capture (games)", + MouseMode::Desktop => "Desktop (absolute)", + } + } +} + /// App settings, persisted as JSON. Stringly-typed gamepad/compositor prefs so the file /// stays readable; parsed with `*Pref::from_name` at connect time. #[derive(Clone, Serialize, Deserialize)] @@ -490,6 +532,12 @@ pub struct Settings { /// stores load as trackpad. #[serde(default = "default_touch_mode")] pub touch_mode: String, + /// How a physical mouse drives the host: a [`MouseMode`] name — `"capture"` (default, + /// pointer lock + relative) or `"desktop"` (uncaptured absolute pointer). Read at + /// connect via [`Settings::mouse_mode`]. `default` so pre-existing stores load as + /// capture — today's behavior. + #[serde(default = "default_mouse_mode")] + pub mouse_mode: String, /// Grab compositor shortcuts (Alt+Tab, Super…) while input is captured. pub inhibit_shortcuts: bool, /// Stream the default microphone to the host's virtual mic source. @@ -577,6 +625,10 @@ fn default_touch_mode() -> String { "trackpad".into() } +fn default_mouse_mode() -> String { + "capture".into() +} + fn default_true() -> bool { true } @@ -604,6 +656,10 @@ impl Settings { TouchMode::from_name(&self.touch_mode) } + pub fn mouse_mode(&self) -> MouseMode { + MouseMode::from_name(&self.mouse_mode) + } + /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). pub fn preferred_codec(&self) -> u8 { match self.codec.as_str() { @@ -631,6 +687,7 @@ impl Default for Settings { forward_pad: String::new(), compositor: "auto".into(), touch_mode: "trackpad".into(), + mouse_mode: "capture".into(), inhibit_shortcuts: true, mic_enabled: false, audio_channels: 2, diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index e6511882..dedbbd4e 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -10,7 +10,7 @@ use crate::screens::{Ctx, Outbox}; use crate::theme::{Fonts, DIM, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; -use pf_client_core::trust::{StatsVerbosity, TouchMode}; +use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; use skia_safe::{Canvas, Rect}; /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale @@ -29,10 +29,11 @@ enum RowId { Pad, PadType, Touch, + Mouse, Stats, } -const ROWS: [RowId; 13] = [ +const ROWS: [RowId; 14] = [ RowId::Resolution, RowId::Refresh, RowId::Bitrate, @@ -45,6 +46,7 @@ const ROWS: [RowId; 13] = [ RowId::Pad, RowId::PadType, RowId::Touch, + RowId::Mouse, RowId::Stats, ]; @@ -251,6 +253,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { "Touch mode", s.touch_mode().label().into(), ), + RowId::Mouse => (None, "Mouse mode", s.mouse_mode().label().into()), RowId::Stats => ( Some("Interface"), "Statistics overlay", @@ -292,6 +295,11 @@ fn detail(id: RowId) -> &'static str { "How the touchscreen drives the host: Trackpad (relative cursor), \ Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)." } + RowId::Mouse => { + "How a physical mouse drives the host: Capture locks the pointer (relative, \ + for games), Desktop leaves it free and sends absolute positions. \ + Ctrl+Alt+Shift+M switches live while streaming." + } RowId::Stats => { "How much the overlay shows: Compact (one line) → Normal → Detailed. \ Ctrl+Alt+Shift+S cycles it live while streaming." @@ -367,6 +375,11 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { step_option(cur, TouchMode::ALL.len(), delta, wrap) .map(|i| s.touch_mode = TouchMode::ALL[i].as_name().to_string()) } + RowId::Mouse => { + let cur = MouseMode::ALL.iter().position(|m| *m == s.mouse_mode()); + step_option(cur, MouseMode::ALL.len(), delta, wrap) + .map(|i| s.mouse_mode = MouseMode::ALL[i].as_name().to_string()) + } RowId::Stats => { let cur = StatsVerbosity::ALL .iter() @@ -510,6 +523,33 @@ mod tests { assert_eq!(ctx.settings.touch_mode, "trackpad"); } + #[test] + fn mouse_mode_steps_and_wraps() { + let (mut settings, pads) = ctx_parts(); + assert_eq!(settings.mouse_mode, "capture"); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + // Capture → Desktop, then a step past the end is a boundary. + assert!( + !adjust(RowId::Mouse, -1, false, &mut ctx), + "already first = thud" + ); + assert!(adjust(RowId::Mouse, 1, false, &mut ctx)); + assert_eq!(ctx.settings.mouse_mode, "desktop"); + assert!(!adjust(RowId::Mouse, 1, false, &mut ctx), "last = thud"); + // A wraps back to the first. + assert!(adjust(RowId::Mouse, 1, true, &mut ctx)); + assert_eq!(ctx.settings.mouse_mode, "capture"); + } + #[test] fn unknown_value_snaps_to_first() { let (mut settings, pads) = ctx_parts(); diff --git a/crates/pf-presenter/src/input.rs b/crates/pf-presenter/src/input.rs index c67cb6c3..ef82eefb 100644 --- a/crates/pf-presenter/src/input.rs +++ b/crates/pf-presenter/src/input.rs @@ -14,10 +14,17 @@ //! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Motion deltas are //! COALESCED: one summed `MouseMove` per loop iteration (a 1000 Hz mouse would //! otherwise send a datagram per event). +//! +//! The DESKTOP mouse model (design/remote-desktop-sweep.md M1) reuses this same engage/ +//! release state but never locks the pointer: the local cursor moves freely (hidden over +//! the window — the host's composited cursor is the one you see) and motion goes on the +//! wire as absolute positions through the letterbox (`MouseMoveAbs`, latest-wins per loop +//! iteration). Requires a host injector with absolute support — gamescope's EIS is +//! relative-only, so sessions there are pinned to capture ([`Capture::new`] `abs_ok`). use crate::keymap_sdl; use crate::touch::{Abs, Act, Gestures}; -use pf_client_core::trust::TouchMode; +use pf_client_core::trust::{MouseMode, TouchMode}; use punktfunk_core::client::NativeClient; use punktfunk_core::input::{InputEvent, InputKind}; use std::collections::{HashMap, HashSet}; @@ -41,6 +48,13 @@ pub struct Capture { held_buttons: HashSet, /// Relative motion not yet on the wire, summed per loop iteration. pending_rel: (i32, i32), + /// Desktop-model position not yet on the wire, latest-wins per loop iteration. + pending_abs: Option, + /// The desktop (absolute, uncaptured) mouse model is active. Flipped live by the + /// Ctrl+Alt+Shift+M chord; never true unless `abs_ok`. + desktop: bool, + /// The host injector accepts `MouseMoveAbs` (any compositor but gamescope). + abs_ok: bool, /// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space — /// precision surfaces deliver sub-unit deltas; truncating each event drops the tail. scroll_acc: (f64, f64), @@ -70,10 +84,14 @@ fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, fl } impl Capture { + /// `abs_ok` = the host injector accepts absolute pointer events; without it the + /// desktop model is unavailable and `mouse_mode` silently resolves to capture. pub fn new( connector: Arc, touch_mode: TouchMode, invert_scroll: bool, + mouse_mode: MouseMode, + abs_ok: bool, ) -> Capture { Capture { connector, @@ -82,6 +100,9 @@ impl Capture { held_keys: HashSet::new(), held_buttons: HashSet::new(), pending_rel: (0, 0), + pending_abs: None, + desktop: abs_ok && mouse_mode == MouseMode::Desktop, + abs_ok, scroll_acc: (0.0, 0.0), touch_slots: HashMap::new(), touch_mode, @@ -94,6 +115,24 @@ impl Capture { self.captured } + /// The desktop (absolute, uncaptured) mouse model is active. + pub fn desktop(&self) -> bool { + self.desktop + } + + /// Flip capture ⇄ desktop (the Ctrl+Alt+Shift+M chord). `None` = the host can't take + /// absolute pointer events (gamescope), so the chord has nothing to offer; otherwise + /// the new desktop state. Motion gathered under the old model never crosses modes. + pub fn toggle_desktop(&mut self) -> Option { + if !self.abs_ok { + return None; + } + self.desktop = !self.desktop; + self.pending_rel = (0, 0); + self.pending_abs = None; + Some(self.desktop) + } + /// Whether a regained focus should re-engage: yes unless the user released /// deliberately (the chord keeps its meaning across an Alt-Tab). pub fn should_reengage(&self) -> bool { @@ -117,6 +156,7 @@ impl Capture { return false; } self.pending_rel = (0, 0); // never flush motion gathered while captured + self.pending_abs = None; for vk in self.held_keys.drain() { send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0); } @@ -132,22 +172,42 @@ impl Capture { true } - /// Forward the coalesced motion delta, if any — one datagram per loop iteration. + /// Forward the coalesced motion, if any — one datagram per loop iteration. Only one + /// of the two stores is ever populated (the run loop routes by [`desktop`](Self::desktop)). pub fn flush_motion(&mut self) { let (dx, dy) = std::mem::take(&mut self.pending_rel); if dx != 0 || dy != 0 { send(&self.connector, InputKind::MouseMove, 0, dx, dy, 0); } + if let Some(a) = self.pending_abs.take() { + send( + &self.connector, + InputKind::MouseMoveAbs, + 0, + a.x, + a.y, + Self::touch_flags(a.w, a.h), + ); + } } /// Relative motion (SDL relative mouse mode delivers raw deltas while locked). pub fn on_motion(&mut self, xrel: f32, yrel: f32) { - if self.captured { + if self.captured && !self.desktop { self.pending_rel.0 += xrel as i32; self.pending_rel.1 += yrel as i32; } } + /// Desktop-model motion: the cursor's position mapped into the letterboxed content + /// rect. Latest-wins — intermediate positions carry no information the final one + /// doesn't (unlike deltas, which must sum). + pub fn on_motion_abs(&mut self, abs: Abs) { + if self.captured && self.desktop { + self.pending_abs = Some(abs); + } + } + pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode) { if !self.captured { return; diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 1f8a17e1..9f197618 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -20,11 +20,11 @@ use crate::vk::{FrameInput, Presenter}; use anyhow::{Context as _, Result}; use pf_client_core::gamepad::GamepadService; use pf_client_core::session::{self, SessionEvent, SessionHandle, SessionParams, Stats}; -use pf_client_core::trust::{StatsVerbosity, TouchMode}; +use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; use pf_client_core::video::VulkanDecodeDevice; use pf_client_core::video::{DecodedFrame, DecodedImage}; use punktfunk_core::client::NativeClient; -use punktfunk_core::config::Mode; +use punktfunk_core::config::{CompositorPref, Mode}; use sdl3::event::{Event, WindowEvent}; use sdl3::keyboard::Mod; use std::sync::atomic::{AtomicBool, Ordering}; @@ -48,6 +48,11 @@ pub struct SessionOpts { /// `Pointer` (absolute cursor), or `Touch` (real multi-touch passthrough). Latched per /// session — a mouse-only client leaves this at the default and never sees a finger. pub touch_mode: TouchMode, + /// Physical-mouse model: `Capture` (pointer lock + relative, the default) or `Desktop` + /// (uncaptured absolute pointer — design/remote-desktop-sweep.md M1). Ctrl+Alt+Shift+M + /// flips it live; silently resolves to capture on hosts without absolute injection + /// (gamescope). + pub mouse_mode: MouseMode, /// Reverse the scroll direction sent to the host ([`Settings::invert_scroll`]). pub invert_scroll: bool, /// Emit the `{"ready":true}` stdout line after the first presented frame. @@ -490,7 +495,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result WindowEvent::FocusLost => { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release(false) { - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); tracing::info!("focus lost — input released"); } } @@ -501,7 +506,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.should_reengage() { cap.engage(); - apply_capture(&mut window, &mouse, true); + apply_capture(&mut window, &mouse, true, cap.desktop()); tracing::info!("focus gained — input recaptured"); } } @@ -537,20 +542,39 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.captured() { cap.release(true); - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); } else { cap.engage(); - apply_capture(&mut window, &mouse, true); + apply_capture(&mut window, &mouse, true, cap.desktop()); } tracing::info!(captured = cap.captured(), "chord: release/engage"); } continue; } + // Mouse model flip (capture ⇄ desktop) — applies immediately when + // engaged; a released stream just changes what the next engage does. + if chord && sc == Scancode::M { + if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { + match cap.toggle_desktop() { + Some(desktop) => { + if cap.captured() { + apply_capture(&mut window, &mouse, true, desktop); + } + tracing::info!(desktop, "chord: mouse mode"); + } + None => tracing::info!( + "chord: mouse mode — host has no absolute pointer \ + (gamescope), staying captured" + ), + } + } + continue; + } if chord && sc == Scancode::D { if let Some(st) = &mut stream { tracing::info!("chord: disconnect"); st.request_quit(); - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); // The pump emits Ended(None); the end path routes per mode. } continue; @@ -583,9 +607,34 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result cap.on_key_up(sc); } } - Event::MouseMotion { xrel, yrel, .. } => { - if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { - cap.on_motion(xrel, yrel); + Event::MouseMotion { + x, y, xrel, yrel, .. + } => { + if let Some(st) = stream.as_mut() { + let video = st.last_video; + if let Some(cap) = st.capture.as_mut() { + if cap.desktop() { + // Desktop model: the cursor's window position through the + // letterbox (same mapping as a pointer-mode finger). + // Before the first decoded frame there is nothing to map + // onto — dropped, like touch. + if let Some(video) = video { + let (lw, lh) = window.size(); + let nx = x / lw.max(1) as f32; + let ny = y / lh.max(1) as f32; + let (ax, ay, aw, ah) = + finger_to_content(window.size_in_pixels(), video, nx, ny); + cap.on_motion_abs(Abs { + x: ax, + y: ay, + w: aw, + h: ah, + }); + } + } else { + cap.on_motion(xrel, yrel); + } + } } } Event::MouseButtonDown { mouse_btn, .. } => { @@ -593,7 +642,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if !cap.captured() { // The engaging click is suppressed toward the host. cap.engage(); - apply_capture(&mut window, &mouse, true); + apply_capture(&mut window, &mouse, true, cap.desktop()); } else { cap.on_button_down(mouse_btn); } @@ -714,7 +763,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result while escape_rx.try_recv().is_ok() { if let Some(cap) = stream.as_mut().and_then(|s| s.capture.as_mut()) { if cap.release(true) { - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); } } if fullscreen && !opts.fullscreen { @@ -727,7 +776,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = &mut stream { tracing::info!("controller chord: disconnect"); st.request_quit(); - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); } } @@ -818,9 +867,26 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .ok(); gamepad.attach(c.clone()); st.clock_offset = Some(c.clock_offset_shared()); - let mut cap = Capture::new(c.clone(), opts.touch_mode, opts.invert_scroll); + // gamescope's EIS grants only a relative pointer — absolute sends + // would be dropped, so the desktop model is pinned off there. Auto + // (an older host that didn't say) stays allowed: Windows hosts and + // pre-Welcome-compositor Linux hosts both take absolute. + let abs_ok = c.resolved_compositor != CompositorPref::Gamescope; + if opts.mouse_mode == MouseMode::Desktop && !abs_ok { + tracing::info!( + "desktop mouse mode unavailable on a gamescope host \ + (relative-only input) — using capture" + ); + } + let mut cap = Capture::new( + c.clone(), + opts.touch_mode, + opts.invert_scroll, + opts.mouse_mode, + abs_ok, + ); cap.engage(); // capture engages when the stream starts (ui_stream parity) - apply_capture(&mut window, &mouse, true); + apply_capture(&mut window, &mouse, true, cap.desktop()); st.capture = Some(cap); st.connector = Some(c); if let Some(f) = opts.on_connected.as_mut() { @@ -870,7 +936,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(st) = stream.take() { st.shutdown(); } - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); if let Some(o) = overlay.as_mut() { // A user-canceled dial ends silently — no error scene. if canceled { @@ -887,7 +953,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if let Some(cap) = &mut st.capture { cap.release(true); } - apply_capture(&mut window, &mouse, false); + apply_capture(&mut window, &mouse, false, false); match &mode { ModeCtl::Single(_) => break 'main Some(Outcome::Ended(reason)), ModeCtl::Browse(_) => { @@ -1477,11 +1543,22 @@ impl ResizeIndicator { /// with a low-level keyboard hook, the same mechanism the WinUI shell's in-process /// client used its own WH_KEYBOARD_LL hooks for. Not engaged on Linux: the compositor /// shortcut-inhibit story stays the shells' concern (Settings.inhibit_shortcuts). -fn apply_capture(window: &mut sdl3::video::Window, mouse: &sdl3::mouse::MouseUtil, on: bool) { - mouse.set_relative_mouse_mode(window, on); +/// +/// The `desktop` mouse model never locks: the pointer roams (and leaves the window) +/// freely, the local cursor is hidden over the window — the host's composited cursor, +/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips +/// who draws it) — and system chords stay local (a remote desktop is something you +/// Alt-Tab away from, not into). `desktop` only matters while `on`. +fn apply_capture( + window: &mut sdl3::video::Window, + mouse: &sdl3::mouse::MouseUtil, + on: bool, + desktop: bool, +) { + mouse.set_relative_mouse_mode(window, on && !desktop); mouse.show_cursor(!on); #[cfg(windows)] - window.set_keyboard_grab(on); + window.set_keyboard_grab(on && !desktop); } /// Is this SDL touch device a real touchscreen (DIRECT, window-relative coordinates)? @@ -1599,7 +1676,7 @@ struct PresentedWindow { /// The capture hints (`ui_stream` parity — the words the user reads while released). const HINT_KEYBOARD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ - Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"; + Ctrl+Alt+Shift+M mouse mode · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats"; const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · \ Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave";