feat(client/input): desktop (absolute) mouse mode — remote-desktop sweep M1
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]| {
|
||||
|
||||
@@ -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]| {
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<u32>,
|
||||
/// 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<Abs>,
|
||||
/// 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<NativeClient>,
|
||||
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<bool> {
|
||||
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;
|
||||
|
||||
@@ -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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
.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<Option<Outcome>
|
||||
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<Option<Outcome>
|
||||
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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user