From b1db32adb11ce7f8a5ede228260baa76afbada94 Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 04:49:53 +0800 Subject: [PATCH 1/8] feat(Capability): add Touchscreen capability and gesture types Add Touchscreen as a new top-level Capability variant with Touch and Gesture sub-types. Gesture covers single-finger edge-swipe directions (SwipeLeft/Right/Up/Down) with optional zone qualifiers (Top/Bottom). Update InputValue translation, evdev/dbus event enums, and capability map config to handle the new types. --- src/config/capability_map.rs | 3 + src/input/capability.rs | 116 +++++++++++++++++++++++++++++++++++ src/input/event/dbus.rs | 1 + src/input/event/evdev.rs | 4 ++ src/input/event/value.rs | 38 ++++++++++++ 5 files changed, 162 insertions(+) diff --git a/src/config/capability_map.rs b/src/config/capability_map.rs index e54a988e..2f225b74 100644 --- a/src/config/capability_map.rs +++ b/src/config/capability_map.rs @@ -318,6 +318,9 @@ pub struct TouchCapability { pub button: Option, #[serde(skip_serializing_if = "Option::is_none")] pub motion: Option, + /// Edge-swipe gesture type + #[serde(skip_serializing_if = "Option::is_none")] + pub gesture: Option, } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema, PartialEq)] diff --git a/src/input/capability.rs b/src/input/capability.rs index 9e548560..939b53c7 100644 --- a/src/input/capability.rs +++ b/src/input/capability.rs @@ -69,18 +69,21 @@ impl Capability { Touch::Button(touch_button) => { format!("Touchpad:LeftPad:Touch:Button:{touch_button}") } + Touch::Gesture(g) => format!("Touchpad:LeftPad:Touch:Gesture:{g}"), }, Touchpad::RightPad(touch) => match touch { Touch::Motion => "Touchpad:RightPad:Touch:Motion".to_string(), Touch::Button(touch_button) => { format!("Touchpad:RightPad:Touch:Button:{touch_button}") } + Touch::Gesture(g) => format!("Touchpad:RightPad:Touch:Gesture:{g}"), }, Touchpad::CenterPad(touch) => match touch { Touch::Motion => "Touchpad:CenterPad:Touch:Motion".to_string(), Touch::Button(touch_button) => { format!("Touchpad:CenterPad:Touch:Button:{touch_button}") } + Touch::Gesture(g) => format!("Touchpad:CenterPad:Touch:Gesture:{g}"), }, }, Capability::Touchscreen(touch) => match touch { @@ -88,11 +91,28 @@ impl Capability { Touch::Button(touch_button) => { format!("Touchscreen:Touch:Button:{touch_button}") } + Touch::Gesture(g) => format!("Touchscreen:Touch:Gesture:{g}"), }, Capability::Gyroscope(source) => format!("Gyroscope:{source}"), Capability::Accelerometer(source) => format!("Accelerometer:{source}"), } } + + /// If this capability is a touchscreen gesture with a specific area (Top or + /// Bottom), return the same gesture with `GestureArea::Any`. Returns `None` + /// for gestures that have no area concept (SwipeUp/SwipeDown) or for + /// non-gesture capabilities. + pub fn with_gesture_area_any(&self) -> Option { + let Capability::Touchscreen(Touch::Gesture(gesture)) = self else { + return None; + }; + let any_gesture = match gesture { + GestureType::SwipeRight(_) => GestureType::SwipeRight(GestureArea::Any), + GestureType::SwipeLeft(_) => GestureType::SwipeLeft(GestureArea::Any), + GestureType::SwipeUp | GestureType::SwipeDown => return None, + }; + Some(Capability::Touchscreen(Touch::Gesture(any_gesture))) + } } impl fmt::Display for Capability { @@ -332,6 +352,17 @@ impl From for Capability { let button = button.unwrap(); return Capability::Touchscreen(Touch::Button(button)); } + + // Gesture + if let Some(gesture_string) = touch.gesture.as_ref() { + let gesture = GestureType::from_str(gesture_string); + if gesture.is_err() { + log::error!("Invalid or unimplemented gesture: {gesture_string}"); + return Capability::NotImplemented; + } + let gesture = gesture.unwrap(); + return Capability::Touchscreen(Touch::Gesture(gesture)); + } } // Gyroscope @@ -1350,10 +1381,91 @@ impl FromStr for Touchpad { } } +/// Area on the screen where a gesture starts, used to differentiate regions +/// for left/right swipes. `Any` is used only in profile configurations as a +/// wildcard that matches both Top and Bottom. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum GestureArea { + /// Upper portion of the screen + Top, + /// Lower portion of the screen + Bottom, + /// Wildcard used in profile mappings; matches both Top and Bottom + Any, +} + +impl fmt::Display for GestureArea { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + GestureArea::Top => write!(f, "Top"), + GestureArea::Bottom => write!(f, "Bottom"), + GestureArea::Any => write!(f, "Any"), + } + } +} + +impl FromStr for GestureArea { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "Top" => Ok(GestureArea::Top), + "Bottom" => Ok(GestureArea::Bottom), + "Any" => Ok(GestureArea::Any), + _ => Err(()), + } + } +} + +/// Touchscreen edge swipe gestures detected in userspace from evdev MT events +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum GestureType { + /// Swipe inward from the left edge + SwipeRight(GestureArea), + /// Swipe inward from the right edge + SwipeLeft(GestureArea), + /// Swipe inward from the bottom edge + SwipeUp, + /// Swipe inward from the top edge + SwipeDown, +} + +impl fmt::Display for GestureType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + GestureType::SwipeRight(area) => write!(f, "SwipeRight:{area}"), + GestureType::SwipeLeft(area) => write!(f, "SwipeLeft:{area}"), + GestureType::SwipeUp => write!(f, "SwipeUp"), + GestureType::SwipeDown => write!(f, "SwipeDown"), + } + } +} + +impl FromStr for GestureType { + type Err = (); + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split(':').collect(); + let Some((part, rest)) = parts.split_first() else { + return Err(()); + }; + match *part { + "SwipeRight" => Ok(GestureType::SwipeRight(GestureArea::from_str( + rest.join(":").as_str(), + )?)), + "SwipeLeft" => Ok(GestureType::SwipeLeft(GestureArea::from_str( + rest.join(":").as_str(), + )?)), + "SwipeUp" => Ok(GestureType::SwipeUp), + "SwipeDown" => Ok(GestureType::SwipeDown), + _ => Err(()), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Touch { Motion, Button(TouchButton), + Gesture(GestureType), } impl fmt::Display for Touch { @@ -1361,6 +1473,7 @@ impl fmt::Display for Touch { match self { Touch::Motion => write!(f, "Motion"), Touch::Button(_) => write!(f, "Button"), + Touch::Gesture(g) => write!(f, "Gesture:{g}"), } } } @@ -1377,6 +1490,9 @@ impl FromStr for Touch { "Button" => Ok(Touch::Button(TouchButton::from_str( parts.join(":").as_str(), )?)), + "Gesture" => Ok(Touch::Gesture(GestureType::from_str( + parts.join(":").as_str(), + )?)), _ => Err(()), } } diff --git a/src/input/event/dbus.rs b/src/input/event/dbus.rs index f4aecf3c..6d5cc369 100644 --- a/src/input/event/dbus.rs +++ b/src/input/event/dbus.rs @@ -412,6 +412,7 @@ fn actions_from_capability(capability: Capability) -> Vec { Capability::Touchscreen(touch) => match touch { Touch::Motion => vec![Action::Touch], Touch::Button(_) => vec![Action::None], + Touch::Gesture(_) => vec![Action::None], }, Capability::Gyroscope(_) => vec![Action::None], Capability::Accelerometer(_) => vec![Action::None], diff --git a/src/input/event/evdev.rs b/src/input/event/evdev.rs index 15f25032..c948e37b 100644 --- a/src/input/event/evdev.rs +++ b/src/input/event/evdev.rs @@ -897,6 +897,7 @@ fn event_codes_from_capability(capability: Capability) -> Vec { TouchButton::Touch => vec![KeyCode::BTN_TOUCH.0], TouchButton::Press => vec![KeyCode::BTN_LEFT.0], }, + Touch::Gesture(_) => vec![], }, Touchpad::RightPad(action) => match action { Touch::Motion => vec![ @@ -907,6 +908,7 @@ fn event_codes_from_capability(capability: Capability) -> Vec { TouchButton::Touch => vec![KeyCode::BTN_TOUCH.0], TouchButton::Press => vec![KeyCode::BTN_LEFT.0], }, + Touch::Gesture(_) => vec![], }, Touchpad::CenterPad(action) => match action { Touch::Motion => vec![ @@ -917,6 +919,7 @@ fn event_codes_from_capability(capability: Capability) -> Vec { TouchButton::Touch => vec![KeyCode::BTN_TOUCH.0], TouchButton::Press => vec![KeyCode::BTN_LEFT.0], }, + Touch::Gesture(_) => vec![], }, }, Capability::Touchscreen(touch) => match touch { @@ -928,6 +931,7 @@ fn event_codes_from_capability(capability: Capability) -> Vec { TouchButton::Touch => vec![KeyCode::BTN_TOUCH.0], TouchButton::Press => vec![KeyCode::BTN_LEFT.0], }, + Touch::Gesture(_) => vec![], }, Capability::Gyroscope(_) => vec![], Capability::Accelerometer(_) => vec![], diff --git a/src/input/event/value.rs b/src/input/event/value.rs index be091606..6932264c 100644 --- a/src/input/event/value.rs +++ b/src/input/event/value.rs @@ -223,6 +223,7 @@ impl InputValue { Touch::Motion => Err(TranslationError::NotImplemented), // Gamepad Button -> Touchscreen Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // Gamepad Button -> Gyroscope Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), @@ -391,6 +392,7 @@ impl InputValue { Capability::Touchscreen(touch) => match touch { Touch::Motion => Err(TranslationError::NotImplemented), Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), Capability::Accelerometer(_) => Err(TranslationError::NotImplemented), @@ -472,18 +474,21 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::RightPad(target_touch) => match target_touch { // Touchpad Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::CenterPad(target_touch) => match target_touch { // Touchpad Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchspad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, }, // Touchpad Motion -> Touchscreen ... @@ -492,6 +497,7 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchscreen Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // Touchpad Motion -> Gyroscope... Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), @@ -499,6 +505,7 @@ impl InputValue { Capability::Accelerometer(_) => Err(TranslationError::NotImplemented), }, Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // RightPad -> ... Touchpad::RightPad(touch) => match touch { @@ -532,18 +539,21 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::RightPad(target_touch) => match target_touch { // Touchpad Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::CenterPad(target_touch) => match target_touch { // Touchpad Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchspad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, }, // Touchpad Motion -> Touchscreen ... @@ -552,6 +562,7 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchscreen Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // Touchpad Motion -> Gyroscope ... Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), @@ -559,6 +570,7 @@ impl InputValue { Capability::Accelerometer(_) => Err(TranslationError::NotImplemented), }, Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // CenterPad -> ... Touchpad::CenterPad(touch) => match touch { @@ -592,18 +604,21 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::RightPad(target_touch) => match target_touch { // Touchpad Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::CenterPad(target_touch) => match target_touch { // Touchpad Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchspad Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, }, // Touchpad Motion -> Touchscreen ... @@ -612,6 +627,7 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchpad Motion -> Touchscreen Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // Touchpad Motion -> Gyroscope ... Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), @@ -619,6 +635,7 @@ impl InputValue { Capability::Accelerometer(_) => Err(TranslationError::NotImplemented), }, Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, }, @@ -657,18 +674,21 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchscreen Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::RightPad(target_touch) => match target_touch { // Touchscreen Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchscreen Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, Touchpad::CenterPad(target_touch) => match target_touch { // Touchscreen Motion -> Touchpad Motion Touch::Motion => Ok(self.clone()), // Touchscreen Motion -> Touchpad Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, }, // Touchscreen Motion -> Touchscreen ... @@ -677,6 +697,7 @@ impl InputValue { Touch::Motion => Ok(self.clone()), // Touchscreen Motion -> Touchscreen Button Touch::Button(_) => Err(TranslationError::NotImplemented), + Touch::Gesture(_) => Err(TranslationError::NotImplemented), }, // Touchscreen Motion -> Gyroscope ... Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), @@ -685,6 +706,23 @@ impl InputValue { }, // Touchscreen Button -> ... Touch::Button(_) => Err(TranslationError::NotImplemented), + // Touchscreen Gesture -> ... + Touch::Gesture(_) => match target_cap { + Capability::None => Ok(InputValue::None), + Capability::NotImplemented => Ok(InputValue::None), + Capability::Sync => Ok(InputValue::Bool(false)), + Capability::Gamepad(gamepad) => match gamepad { + Gamepad::Button(_) => Ok(self.clone()), + _ => Err(TranslationError::NotImplemented), + }, + Capability::Keyboard(_) => Ok(self.clone()), + Capability::Mouse(mouse) => match mouse { + Mouse::Button(_) => Ok(self.clone()), + _ => Err(TranslationError::NotImplemented), + }, + Capability::DBus(_) => Ok(self.clone()), + _ => Err(TranslationError::NotImplemented), + }, }, Capability::Gyroscope(_) => Err(TranslationError::NotImplemented), Capability::Accelerometer(_) => Err(TranslationError::NotImplemented), From 4b70e5b9d72a5626a225f7dc6d98d3d008e0b1f9 Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 04:50:00 +0800 Subject: [PATCH 2/8] feat(Touchscreen): add edge-swipe gesture detection with grab and tap replay Implement single-finger edge-swipe gesture detection in the touchscreen source device: - Detect swipes starting from left/right/top/bottom screen edges - Optional exclusive grab mode: suppresses raw touch events while a gesture is being evaluated, replaying them as a tap if no gesture is confirmed - Orientation config to rotate coordinates for rotated displays - TouchscreenConfig extended with grab and orientation fields --- src/config/mod.rs | 6 + src/input/source/evdev/touchscreen.rs | 289 +++++++++++++++++++++++++- 2 files changed, 289 insertions(+), 6 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 530e0f3d..cfecd424 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -303,6 +303,12 @@ pub struct TouchscreenConfig { /// instead of the size advertised by the device itself. #[serde(skip_serializing_if = "Option::is_none")] pub override_source_size: Option, + /// If true, the device will be grabbed exclusively. Touch events will be + /// fully managed by InputPlumber; a virtual touchscreen target device will + /// be created and edge-swipe gestures will suppress raw touch delivery. + /// Defaults to false (pass-through mode). + #[serde(skip_serializing_if = "Option::is_none")] + pub grab: Option, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] diff --git a/src/input/source/evdev/touchscreen.rs b/src/input/source/evdev/touchscreen.rs index ae8d8b51..0f2461d8 100644 --- a/src/input/source/evdev/touchscreen.rs +++ b/src/input/source/evdev/touchscreen.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::fmt::Debug; use std::os::fd::AsFd; +use std::time::{Duration, Instant}; use std::{collections::HashMap, error::Error}; use evdev::{ @@ -11,7 +12,7 @@ use nix::fcntl::{FcntlArg, OFlag}; use crate::config::capability_map::CapabilityMapConfigV2; use crate::config::TouchscreenConfig; -use crate::input::capability::Touch; +use crate::input::capability::{GestureArea, GestureType, Touch}; use crate::input::event::evdev::translator::EventTranslator; use crate::input::event::value::InputValue; use crate::{ @@ -23,6 +24,18 @@ use crate::{ udev::device::UdevDevice, }; +/// Edge zone for gesture detection: finger must start within this fraction of the edge. +const GESTURE_START: f64 = 0.03; +/// Edge zone for pre-suppression in grab mode: narrower than GESTURE_START so that +/// taps and scrolls near (but not at) the edge still pass through freely. +const GESTURE_SUPPRESS_START: f64 = 0.01; +/// Minimum travel distance (as a fraction of screen) required to confirm a gesture. +const GESTURE_MIN_TRAVEL: f64 = 0.12; +/// Maximum duration from first touch to gesture recognition +const GESTURE_TIME: Duration = Duration::from_millis(400); +/// Y coordinate ratio separating the top and bottom gesture areas for left/right swipes +const GESTURE_TOP_RATIO: f64 = 0.33; + /// Orientation of the touchscreen used to translate touch #[derive(Debug, Clone, Copy, Default)] enum Orientation { @@ -40,11 +53,82 @@ impl From<&str> for Orientation { "left" => Self::RotateLeft, "right" => Self::RotateRight, "upsidedown" => Self::UpsideDown, - _ => Self::Normal, + other => { + log::warn!("Unknown touchscreen orientation '{other}', defaulting to normal"); + Self::Normal + } } } } +/// Lifecycle of a single-finger edge-swipe gesture within one touch sequence +#[derive(Debug, Default)] +enum GesturePhase { + /// No touch in progress. + #[default] + Idle, + /// Touch started; watching for a gesture. + /// In grab mode, `suppressing` indicates whether slot-0 events are being + /// held back because the touch started in an edge zone. + Tracking { suppressing: bool }, + /// A gesture was recognized; suppress remaining touch events until lift. + Triggered, + /// A second finger arrived; gesture detection disabled for this touch. + Invalidated, +} + +/// Tracks a single-finger swipe gesture in progress +#[derive(Debug, Default)] +struct GestureState { + start_x: f64, + /// None until the first Y-axis event arrives after touch-down + start_y: Option, + last_x: f64, + last_y: f64, + start_time: Option, + phase: GesturePhase, +} + +impl GestureState { + /// Returns true if no touch is currently being tracked + fn is_idle(&self) -> bool { + matches!(self.phase, GesturePhase::Idle) + } + + /// Returns true if slot-0 touch events should not be forwarded + fn is_suppressing(&self) -> bool { + matches!( + self.phase, + GesturePhase::Tracking { suppressing: true } | GesturePhase::Triggered + ) + } + + /// Multi-finger touch detected; disable gesture for this touch sequence + fn invalidate(&mut self) { + self.phase = GesturePhase::Invalidated; + } + + /// Gesture recognized; suppress remaining touch and block re-initialization + fn mark_triggered(&mut self) { + self.phase = GesturePhase::Triggered; + } + + /// Finger released; reset all state + fn reset(&mut self) { + *self = GestureState::default(); + } + + /// Returns true if a gesture is still being evaluated (within time limit) + fn is_active(&self) -> bool { + let GesturePhase::Tracking { .. } = self.phase else { + return false; + }; + self.start_time + .map(|t| t.elapsed() <= GESTURE_TIME) + .unwrap_or(false) + } +} + /// TouchState represents the state of a single touch #[derive(Debug, Clone)] struct TouchState { @@ -108,6 +192,10 @@ pub struct TouchscreenEventDevice { touch_state: [TouchState; 10], // NOTE: Max of 10 touch inputs dirty_states: HashSet, last_touch_idx: usize, + gesture_state: GestureState, + /// When true, the device is grabbed exclusively and touch events in the + /// edge zone are suppressed until a gesture is confirmed or ruled out. + grab: bool, } impl TouchscreenEventDevice { @@ -120,7 +208,20 @@ impl TouchscreenEventDevice { let path = device_info.devnode(); log::debug!("Opening device at: {}", path); let mut device = Device::open(path.clone())?; - device.grab()?; + + let grab = if config.as_ref().and_then(|c| c.grab).unwrap_or(false) { + match device.grab() { + Ok(_) => true, + Err(e) => { + log::warn!( + "Failed to grab touchscreen, falling back to pass-through mode: {e}" + ); + false + } + } + } else { + false + }; // Set the device to do non-blocking reads // TODO: use epoll to wake up when data is available @@ -193,6 +294,8 @@ impl TouchscreenEventDevice { touch_state: Default::default(), dirty_states: HashSet::with_capacity(10), last_touch_idx: 0, + gesture_state: GestureState::default(), + grab, }) } @@ -215,12 +318,20 @@ impl TouchscreenEventDevice { continue; }; + // Suppress slot-0 touch events while a potential gesture is being tracked + if idx == 0 && self.gesture_state.is_suppressing() { + continue; + } + // Rotate values based on config let rotated_touch = touch.rotate(self.orientation); let event = rotated_touch.to_native_event(idx as u8); events.push(event); } + // Detect edge-swipe gestures from the primary touch slot + events.extend(self.detect_gesture()); + return events; } // The BTN_TOUCH event occurs whenever touches have started or stopped. @@ -238,17 +349,53 @@ impl TouchscreenEventDevice { // be the first touch, "1", the second, etc. Upon receiving this event, // any following ABS_X/Y events are associated with this touch index. EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_MT_SLOT, value) => { - // Select the current slot to update let slot = value as usize; self.last_touch_idx = slot; self.dirty_states.insert(slot); + // A second finger arriving invalidates any in-progress gesture + if slot > 0 { + self.gesture_state.invalidate(); + } } // Whenever a touch is lifted, an ABS_MT_TRACKING_ID event with a value of // -1 event will occur. EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_MT_TRACKING_ID, -1) => { if let Some(touch) = self.touch_state.get_mut(self.last_touch_idx) { touch.is_touching = false; - self.dirty_states.insert(self.last_touch_idx); + + if self.last_touch_idx == 0 && self.gesture_state.is_suppressing() { + // Touch was pre-suppressed. If no gesture fired, the user + // made a tap or short swipe that didn't qualify as a gesture; + // replay it as a synthetic press→release so the system sees it. + if !matches!(self.gesture_state.phase, GesturePhase::Triggered) { + if let Some(start_y) = self.gesture_state.start_y { + let press = TouchState { + is_touching: true, + x: self.gesture_state.start_x, + y: start_y, + pressure: 1.0, + }; + let release = TouchState { + is_touching: false, + ..press.clone() + }; + self.gesture_state.reset(); + return vec![ + press.rotate(self.orientation).to_native_event(0), + release.rotate(self.orientation).to_native_event(0), + ]; + } + } + // Gesture fired (or no Y data recorded): discard silently. + } else { + // Only emit release if this touch was not suppressed (i.e. the + // system never saw a press, so sending a release would confuse it) + self.dirty_states.insert(self.last_touch_idx); + } + } + // Primary finger lifted: reset gesture state + if self.last_touch_idx == 0 { + self.gesture_state.reset(); } } // Emitted whenever touch motion is detected for the X axis @@ -265,6 +412,21 @@ impl TouchscreenEventDevice { touch.x = normal_value; self.dirty_states.insert(self.last_touch_idx); } + + // Track gesture only for the primary slot + if self.last_touch_idx == 0 { + if self.gesture_state.is_idle() { + // In grab mode, pre-suppress touches that start in the + // edge zone to avoid any leakage before gesture confirm. + let suppressing = self.grab + && (normal_value < GESTURE_SUPPRESS_START + || normal_value > 1.0 - GESTURE_SUPPRESS_START); + self.gesture_state.start_x = normal_value; + self.gesture_state.start_time = Some(Instant::now()); + self.gesture_state.phase = GesturePhase::Tracking { suppressing }; + } + self.gesture_state.last_x = normal_value; + } } // Emitted whenever touch motion is detected for the Y axis EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_MT_POSITION_Y, value) => { @@ -280,6 +442,24 @@ impl TouchscreenEventDevice { touch.y = normal_value; self.dirty_states.insert(self.last_touch_idx); } + + // Track gesture only for the primary slot + if self.last_touch_idx == 0 { + if self.gesture_state.start_y.is_none() + && matches!(self.gesture_state.phase, GesturePhase::Tracking { .. }) + { + self.gesture_state.start_y = Some(normal_value); + // In grab mode, also suppress touches starting at the + // top or bottom edge. + if self.grab + && (normal_value < GESTURE_SUPPRESS_START + || normal_value > 1.0 - GESTURE_SUPPRESS_START) + { + self.gesture_state.phase = GesturePhase::Tracking { suppressing: true }; + } + } + self.gesture_state.last_y = normal_value; + } } // Some touchscreens support touch pressure and emit this event. EventSummary::AbsoluteAxis(_, AbsoluteAxisCode::ABS_PRESSURE, value) => { @@ -301,6 +481,92 @@ impl TouchscreenEventDevice { vec![] } + + /// Attempt to recognize a completed edge-swipe gesture from the current + /// gesture state. Returns gesture events if one is recognized, or an empty + /// vec if the gesture has not yet been confirmed. + fn detect_gesture(&mut self) -> Vec { + if !self.gesture_state.is_active() { + return vec![]; + } + + let Some(raw_start_y) = self.gesture_state.start_y else { + return vec![]; + }; + + // Rotate gesture coordinates to match display orientation before + // evaluating edge zones and travel direction. + let start = TouchState { + is_touching: true, + x: self.gesture_state.start_x, + y: raw_start_y, + pressure: 1.0, + } + .rotate(self.orientation); + let last = TouchState { + is_touching: true, + x: self.gesture_state.last_x, + y: self.gesture_state.last_y, + pressure: 1.0, + } + .rotate(self.orientation); + + let (start_x, start_y) = (start.x, start.y); + let (last_x, last_y) = (last.x, last.y); + + let gesture_type = if start_x < GESTURE_START && (last_x - start_x) > GESTURE_MIN_TRAVEL { + // Swipe inward from the left edge + let area = if start_y < GESTURE_TOP_RATIO { + GestureArea::Top + } else { + GestureArea::Bottom + }; + Some(GestureType::SwipeRight(area)) + } else if start_x > 1.0 - GESTURE_START && (start_x - last_x) > GESTURE_MIN_TRAVEL { + // Swipe inward from the right edge + let area = if start_y < GESTURE_TOP_RATIO { + GestureArea::Top + } else { + GestureArea::Bottom + }; + Some(GestureType::SwipeLeft(area)) + } else if start_y > 1.0 - GESTURE_START && (start_y - last_y) > GESTURE_MIN_TRAVEL { + // Swipe inward from the bottom edge + Some(GestureType::SwipeUp) + } else if start_y < GESTURE_START && (last_y - start_y) > GESTURE_MIN_TRAVEL { + // Swipe inward from the top edge + Some(GestureType::SwipeDown) + } else { + None + }; + + if let Some(gesture) = gesture_type { + log::debug!("Gesture detected: {:?}", gesture); + + // Capture suppression state before transitioning: if the touch was + // NOT pre-suppressed, the system already received press frames and + // needs a matching synthetic release before the gesture fires. + let needs_synthetic_release = self.grab && !self.gesture_state.is_suppressing(); + self.gesture_state.mark_triggered(); + + let cap = Capability::Touchscreen(Touch::Gesture(gesture)); + let mut events = vec![ + NativeEvent::new(cap.clone(), InputValue::Bool(true)), + NativeEvent::new(cap, InputValue::Bool(false)), + ]; + + if needs_synthetic_release { + let mut release_state = self.touch_state[0].clone(); + release_state.is_touching = false; + let release_event = release_state.rotate(self.orientation).to_native_event(0); + events.insert(0, release_event); + } + + events + } else { + vec![] + } + } } impl SourceInputDevice for TouchscreenEventDevice { @@ -377,7 +643,15 @@ impl SourceInputDevice for TouchscreenEventDevice { /// Returns the possible input events this device is capable of emitting fn get_capabilities(&self) -> Result, InputError> { - Ok(vec![Capability::Touchscreen(Touch::Motion)]) + Ok(vec![ + Capability::Touchscreen(Touch::Motion), + Capability::Touchscreen(Touch::Gesture(GestureType::SwipeRight(GestureArea::Top))), + Capability::Touchscreen(Touch::Gesture(GestureType::SwipeRight(GestureArea::Bottom))), + Capability::Touchscreen(Touch::Gesture(GestureType::SwipeLeft(GestureArea::Top))), + Capability::Touchscreen(Touch::Gesture(GestureType::SwipeLeft(GestureArea::Bottom))), + Capability::Touchscreen(Touch::Gesture(GestureType::SwipeUp)), + Capability::Touchscreen(Touch::Gesture(GestureType::SwipeDown)), + ]) } } @@ -394,5 +668,8 @@ impl Debug for TouchscreenEventDevice { // Returns a value between 0.0 and 1.0 based on the given value with its // maximum. fn normalize_unsigned_value(raw_value: i32, max: i32) -> f64 { + if max <= 0 { + return 0.0; + } raw_value as f64 / max as f64 } From 7b02337cd716c4d0e9116ce9be778655e1ddfae0 Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 04:50:07 +0800 Subject: [PATCH 3/8] feat(CompositeDevice): route touchscreen gesture events through profile mapping - Pass Touchscreen capability events through translate_event so they can be mapped to gamepad buttons via device profiles - Bypass intercept-mode checks for touchscreen events (gesture handling is always active) - Add Touchscreen capability to the output capability lists of DualSense, SteamDeckUhid and UnifiedGamepad target devices --- src/input/composite_device/mod.rs | 89 ++++++++++++++++++++++++++- src/input/composite_device/targets.rs | 30 ++++++++- src/input/target/dualsense.rs | 1 + src/input/target/steam_deck_uhid.rs | 2 + src/input/target/unified_gamepad.rs | 12 ++++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/input/composite_device/mod.rs b/src/input/composite_device/mod.rs index c8316c4a..6dd965b6 100644 --- a/src/input/composite_device/mod.rs +++ b/src/input/composite_device/mod.rs @@ -178,6 +178,7 @@ impl CompositeDevice { let (tx, rx) = mpsc::channel(BUFFER_SIZE); let name = config.name.clone(); let dbus = DBusInterfaceManager::new(conn.clone(), dbus_path.clone())?; + let targets = CompositeDeviceTargets::new(conn, dbus_path, tx.clone().into(), manager, &config); let mut device = Self { dbus, config, @@ -205,7 +206,7 @@ impl CompositeDevice { source_device_tasks: JoinSet::new(), source_device_persistent_ids: HashMap::new(), source_devices_used: Vec::new(), - targets: CompositeDeviceTargets::new(conn, dbus_path, tx.into(), manager), + targets, ff_enabled: true, ff_effect_ids: (0..64).collect(), ff_effect_id_source_map: HashMap::new(), @@ -1563,6 +1564,92 @@ impl CompositeDevice { return Ok(events); } + // For gesture capabilities with a specific area (Top/Bottom), fall back + // to any GestureArea::Any mapping configured in the profile. + if let Some(any_cap) = source_cap.with_gesture_area_any() { + if let Some(mappings) = self.device_profile_config_map.get(&any_cap) { + let matched_mappings = mappings + .iter() + .filter(|mapping| mapping.source_matches_properties(event)); + + let mut events = Vec::new(); + for mapping in matched_mappings { + log::trace!( + "Found Any-area translation for gesture {:?} via mapping: {}", + source_cap, + mapping.name + ); + + for target_event in mapping.target_events.iter() { + let target_cap: Capability = target_event.clone().into(); + let result = event.get_value().translate( + &source_cap, + &mapping.source_event, + &target_cap, + target_event, + ); + let value = match result { + Ok(v) => v, + Err(err) => { + match err { + TranslationError::NotImplemented => { + log::warn!( + "Translation not implemented for Any-area mapping '{}': {:?} -> {:?}", + mapping.name, + source_cap, + target_cap, + ); + continue; + } + TranslationError::ImpossibleTranslation(msg) => { + log::warn!( + "Impossible translation for Any-area mapping '{}': {msg}", + mapping.name + ); + continue; + } + TranslationError::InvalidSourceConfig(msg) => { + log::warn!("Invalid source event config in Any-area mapping '{}': {msg}", mapping.name); + continue; + } + TranslationError::InvalidTargetConfig(msg) => { + log::warn!("Invalid target event config in Any-area mapping '{}': {msg}", mapping.name); + continue; + } + } + } + }; + if matches!(value, InputValue::None) { + continue; + } + + if source_cap.is_momentary_translation(&target_cap) { + events.push(NativeEvent::new_translated( + source_cap.clone(), + target_cap.clone(), + InputValue::Bool(true), + )); + events.push(NativeEvent::new_translated( + source_cap.clone(), + target_cap, + InputValue::Bool(false), + )); + continue; + } + + events.push(NativeEvent::new_translated( + source_cap.clone(), + target_cap, + value, + )); + } + } + if !events.is_empty() { + return Ok(events); + } + } + } + log::trace!("No translation mapping found for event: {:?}", source_cap); Ok(vec![event.clone()]) } diff --git a/src/input/composite_device/targets.rs b/src/input/composite_device/targets.rs index ab2c5b7c..dfa90ae4 100644 --- a/src/input/composite_device/targets.rs +++ b/src/input/composite_device/targets.rs @@ -4,6 +4,9 @@ use std::{ time::Duration, }; +/// The target device type identifier for the virtual touchscreen +const TOUCHSCREEN_TARGET_ID: &str = "touchscreen"; + use tokio::{ sync::mpsc::{self, Sender}, task::JoinSet, @@ -51,6 +54,9 @@ pub struct CompositeDeviceTargets { /// Map of DBusDevice DBus paths to their respective transmitter channel. /// E.g. {"/org/shadowblip/InputPlumber/devices/target/dbus0": } target_dbus_devices: HashMap, + /// True if this composite device has a grabbed touchscreen source. + /// When true, the touchscreen target will always be kept in the target list. + has_touchscreen_source: bool, } impl CompositeDeviceTargets { @@ -60,7 +66,17 @@ impl CompositeDeviceTargets { path: String, device: CompositeDeviceClient, manager: Sender, + config: &crate::config::CompositeDeviceConfig, ) -> Self { + let has_touchscreen_source = config.source_devices.iter().any(|src| { + src.group == "touchscreen" + && src + .config + .as_ref() + .and_then(|c| c.touchscreen.as_ref()) + .and_then(|t| t.grab) + .unwrap_or(false) + }); Self { _dbus: dbus, path, @@ -71,6 +87,7 @@ impl CompositeDeviceTargets { target_devices_queued: Default::default(), target_devices_suspended: Default::default(), target_dbus_devices: Default::default(), + has_touchscreen_source, } } @@ -124,9 +141,20 @@ impl CompositeDeviceTargets { /// existing devices. pub async fn set_devices( &mut self, - device_types: Vec, + mut device_types: Vec, ) -> Result<(), Box> { let dbus_path = self.path.as_str(); + + // If the composite device has a grabbed touchscreen source, always keep + // touchscreen in the target list regardless of what the caller requested. + if self.has_touchscreen_source { + let touchscreen_id: TargetDeviceTypeId = TOUCHSCREEN_TARGET_ID.try_into().unwrap(); + if !device_types.contains(&touchscreen_id) { + log::debug!("[{dbus_path}] Auto-adding touchscreen target (grabbed touchscreen source detected)"); + device_types.push(touchscreen_id); + } + } + log::info!("[{dbus_path}] Setting target devices: {device_types:?}"); // NOTE: If the device is suspended, we resume the device and use the new diff --git a/src/input/target/dualsense.rs b/src/input/target/dualsense.rs index b9b24493..aaea9d01 100644 --- a/src/input/target/dualsense.rs +++ b/src/input/target/dualsense.rs @@ -622,6 +622,7 @@ impl DualSenseDevice { TouchButton::Touch => (), TouchButton::Press => state.touchpad = event.pressed(), }, + Touch::Gesture(_) => (), } } Capability::Gyroscope(_) => { diff --git a/src/input/target/steam_deck_uhid.rs b/src/input/target/steam_deck_uhid.rs index 3bf3f2a6..306a81ac 100644 --- a/src/input/target/steam_deck_uhid.rs +++ b/src/input/target/steam_deck_uhid.rs @@ -345,6 +345,7 @@ impl SteamDeckUhidDevice { TouchButton::Touch => self.state.l_pad_touch = event.pressed(), TouchButton::Press => self.state.l_pad_press = event.pressed(), }, + Touch::Gesture(_) => (), }, //TODO:: Remove CenterPad after we implement Target Profiles Touchpad::RightPad(touch_event) | Touchpad::CenterPad(touch_event) => { @@ -377,6 +378,7 @@ impl SteamDeckUhidDevice { TouchButton::Touch => self.state.r_pad_touch = event.pressed(), TouchButton::Press => self.state.r_pad_press = event.pressed(), }, + Touch::Gesture(_) => (), } } }, diff --git a/src/input/target/unified_gamepad.rs b/src/input/target/unified_gamepad.rs index cc7adeb3..21b48e4f 100644 --- a/src/input/target/unified_gamepad.rs +++ b/src/input/target/unified_gamepad.rs @@ -564,6 +564,7 @@ impl From for StateUpdate { Self { capability, value } } + Touch::Gesture(_) => Self::default(), }, Touchpad::RightPad(pad) => match pad { Touch::Motion => { @@ -604,6 +605,7 @@ impl From for StateUpdate { Self { capability, value } } + Touch::Gesture(_) => Self::default(), }, Touchpad::CenterPad(pad) => match pad { Touch::Motion => { @@ -644,6 +646,7 @@ impl From for StateUpdate { Self { capability, value } } + Touch::Gesture(_) => Self::default(), }, }, Capability::Touchscreen(touch) => match touch { @@ -685,6 +688,7 @@ impl From for StateUpdate { Self { capability, value } } + Touch::Gesture(_) => Self::default(), }, Capability::Gyroscope(_) => { let value = match event.get_value() { @@ -961,19 +965,23 @@ impl From for InputCapability { Touchpad::LeftPad(pad) => match pad { Touch::Motion => Self::TouchpadLeftMotion, Touch::Button(_) => Self::TouchpadLeftButton, + Touch::Gesture(_) => Self::default(), }, Touchpad::RightPad(pad) => match pad { Touch::Motion => Self::TouchpadRightMotion, Touch::Button(_) => Self::TouchpadRightButton, + Touch::Gesture(_) => Self::default(), }, Touchpad::CenterPad(pad) => match pad { Touch::Motion => Self::TouchpadCenterMotion, Touch::Button(_) => Self::TouchpadCenterButton, + Touch::Gesture(_) => Self::default(), }, }, Capability::Touchscreen(touch) => match touch { Touch::Motion => Self::TouchscreenMotion, Touch::Button(_) => Self::default(), + Touch::Gesture(_) => Self::default(), }, Capability::Gyroscope(source) => match source { Source::Left => Self::GyroscopeLeft, @@ -1013,19 +1021,23 @@ impl From for InputCapabilityInfo { Touchpad::LeftPad(pad) => match pad { Touch::Motion => Self::new(capability, ValueType::Touch), Touch::Button(_) => Self::new(capability, ValueType::Bool), + Touch::Gesture(_) => Self::default(), }, Touchpad::RightPad(pad) => match pad { Touch::Motion => Self::new(capability, ValueType::Touch), Touch::Button(_) => Self::new(capability, ValueType::Bool), + Touch::Gesture(_) => Self::default(), }, Touchpad::CenterPad(pad) => match pad { Touch::Motion => Self::new(capability, ValueType::Touch), Touch::Button(_) => Self::new(capability, ValueType::Bool), + Touch::Gesture(_) => Self::default(), }, }, Capability::Touchscreen(touch) => match touch { Touch::Motion => Self::new(capability, ValueType::Touch), Touch::Button(_) => Self::new(capability, ValueType::Bool), + Touch::Gesture(_) => Self::default(), }, Capability::Gyroscope(_) => Self::new(capability, ValueType::Int16Vector3), Capability::Accelerometer(_) => Self::new(capability, ValueType::Int16Vector3), From 1f16323ef7f3d997027e2c7f6ef2db10d159ab1e Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 04:50:14 +0800 Subject: [PATCH 4/8] feat(Profile): add default gesture mappings and update schemas Map SwipeRight:Bottom to Guide and SwipeLeft:Bottom to QuickAccess in the default profile, matching hhd-style Steam/QAM gestures. Update composite_device_v1.json to add grab field to TouchscreenConfig. Update device_profile_v1.json to include Touchscreen gesture capability definitions. --- .../usr/share/inputplumber/profiles/default.yaml | 14 ++++++++++++++ .../inputplumber/schema/composite_device_v1.json | 4 ++++ .../inputplumber/schema/device_profile_v1.json | 14 ++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/rootfs/usr/share/inputplumber/profiles/default.yaml b/rootfs/usr/share/inputplumber/profiles/default.yaml index f97f4959..411ffc14 100644 --- a/rootfs/usr/share/inputplumber/profiles/default.yaml +++ b/rootfs/usr/share/inputplumber/profiles/default.yaml @@ -78,3 +78,17 @@ mapping: direction: clockwise target_events: - keyboard: KeyBrightnessUp + - name: Swipe Right from Bottom-Left + source_event: + touchscreen: + gesture: SwipeRight:Bottom + target_events: + - gamepad: + button: Guide + - name: Swipe Left from Bottom-Right + source_event: + touchscreen: + gesture: SwipeLeft:Bottom + target_events: + - gamepad: + button: QuickAccess diff --git a/rootfs/usr/share/inputplumber/schema/composite_device_v1.json b/rootfs/usr/share/inputplumber/schema/composite_device_v1.json index 1e66fe7a..ac50e899 100644 --- a/rootfs/usr/share/inputplumber/schema/composite_device_v1.json +++ b/rootfs/usr/share/inputplumber/schema/composite_device_v1.json @@ -243,6 +243,10 @@ "type": "object", "additionalProperties": false, "properties": { + "grab": { + "description": "If true, the touchscreen will be exclusively grabbed so raw touch events are not passed to other consumers. Defaults to false.", + "type": "boolean" + }, "orientation": { "description": "Orientation of the touchscreen device. Defaults to normal.", "type": "string", diff --git a/rootfs/usr/share/inputplumber/schema/device_profile_v1.json b/rootfs/usr/share/inputplumber/schema/device_profile_v1.json index a814f9ae..0d7b7708 100644 --- a/rootfs/usr/share/inputplumber/schema/device_profile_v1.json +++ b/rootfs/usr/share/inputplumber/schema/device_profile_v1.json @@ -379,6 +379,20 @@ "Touch", "Press" ] + }, + "gesture": { + "type": "string", + "description": "Edge-swipe gesture type. Left/right swipes support an optional area suffix (:Top, :Bottom, :Any).", + "examples": [ + "SwipeRight:Top", + "SwipeRight:Bottom", + "SwipeRight:Any", + "SwipeLeft:Top", + "SwipeLeft:Bottom", + "SwipeLeft:Any", + "SwipeUp", + "SwipeDown" + ] } }, "required": [] From b0968e41780742e2da6f7cf25b74c37ef302884f Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 04:50:22 +0800 Subject: [PATCH 5/8] refactor(Target): move MIN_FRAME_TIME enforcement to TargetDriver Button press+release pairs arriving within 80ms now have their release delayed at the TargetDriver layer, so all target devices benefit automatically. Previously this logic only existed in steam_deck. Add ScheduledNativeEvent::event() accessor to support filtering scheduled events during ClearState. Also clear pending button events when ClearState is called to avoid ghost releases after intercept mode resets device state. --- src/input/event/native.rs | 5 +++ src/input/target/mod.rs | 62 +++++++++++++++++++++++++++++++--- src/input/target/steam_deck.rs | 47 ++------------------------ 3 files changed, 66 insertions(+), 48 deletions(-) diff --git a/src/input/event/native.rs b/src/input/event/native.rs index 589f6bf2..d5ee213b 100644 --- a/src/input/event/native.rs +++ b/src/input/event/native.rs @@ -198,4 +198,9 @@ impl ScheduledNativeEvent { pub fn is_ready(&self) -> bool { self.scheduled_time.elapsed() > self.wait_time } + + /// Returns a reference to the underlying event + pub fn event(&self) -> &NativeEvent { + &self.event + } } diff --git a/src/input/target/mod.rs b/src/input/target/mod.rs index 22a49c2a..146d50c3 100644 --- a/src/input/target/mod.rs +++ b/src/input/target/mod.rs @@ -1,10 +1,10 @@ use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, env, error::Error, io, sync::{Arc, Mutex, MutexGuard}, - time::Duration, + time::{Duration, Instant}, }; use debug::DebugDevice; @@ -27,7 +27,7 @@ use crate::{ }; use super::{ - capability::Capability, + capability::{Capability, Gamepad, Mouse}, composite_device::client::{ClientError, CompositeDeviceClient}, event::{ context::EventContext, @@ -328,6 +328,11 @@ impl Display for TargetDeviceClass { } } +/// Minimum time a button must remain pressed before the up event is allowed +/// through. Prevents instantaneous press+release pairs (e.g. from gesture translation) +/// from being invisible to receivers that sample state periodically. +const MIN_FRAME_TIME: Duration = Duration::from_millis(80); + /// A [TargetInputDevice] is a device implementation that is capable of emitting /// input events. Input events originate from source devices, are processed by /// a composite device, and are sent to a target device to be emitted. @@ -447,6 +452,7 @@ pub struct TargetDriver { implementation: Arc>, composite_device: Option, scheduled_events: Vec, + pressed_events: HashMap, tx: mpsc::Sender, rx: mpsc::Receiver, } @@ -473,6 +479,7 @@ impl TargetDriver implementation: Arc::new(Mutex::new(device)), composite_device: None, scheduled_events: Vec::new(), + pressed_events: HashMap::new(), rx, tx, } @@ -546,6 +553,8 @@ impl TargetDriver rx, &metrics_tx, &mut implementation, + &mut self.pressed_events, + &mut self.scheduled_events, ) { log::debug!("Error receiving commands: {e:?}"); break; @@ -589,7 +598,7 @@ impl TargetDriver _ = interval.tick() => (), Some(cmd) = rx.recv() => { let mut implementation = self.implementation.lock().unwrap(); - let result = Self::process_command(&self.type_id, &mut composite_device, &metrics_tx, &mut implementation, cmd); + let result = Self::process_command(&self.type_id, &mut composite_device, &metrics_tx, &mut implementation, cmd, &mut self.pressed_events, &mut self.scheduled_events); if let Err(e) = result { log::debug!("Error processing received command: {e}"); break; @@ -651,6 +660,8 @@ impl TargetDriver rx: &mut mpsc::Receiver, metrics_tx: &Option>, implementation: &mut MutexGuard<'_, T>, + pressed_events: &mut HashMap, + scheduled_events: &mut Vec, ) -> Result<(), Box> { const MAX_COMMANDS: u8 = 64; let mut commands_processed = 0; @@ -662,6 +673,8 @@ impl TargetDriver metrics_tx, implementation, cmd, + pressed_events, + scheduled_events, )?, Err(e) => match e { TryRecvError::Empty => return Ok(()), @@ -687,9 +700,39 @@ impl TargetDriver metrics_tx: &Option>, implementation: &mut MutexGuard<'_, T>, cmd: TargetCommand, + pressed_events: &mut HashMap, + scheduled_events: &mut Vec, ) -> Result<(), Box> { match cmd { TargetCommand::WriteEvent(event) => { + let cap = event.as_capability(); + // For button-type capabilities, enforce a minimum press duration so that + // instantaneous press+release pairs (e.g. from gesture translation) are + // visible to receivers that sample HID state periodically. + if let Capability::Gamepad(Gamepad::Button(_)) + | Capability::Keyboard(_) + | Capability::Mouse(Mouse::Button(_)) = &cap + { + if event.pressed() { + // Emit immediately and record the press time + Self::write_event(metrics_tx, implementation, event)?; + pressed_events.insert(cap, Instant::now()); + return Ok(()); + } else if let Some(press_time) = pressed_events.remove(&cap) { + if press_time.elapsed() < MIN_FRAME_TIME { + // Release arrived too soon after press; delay it + let scheduled = ScheduledNativeEvent::new_with_time( + event, + press_time, + MIN_FRAME_TIME, + ); + scheduled_events.push(scheduled); + return Ok(()); + } + // Sufficient time has passed; fall through to emit normally + } + // Up event with no matching tracked press, or elapsed >= MIN_FRAME_TIME + } Self::write_event(metrics_tx, implementation, event)?; } TargetCommand::SetCompositeDevice(device) => { @@ -720,6 +763,17 @@ impl TargetDriver } TargetCommand::ClearState => { implementation.clear_state(); + // Discard any pending delayed releases to avoid ghost key events + // after the composite device has cleared intercept state. + pressed_events.clear(); + scheduled_events.retain(|e| { + !matches!( + e.event().as_capability(), + Capability::Gamepad(Gamepad::Button(_)) + | Capability::Keyboard(_) + | Capability::Mouse(Mouse::Button(_)) + ) + }); } TargetCommand::Stop => { implementation.stop()?; diff --git a/src/input/target/steam_deck.rs b/src/input/target/steam_deck.rs index 071be6a9..59f75059 100644 --- a/src/input/target/steam_deck.rs +++ b/src/input/target/steam_deck.rs @@ -4,11 +4,10 @@ use packed_struct::{ }; use std::{ cmp::Ordering, - collections::HashMap, error::Error, fmt::Debug, sync::mpsc::{channel, Receiver, TryRecvError}, - time::{Duration, Instant}, + time::Duration, }; use virtual_usb::{ @@ -68,10 +67,6 @@ impl Default for SteamDeckConfig { } } -// The minimum amount of time that button up events must wait after -// a button down event. -const MIN_FRAME_TIME: Duration = Duration::from_millis(80); - pub struct SteamDeckDevice { chip_id: [u8; 15], config: SteamDeckConfig, @@ -82,7 +77,6 @@ pub struct SteamDeckDevice { device: Option, lizard_mode_enabled: bool, output_event: Option, - pressed_events: HashMap, queued_events: Vec, serial_number: String, state: PackedInputDataReport, @@ -109,7 +103,6 @@ impl SteamDeckDevice { device: None, lizard_mode_enabled: false, output_event: None, - pressed_events: HashMap::new(), queued_events: vec![], serial_number: "1NPU7PLUMB3R".to_string(), state: PackedInputDataReport::default(), @@ -741,6 +734,7 @@ impl SteamDeckDevice { TouchButton::Touch => self.state.l_pad_touch = event.pressed(), TouchButton::Press => self.state.l_pad_press = event.pressed(), }, + Touch::Gesture(_) => (), }, //TODO:: Remove CenterPad after we implement Target Profiles Touchpad::RightPad(touch_event) | Touchpad::CenterPad(touch_event) => { @@ -773,6 +767,7 @@ impl SteamDeckDevice { TouchButton::Touch => self.state.r_pad_touch = event.pressed(), TouchButton::Press => self.state.r_pad_press = event.pressed(), }, + Touch::Gesture(_) => (), } } }, @@ -917,41 +912,6 @@ impl TargetInputDevice for SteamDeckDevice { fn write_event(&mut self, event: NativeEvent) -> Result<(), InputError> { log::trace!("Received event: {event:?}"); - // Check to see if this is a button event - // In some cases, a button down and button up event can happen within - // the same "frame", which would result in no net state change. This - // allows us to process up events at a later time. - let cap = event.as_capability(); - if let Capability::Gamepad(Gamepad::Button(_)) = cap { - if event.pressed() { - log::trace!("Button down: {cap:?}"); - // Keep track of button down events - self.pressed_events.insert(cap.clone(), Instant::now()); - } else { - log::trace!("Button up: {cap:?}"); - // If the event is a button up event, check to - // see if we received a down event in the same - // frame. - if let Some(last_pressed) = self.pressed_events.get(&cap) { - log::trace!("Button was pressed {:?} ago", last_pressed.elapsed()); - if last_pressed.elapsed() < MIN_FRAME_TIME { - log::trace!("Button up & down event received in the same frame. Queueing event for the next frame."); - let scheduled_event = ScheduledNativeEvent::new_with_time( - event, - *last_pressed, - MIN_FRAME_TIME, - ); - self.queued_events.push(scheduled_event); - return Ok(()); - } else { - log::trace!("Removing button from pressed"); - // Button up event should be processed now - self.pressed_events.remove(&cap); - } - } - } - } - // Update device state with input events self.update_state(event); @@ -1126,7 +1086,6 @@ impl Debug for SteamDeckDevice { .field("lizard_mode_enabled", &self.lizard_mode_enabled) .field("serial_number", &self.serial_number) .field("queued_events", &self.queued_events) - .field("pressed_events", &self.pressed_events) .finish() } } From bb06a88278c955c99ef3478437d3663d5694d6db Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 04:50:29 +0800 Subject: [PATCH 6/8] feat(Hardware): enable touchscreen gesture support for GPD Win5 and ONEXPLAYER X1 GPD Win5: enable touchscreen source with grab mode. ONEXPLAYER X1 Mini: enable touchscreen source with grab mode and left orientation (display is rotated 90 degrees). --- .../usr/share/inputplumber/devices/50-gpd_win5.yaml | 10 ++++++++++ .../share/inputplumber/devices/50-onexplayer_x1.yaml | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/rootfs/usr/share/inputplumber/devices/50-gpd_win5.yaml b/rootfs/usr/share/inputplumber/devices/50-gpd_win5.yaml index e5949af3..e4b010cd 100644 --- a/rootfs/usr/share/inputplumber/devices/50-gpd_win5.yaml +++ b/rootfs/usr/share/inputplumber/devices/50-gpd_win5.yaml @@ -43,6 +43,16 @@ source_devices: name: AT Translated Set 2 keyboard phys_path: isa0060/serio0/input0 handler: event* + - group: touchscreen + udev: + properties: + - name: ID_INPUT_TOUCHSCREEN + value: "1" + sys_name: "event*" + subsystem: input + config: + touchscreen: + grab: true - group: imu iio: name: "{i2c-BMI0160:00,bmi260}" diff --git a/rootfs/usr/share/inputplumber/devices/50-onexplayer_x1.yaml b/rootfs/usr/share/inputplumber/devices/50-onexplayer_x1.yaml index cea7ac36..68f7ada7 100644 --- a/rootfs/usr/share/inputplumber/devices/50-onexplayer_x1.yaml +++ b/rootfs/usr/share/inputplumber/devices/50-onexplayer_x1.yaml @@ -53,6 +53,17 @@ source_devices: - group: imu iio: name: "{i2c-BMI0260:00,bmi260}" + - group: touchscreen + udev: + properties: + - name: ID_INPUT_TOUCHSCREEN + value: "1" + sys_name: "event*" + subsystem: input + config: + touchscreen: + grab: true + orientation: "left" # The target input device(s) that the virtual device profile can use target_devices: From 4eaf29d38b58cc6afe6e5a2372a3826b670a5cb4 Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 05:59:02 +0800 Subject: [PATCH 7/8] fix(Touchscreen): fix clippy warnings in gesture detection and value translation --- src/input/event/value.rs | 12 ++++-------- src/input/source/evdev/touchscreen.rs | 6 ++---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/input/event/value.rs b/src/input/event/value.rs index 6932264c..c0719d50 100644 --- a/src/input/event/value.rs +++ b/src/input/event/value.rs @@ -711,15 +711,11 @@ impl InputValue { Capability::None => Ok(InputValue::None), Capability::NotImplemented => Ok(InputValue::None), Capability::Sync => Ok(InputValue::Bool(false)), - Capability::Gamepad(gamepad) => match gamepad { - Gamepad::Button(_) => Ok(self.clone()), - _ => Err(TranslationError::NotImplemented), - }, + Capability::Gamepad(Gamepad::Button(_)) => Ok(self.clone()), + Capability::Gamepad(_) => Err(TranslationError::NotImplemented), Capability::Keyboard(_) => Ok(self.clone()), - Capability::Mouse(mouse) => match mouse { - Mouse::Button(_) => Ok(self.clone()), - _ => Err(TranslationError::NotImplemented), - }, + Capability::Mouse(Mouse::Button(_)) => Ok(self.clone()), + Capability::Mouse(_) => Err(TranslationError::NotImplemented), Capability::DBus(_) => Ok(self.clone()), _ => Err(TranslationError::NotImplemented), }, diff --git a/src/input/source/evdev/touchscreen.rs b/src/input/source/evdev/touchscreen.rs index 0f2461d8..b9680beb 100644 --- a/src/input/source/evdev/touchscreen.rs +++ b/src/input/source/evdev/touchscreen.rs @@ -419,8 +419,7 @@ impl TouchscreenEventDevice { // In grab mode, pre-suppress touches that start in the // edge zone to avoid any leakage before gesture confirm. let suppressing = self.grab - && (normal_value < GESTURE_SUPPRESS_START - || normal_value > 1.0 - GESTURE_SUPPRESS_START); + && !(GESTURE_SUPPRESS_START..=1.0 - GESTURE_SUPPRESS_START).contains(&normal_value); self.gesture_state.start_x = normal_value; self.gesture_state.start_time = Some(Instant::now()); self.gesture_state.phase = GesturePhase::Tracking { suppressing }; @@ -452,8 +451,7 @@ impl TouchscreenEventDevice { // In grab mode, also suppress touches starting at the // top or bottom edge. if self.grab - && (normal_value < GESTURE_SUPPRESS_START - || normal_value > 1.0 - GESTURE_SUPPRESS_START) + && !(GESTURE_SUPPRESS_START..=1.0 - GESTURE_SUPPRESS_START).contains(&normal_value) { self.gesture_state.phase = GesturePhase::Tracking { suppressing: true }; } From 8f1ea184f45c743c9811b0394ab5365ef8d739a3 Mon Sep 17 00:00:00 2001 From: honjow Date: Mon, 30 Mar 2026 06:04:08 +0800 Subject: [PATCH 8/8] fix(Capability): remove redundant Swipe prefix from GestureType variants --- src/input/capability.rs | 30 +++++++++++++-------------- src/input/source/evdev/touchscreen.rs | 20 +++++++++--------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/input/capability.rs b/src/input/capability.rs index 939b53c7..d28061d7 100644 --- a/src/input/capability.rs +++ b/src/input/capability.rs @@ -107,9 +107,9 @@ impl Capability { return None; }; let any_gesture = match gesture { - GestureType::SwipeRight(_) => GestureType::SwipeRight(GestureArea::Any), - GestureType::SwipeLeft(_) => GestureType::SwipeLeft(GestureArea::Any), - GestureType::SwipeUp | GestureType::SwipeDown => return None, + GestureType::Right(_) => GestureType::Right(GestureArea::Any), + GestureType::Left(_) => GestureType::Left(GestureArea::Any), + GestureType::Up | GestureType::Down => return None, }; Some(Capability::Touchscreen(Touch::Gesture(any_gesture))) } @@ -1420,22 +1420,22 @@ impl FromStr for GestureArea { #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum GestureType { /// Swipe inward from the left edge - SwipeRight(GestureArea), + Right(GestureArea), /// Swipe inward from the right edge - SwipeLeft(GestureArea), + Left(GestureArea), /// Swipe inward from the bottom edge - SwipeUp, + Up, /// Swipe inward from the top edge - SwipeDown, + Down, } impl fmt::Display for GestureType { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - GestureType::SwipeRight(area) => write!(f, "SwipeRight:{area}"), - GestureType::SwipeLeft(area) => write!(f, "SwipeLeft:{area}"), - GestureType::SwipeUp => write!(f, "SwipeUp"), - GestureType::SwipeDown => write!(f, "SwipeDown"), + GestureType::Right(area) => write!(f, "SwipeRight:{area}"), + GestureType::Left(area) => write!(f, "SwipeLeft:{area}"), + GestureType::Up => write!(f, "SwipeUp"), + GestureType::Down => write!(f, "SwipeDown"), } } } @@ -1448,14 +1448,14 @@ impl FromStr for GestureType { return Err(()); }; match *part { - "SwipeRight" => Ok(GestureType::SwipeRight(GestureArea::from_str( + "SwipeRight" => Ok(GestureType::Right(GestureArea::from_str( rest.join(":").as_str(), )?)), - "SwipeLeft" => Ok(GestureType::SwipeLeft(GestureArea::from_str( + "SwipeLeft" => Ok(GestureType::Left(GestureArea::from_str( rest.join(":").as_str(), )?)), - "SwipeUp" => Ok(GestureType::SwipeUp), - "SwipeDown" => Ok(GestureType::SwipeDown), + "SwipeUp" => Ok(GestureType::Up), + "SwipeDown" => Ok(GestureType::Down), _ => Err(()), } } diff --git a/src/input/source/evdev/touchscreen.rs b/src/input/source/evdev/touchscreen.rs index b9680beb..10700f08 100644 --- a/src/input/source/evdev/touchscreen.rs +++ b/src/input/source/evdev/touchscreen.rs @@ -519,7 +519,7 @@ impl TouchscreenEventDevice { } else { GestureArea::Bottom }; - Some(GestureType::SwipeRight(area)) + Some(GestureType::Right(area)) } else if start_x > 1.0 - GESTURE_START && (start_x - last_x) > GESTURE_MIN_TRAVEL { // Swipe inward from the right edge let area = if start_y < GESTURE_TOP_RATIO { @@ -527,13 +527,13 @@ impl TouchscreenEventDevice { } else { GestureArea::Bottom }; - Some(GestureType::SwipeLeft(area)) + Some(GestureType::Left(area)) } else if start_y > 1.0 - GESTURE_START && (start_y - last_y) > GESTURE_MIN_TRAVEL { // Swipe inward from the bottom edge - Some(GestureType::SwipeUp) + Some(GestureType::Up) } else if start_y < GESTURE_START && (last_y - start_y) > GESTURE_MIN_TRAVEL { // Swipe inward from the top edge - Some(GestureType::SwipeDown) + Some(GestureType::Down) } else { None }; @@ -643,12 +643,12 @@ impl SourceInputDevice for TouchscreenEventDevice { fn get_capabilities(&self) -> Result, InputError> { Ok(vec![ Capability::Touchscreen(Touch::Motion), - Capability::Touchscreen(Touch::Gesture(GestureType::SwipeRight(GestureArea::Top))), - Capability::Touchscreen(Touch::Gesture(GestureType::SwipeRight(GestureArea::Bottom))), - Capability::Touchscreen(Touch::Gesture(GestureType::SwipeLeft(GestureArea::Top))), - Capability::Touchscreen(Touch::Gesture(GestureType::SwipeLeft(GestureArea::Bottom))), - Capability::Touchscreen(Touch::Gesture(GestureType::SwipeUp)), - Capability::Touchscreen(Touch::Gesture(GestureType::SwipeDown)), + Capability::Touchscreen(Touch::Gesture(GestureType::Right(GestureArea::Top))), + Capability::Touchscreen(Touch::Gesture(GestureType::Right(GestureArea::Bottom))), + Capability::Touchscreen(Touch::Gesture(GestureType::Left(GestureArea::Top))), + Capability::Touchscreen(Touch::Gesture(GestureType::Left(GestureArea::Bottom))), + Capability::Touchscreen(Touch::Gesture(GestureType::Up)), + Capability::Touchscreen(Touch::Gesture(GestureType::Down)), ]) } }