From 719190ca89de299babbf7f4277f105b8aa715439 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Wed, 29 Apr 2026 13:21:29 -0500 Subject: [PATCH 01/52] feat: cross-platform wall-press auto-release fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a host-side fallback that releases capture when the user sweeps the cursor against the host-adjacent edge of the guest and keeps pushing past a configurable threshold. Solves the "two locked screens" case where the peer's capture backend can't fire CaptureBegin (and therefore can't send Leave back), leaving the host stuck capturing indefinitely until the release-bind chord is pressed. Algorithm lives in InputCapture::poll_next so every backend (macOS, libei, layer-shell, x11, windows, dummy) gets it for free — they only need to emit standard motion events through the existing Stream interface, which they already do. The wrapper tracks: virtual_pos: signed position along the entry axis, clamped at 0 from below. No upper clamp — the wrapper can't know the guest's far-edge extent without protocol-level cooperation, and any proxy is wrong for some user's setup. wall_pressure: motion that overshoots the host-adjacent edge and would have driven virtual_pos negative. Fires CaptureEvent::AutoRelease when the threshold is reached; the capture loop then runs the same teardown path as the release-bind chord. State resets on Begin (entry to capture), AutoRelease (we self-released), and external release (chord, peer Leave, connection error, EnterOnly fallback). Surface: - New FrontendRequest::SetReleaseThreshold + FrontendEvent:: ReleaseThreshold IPC pair. - New release_threshold_px field on the daemon config (0 = off, serialized to config.toml). - New AdwPreferencesGroup with a 0–500px slider in the GTK window. Default 0 (disabled) so existing users see no behavior change until they opt in. - New CaptureEvent::AutoRelease variant + handling in src/capture.rs's handle_capture_event (short-circuit to release_capture, which already synthesizes key-ups and sends Leave to the peer). Known limitation: the wrapper has no way to know where the guest's cursor actually is (the guest doesn't tell us). On re-entry into a peer mid-session, virtual_pos resets to 0 but the guest's cursor may still be in the middle of its screen from the prior session, causing the threshold to fire from the wrong reference point. A protocol-level Bounds event + cursor-warp on Enter is needed for full correctness. Co-Authored-By: Claude Opus 4.7 (1M context) --- input-capture/src/lib.rs | 138 ++++++++++++++++++++++++++++-- lan-mouse-gtk/resources/window.ui | 52 +++++++++++ lan-mouse-gtk/src/lib.rs | 3 + lan-mouse-gtk/src/window.rs | 26 ++++++ lan-mouse-gtk/src/window/imp.rs | 32 ++++++- lan-mouse-ipc/src/lib.rs | 5 ++ src/capture.rs | 33 +++++++ src/config.rs | 23 +++++ src/service.rs | 19 +++- 9 files changed, 324 insertions(+), 7 deletions(-) diff --git a/input-capture/src/lib.rs b/input-capture/src/lib.rs index b1ef6c0be..460857a8e 100644 --- a/input-capture/src/lib.rs +++ b/input-capture/src/lib.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use futures::StreamExt; use futures_core::Stream; -use input_event::{Event, KeyboardEvent, scancode}; +use input_event::{Event, KeyboardEvent, PointerEvent, scancode}; pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; @@ -41,6 +41,11 @@ pub enum CaptureEvent { Begin, /// input event coming from capture handle Input(Event), + /// the capture wrapper detected sustained back-toward-host motion + /// past the configured threshold (the user has pinned the cursor + /// at the host-adjacent edge of the guest and kept pushing). The + /// capture loop should treat this like a release-bind chord. + AutoRelease, } impl Display for CaptureEvent { @@ -48,6 +53,7 @@ impl Display for CaptureEvent { match self { CaptureEvent::Begin => write!(f, "begin capture"), CaptureEvent::Input(e) => write!(f, "{e}"), + CaptureEvent::AutoRelease => write!(f, "auto-release"), } } } @@ -127,6 +133,37 @@ pub struct InputCapture { id_map: HashMap, /// pending events pending: VecDeque<(CaptureHandle, CaptureEvent)>, + /// pixel threshold for the cross-platform auto-release-on-wall- + /// press fallback. 0 disables. See `track_wall_press`. + release_threshold_px: u32, + /// position the cursor is currently captured into, if any. Tracks + /// `Begin`/release transitions so the wall-press accumulator + /// resets correctly across capture sessions. + capture_pos: Option, + /// Modeled cursor position on the guest along the entry axis, + /// relative to the host-adjacent edge. 0 = at the entry edge, + /// growing values = further into the guest. Clamped at 0 from + /// below; *not* clamped from above (the wrapper can't know the + /// guest's far-edge extent without a protocol-level Bounds event, + /// and any proxy is wrong for some user's setup). + virtual_pos: f64, + /// Pixels of back-toward-host motion that the modeled cursor + /// could not absorb (proposed virtual_pos < 0). Resets whenever + /// the cursor is back in the interior or moving deeper. + wall_pressure: f64, +} + +/// Project a motion delta onto the entry axis. Positive return = +/// "into guest", so virtual_pos increases as the user pushes deeper. +fn entry_axis_delta(position: Position, dx: f64, dy: f64) -> f64 { + match position { + // Position::Left = guest is to the LEFT of host. User entered + // by moving left (-dx). Convention: positive = into guest. + Position::Left => -dx, + Position::Right => dx, + Position::Top => -dy, + Position::Bottom => dy, + } } impl InputCapture { @@ -168,9 +205,82 @@ impl InputCapture { /// release mouse pub async fn release(&mut self) -> Result<(), CaptureError> { self.pressed_keys.clear(); + self.reset_wall_press_state(); self.capture.release().await } + /// Configure the wall-press auto-release pixel threshold. + /// 0 disables. Effective immediately for the next motion event; + /// no need to recreate the backend. + pub fn set_release_threshold(&mut self, threshold: u32) { + self.release_threshold_px = threshold; + } + + fn reset_wall_press_state(&mut self) { + self.capture_pos = None; + self.virtual_pos = 0.0; + self.wall_pressure = 0.0; + } + + /// Update the wall-press accumulator from one event coming up + /// from the backend. Returns true if the threshold was reached + /// and an `AutoRelease` should be synthesized for the active + /// capture position. + fn track_wall_press(&mut self, pos: Position, event: &CaptureEvent) -> bool { + match event { + CaptureEvent::Begin => { + self.capture_pos = Some(pos); + self.virtual_pos = 0.0; + self.wall_pressure = 0.0; + false + } + CaptureEvent::AutoRelease => { + self.reset_wall_press_state(); + false + } + CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { dx, dy, .. })) => { + if self.release_threshold_px == 0 { + return false; + } + let Some(active_pos) = self.capture_pos else { + return false; + }; + if active_pos != pos { + return false; + } + + let delta = entry_axis_delta(active_pos, *dx, *dy); + let proposed = self.virtual_pos + delta; + self.virtual_pos = proposed.max(0.0); + + if proposed < 0.0 { + // Motion overshot the host-adjacent edge — + // accumulate the unabsorbed amount as wall + // pressure. + self.wall_pressure += -proposed; + } else { + // Cursor moved into the interior or further in; + // reset so a brief bump against the wall followed + // by motion deeper into the guest doesn't combine + // with a later wall-press to fire spuriously. + self.wall_pressure = 0.0; + } + + if self.wall_pressure >= f64::from(self.release_threshold_px) { + log::info!( + "auto-release: {:.0}px wall-press past entry edge ({}px threshold)", + self.wall_pressure, + self.release_threshold_px + ); + self.reset_wall_press_state(); + return true; + } + false + } + _ => false, + } + } + /// Drain and return every key the capture has forwarded as /// down-but-not-up. The caller is expected to synthesize key-up /// events to the remote peer for each — otherwise the peer @@ -198,6 +308,10 @@ impl InputCapture { pending: Default::default(), position_map: Default::default(), pressed_keys: HashSet::new(), + release_threshold_px: 0, + capture_pos: None, + virtual_pos: 0.0, + wall_pressure: 0.0, }) } @@ -248,6 +362,11 @@ impl Stream for InputCapture { self.update_pressed_keys(key, state); } + // wall-press auto-release tracking. Runs against every event + // before routing so a single global accumulator stays consistent + // regardless of how many handles exist at this position. + let auto_release = self.track_wall_press(pos, &event); + let len = self .position_map .get(&pos) @@ -256,16 +375,25 @@ impl Stream for InputCapture { match len { 0 => Poll::Pending, - 1 => Poll::Ready(Some(Ok(( - self.position_map.get(&pos).expect("no id")[0], - event, - )))), + 1 => { + let id = self.position_map.get(&pos).expect("no id")[0]; + if auto_release { + // Deliver the original motion first; queue the + // synthesized AutoRelease so the next poll picks + // it up. + self.pending.push_back((id, CaptureEvent::AutoRelease)); + } + Poll::Ready(Some(Ok((id, event)))) + } _ => { let mut position_map = HashMap::new(); swap(&mut self.position_map, &mut position_map); { for &id in position_map.get(&pos).expect("position") { self.pending.push_back((id, event)); + if auto_release { + self.pending.push_back((id, CaptureEvent::AutoRelease)); + } } } swap(&mut self.position_map, &mut position_map); diff --git a/lan-mouse-gtk/resources/window.ui b/lan-mouse-gtk/resources/window.ui index 609ea4e88..5afc81b48 100644 --- a/lan-mouse-gtk/resources/window.ui +++ b/lan-mouse-gtk/resources/window.ui @@ -198,6 +198,58 @@ + + + Auto-release + Auto-release capture when the cursor reaches the host-adjacent edge of the guest and you keep pushing. 0 disables; rely on the release-bind chord or a peer-side leave event. + + + Release threshold + pixels of wall-press past the entry edge before auto-release fires + + + disabled + center + 8 + 1.0 + + + + + + + + horizontal + true + false + 0 + 12 + 12 + 4 + 8 + + + 0 + 500 + 10 + 50 + 0 + + + + off + 50 + 100 + 200 + 500 + + + + + Connections diff --git a/lan-mouse-gtk/src/lib.rs b/lan-mouse-gtk/src/lib.rs index ecdd7080c..688d37a1f 100644 --- a/lan-mouse-gtk/src/lib.rs +++ b/lan-mouse-gtk/src/lib.rs @@ -269,6 +269,9 @@ fn build_ui(app: &Application) { FrontendEvent::IncomingDisconnected(addr) => { window.show_toast(format!("{addr} disconnected").as_str()); } + FrontendEvent::ReleaseThreshold(threshold) => { + window.set_release_threshold(threshold); + } } } } diff --git a/lan-mouse-gtk/src/window.rs b/lan-mouse-gtk/src/window.rs index f65015725..22c9d7480 100644 --- a/lan-mouse-gtk/src/window.rs +++ b/lan-mouse-gtk/src/window.rs @@ -407,6 +407,10 @@ impl Window { self.request(FrontendRequest::Create); } + pub(super) fn request_release_threshold(&self, threshold: u32) { + self.request(FrontendRequest::SetReleaseThreshold(threshold)); + } + fn open_fingerprint_dialog(&self, fp: Option) { let window = FingerprintWindow::new(fp); window.set_transient_for(Some(self)); @@ -461,6 +465,28 @@ impl Window { self.update_capture_emulation_status(); } + pub(super) fn set_release_threshold(&self, threshold: u32) { + let imp = self.imp(); + // Block the value-changed handler so programmatically setting + // the slider value (e.g. on Sync from the daemon) doesn't + // ricochet back as a SetReleaseThreshold request. + let scale = &imp.release_threshold_scale; + let handler_id = imp.release_threshold_handler.borrow(); + if let Some(id) = handler_id.as_ref() { + scale.block_signal(id); + } + scale.set_value(threshold as f64); + if let Some(id) = handler_id.as_ref() { + scale.unblock_signal(id); + } + let label = if threshold == 0 { + "disabled".to_string() + } else { + format!("{threshold} px") + }; + imp.release_threshold_value.set_label(&label); + } + #[cfg(target_os = "macos")] pub(super) fn refresh_capture_emulation_status(&self) { self.update_capture_emulation_status(); diff --git a/lan-mouse-gtk/src/window/imp.rs b/lan-mouse-gtk/src/window/imp.rs index bb7d48cba..56765a14b 100644 --- a/lan-mouse-gtk/src/window/imp.rs +++ b/lan-mouse-gtk/src/window/imp.rs @@ -4,7 +4,7 @@ use adw::subclass::prelude::*; use adw::{ActionRow, PreferencesGroup, ToastOverlay, prelude::*}; use glib::subclass::InitializingObject; use gtk::glib::clone; -use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, gdk, gio, glib}; +use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, Scale, gdk, gio, glib}; use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter}; @@ -45,6 +45,12 @@ pub struct Window { pub input_capture_button: TemplateChild