From 298a99228f8a630a48a917b4bc8084a79ae8a5e4 Mon Sep 17 00:00:00 2001 From: slkass <78346291+slkass@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:26:24 +0800 Subject: [PATCH 1/6] Add DualSense feedback and controls with a unified Windows title bar --- Cargo.lock | 2 + README.md | 16 ++ crates/open-controller-core/Cargo.toml | 1 + .../examples/windows/loopback.rs | 21 +++ crates/open-controller-core/src/engine.rs | 37 ++++- crates/open-controller-core/src/i18n.rs | 27 +++ crates/open-controller-core/src/i18n/zh.rs | 9 + crates/open-controller-core/src/lib.rs | 1 + crates/open-controller-core/src/mapping.rs | 33 +++- crates/open-controller-core/src/microphone.rs | 99 +++++++++++ crates/open-controller-core/src/profile.rs | 26 +++ crates/open-controller-core/src/sdl.rs | 11 +- crates/open-controller-ui/Cargo.toml | 1 + crates/open-controller-ui/src/art.rs | 68 +++++++- crates/open-controller-ui/src/demo.rs | 1 + crates/open-controller-ui/src/detail.rs | 140 ++++++++++++++-- crates/open-controller-ui/src/frame.rs | 39 ++++- crates/open-controller-ui/src/main.rs | 3 +- .../open-controller-ui/src/touch_animation.rs | 154 ++++++++++++++++++ crates/open-controller-ui/src/ui.rs | 13 +- 20 files changed, 672 insertions(+), 30 deletions(-) create mode 100644 crates/open-controller-core/src/microphone.rs create mode 100644 crates/open-controller-ui/src/touch_animation.rs diff --git a/Cargo.lock b/Cargo.lock index 4d69855..0ce5aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4308,6 +4308,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "windows 0.62.2", "windows-sys 0.61.2", "x11rb", ] @@ -4321,6 +4322,7 @@ dependencies = [ "gpui-pre", "gpui-pre-platform", "open-controller-core", + "raw-window-handle", "serde", "serde_json", "unicode-segmentation", diff --git a/README.md b/README.md index 4609f0a..ddaf7df 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,22 @@ already opened it keeps it after it goes back to Xbox, so it would read it twice without a serial number shares its settings with every controller of its model, and so does this choice. With "Hide original controllers" off, native only removes the Xbox controller. +The Light page can explicitly opt into light-bar control while input stays native. This is off +by default; a game that also sets the light can compete with it. Custom colours accept a full +`#RRGGBB` value as well as the colour swatches. + +The preview follows both touchpad contacts, including movement without clicking, with +frame-timed smoothing and a short fading trail in the focused detailed preview. Unfocused +windows and reduced-motion mode keep direct feedback instead; losing focus clears old trails. +Both contacts and trails follow the configured LED colour, with the theme accent as a fallback +when the LED colour is black or off. This only affects drawing, never controller input. On Windows, +the DualSense microphone indicator shows the actual mute state of the default recording device. +Its separate, opt-in mic-button switch toggles that device; another binding for the button takes +priority. Bluetooth may use the PC microphone, since it need not expose a DualSense recording +device. This reads the endpoint's mute flag and never records audio. + +Windows uses one application-drawn title bar with the usual window controls. + ## Supported controllers Controllers are read through [SDL 3](https://libsdl.org), which speaks each one's own protocol, diff --git a/crates/open-controller-core/Cargo.toml b/crates/open-controller-core/Cargo.toml index a9cdce2..68bce9d 100644 --- a/crates/open-controller-core/Cargo.toml +++ b/crates/open-controller-core/Cargo.toml @@ -26,6 +26,7 @@ sdl3-sys = { version = "0.7.2", default-features = false, features = [ ] } [target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_System_Com", "Win32_System_Com_StructuredStorage"] } # Checks a downloaded driver installer against the hash it had when this version was made. sha2 = "0.10" windows-sys = { workspace = true, features = [ diff --git a/crates/open-controller-core/examples/windows/loopback.rs b/crates/open-controller-core/examples/windows/loopback.rs index 485c116..327f5fd 100644 --- a/crates/open-controller-core/examples/windows/loopback.rs +++ b/crates/open-controller-core/examples/windows/loopback.rs @@ -217,7 +217,13 @@ fn main() { engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.extras.contains(&TOUCH_LEFT)) }); check(pad.features.touchpad && pad.features.motion, "a touchpad and a gyro are seen, and the touchpad's halves listed"); + engine.send(Command::Watch(true)); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, true, 0.2, 0.5, 1.0) }; + wait_for("touch coordinates in the preview", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) + && p.input.touches[0] == open_controller_core::mapping::Touch::from_position(true, 0.2, 0.5)) + }); + check(true, "touching without clicking reaches the preview with its position"); button(TOUCHPAD, true); wait_for("a click on the left half as X", Duration::from_millis(500), x(true)); button(TOUCHPAD, false); @@ -229,6 +235,21 @@ fn main() { button(TOUCHPAD, false); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, false, 0.8, 0.5, 0.0) }; check(right_is_not_x, "a click on the left half of the touchpad is a button of its own, the right half another"); + wait_for("released touch clears the preview", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && !p.input.touches[0].down) + }); + unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, true, 0.7, 0.2, 1.0) }; + wait_for("the second finger independently reaches the preview", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) + && !p.input.touches[0].down + && p.input.touches[1] == open_controller_core::mapping::Touch::from_position(true, 0.7, 0.2)) + }); + unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, false, 0.7, 0.2, 0.0) }; + wait_for("both fingers release", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.input.touches.iter().all(|t| !t.down)) + }); + check(true, "both touchpad fingers and their independent releases reach the preview"); + engine.send(Command::Watch(false)); // A profile for a program: in front, its assignments; away, the chosen profile's again. profiles.apply(Edit::Select(0)); diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index 4c95d24..e61a7d7 100644 --- a/crates/open-controller-core/src/engine.rs +++ b/crates/open-controller-core/src/engine.rs @@ -185,6 +185,9 @@ pub enum Role { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PadView { + /// Actual mute flag of Windows' default input endpoint, not a latched button press. + #[serde(default)] + pub microphone_muted: Option, pub key: PadKey, pub name: String, pub brand: Brand, @@ -541,6 +544,8 @@ impl Target { } struct Core { + microphone: crate::microphone::Monitor, + microphone_seen: Option, bus: Option>, /// Read once on connecting: the device tree is slow to query, and this is the input thread. bus_version: Option, @@ -638,6 +643,8 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang HANDHELD_STOP.store(false, Ordering::Relaxed); let handheld_reader = handheld::start(handheld_tx, &HANDHELD_STOP); let mut core = Core { + microphone: crate::microphone::Monitor::start(), + microphone_seen: None, bus: None, bus_version: None, bus_error: None, @@ -715,6 +722,11 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang core.macro_buttons(); core.feedback(now); core.housekeeping(now); + let muted = core.microphone.muted(); + if core.microphone_seen != muted { + core.microphone_seen = muted; + core.changed = true; + } let waiting = core.roster.slots().iter().any(|s| s.lost_since.is_some()); let countdown = waiting && now.duration_since(last_countdown) >= Duration::from_millis(500); @@ -1699,7 +1711,13 @@ impl Core { for p in self.phys.values_mut() { // Where controllers stay as they are, their lights are still OpenController's to set. let player = match p.kind { - Kind::Slot(_) if native(&self.profiles, &p.model.store, p.model.form) => continue, + Kind::Slot(_) + if native(&self.profiles, &p.model.store, p.model.form) + && !self.profiles.get(&p.model.store).is_some_and(|x| x.native_light) => + { + p.led = None; + continue; + } Kind::Slot(s) => self.targets.get(&s).and_then(|t| t.player), Kind::Native if !VIRTUAL_PADS => None, _ => continue, @@ -1777,6 +1795,22 @@ impl Core { let aim_before = p.aim_out; let (rate_before, accel_before) = (p.rate, p.accel); p.pad.read(&mut p.state); + let mic_owner = match p.kind { + Kind::Slot(s) => self.roster.get(s).and_then(|s| s.source) == Some(id), + Kind::Native => true, + _ => false, + }; + if mic_owner + && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) + && self.profiles.get(&p.model.store).is_some_and(|x| x.mic_control) + && !self + .profiles + .get(&p.model.store) + .is_some_and(|x| x.for_program(&self.foreground).bindings.contains_key(&(crate::mapping::button::MISC1 as u8))) + && crate::microphone::pressed(before.buttons, p.state.buttons) + { + self.microphone.toggle(); + } if p.model.family == Family::Handheld && p.kind == Kind::Native { p.state.buttons |= self.handheld_held; } @@ -2228,6 +2262,7 @@ impl Core { // A controller that copies another's ids goes by the name of the model its owner chose. let alias = model.and_then(|m| models::alias(m.vendor, m.product, chosen)); PadView { + microphone_muted: model.filter(|m| matches!(m.family, Family::DualSense | Family::DualSenseEdge)).and(self.microphone_seen), key, name: alias.map(|a| a.4.to_string()).or_else(|| model.map(|m| m.name.clone())).unwrap_or_default(), brand: model.map(|m| m.brand).unwrap_or(Brand::Other), diff --git a/crates/open-controller-core/src/i18n.rs b/crates/open-controller-core/src/i18n.rs index 236f730..f4648b9 100644 --- a/crates/open-controller-core/src/i18n.rs +++ b/crates/open-controller-core/src/i18n.rs @@ -121,6 +121,15 @@ pub struct Text { pub touchpad_click: &'static str, pub capture: &'static str, pub mic: &'static str, + pub native_light: &'static str, + pub native_light_hint: &'static str, + pub mic_sync: &'static str, + pub mic_sync_hint: &'static str, + pub mic_muted: &'static str, + pub mic_live: &'static str, + pub mic_unavailable: &'static str, + pub rgb_hex: &'static str, + pub rgb_hex_hint: &'static str, pub share: &'static str, pub extra_n: &'static str, @@ -584,6 +593,15 @@ pub const EN: Text = Text { touchpad_click: "Touchpad click", capture: "Capture", mic: "Mic", + native_light: "Control lighting in native mode", + native_light_hint: "Keeps native input. Lighting may conflict with a game that also controls it.", + mic_sync: "Mic button controls Windows default microphone", + mic_sync_hint: "Press to toggle actual mute. Other assignments take priority. Bluetooth may use the PC microphone.", + mic_muted: "Windows microphone: muted · active", + mic_live: "Windows microphone: unmuted · inactive", + mic_unavailable: "Windows microphone: unavailable", + rgb_hex: "RGB colour · #RRGGBB", + rgb_hex_hint: "Enter six hexadecimal digits to apply.", share: "Share", extra_n: "Extra {}", @@ -1052,6 +1070,15 @@ pub const PT: Text = Text { touchpad_click: "Clique do touchpad", capture: "Captura", mic: "Microfone", + native_light: "Controlar luz no modo nativo", + native_light_hint: "Mantém a entrada nativa. A luz pode entrar em conflito com o jogo.", + mic_sync: "Botão de microfone controla a entrada padrão do Windows", + mic_sync_hint: "Aperte para alternar o mudo real. Outros mapeamentos têm prioridade. Bluetooth pode usar o microfone do PC.", + mic_muted: "Microfone do Windows: mudo · ativo", + mic_live: "Microfone do Windows: aberto · inativo", + mic_unavailable: "Microfone do Windows: indisponível", + rgb_hex: "Cor RGB · #RRGGBB", + rgb_hex_hint: "Digite seis dígitos hexadecimais para aplicar.", share: "Compartilhar", extra_n: "Extra {}", diff --git a/crates/open-controller-core/src/i18n/zh.rs b/crates/open-controller-core/src/i18n/zh.rs index 9960220..a628a7a 100644 --- a/crates/open-controller-core/src/i18n/zh.rs +++ b/crates/open-controller-core/src/i18n/zh.rs @@ -105,6 +105,15 @@ pub const ZH: Text = Text { touchpad_click: "触摸板按下", capture: "截图键", mic: "麦克风键", + native_light: "原生模式下控制灯光", + native_light_hint: "保留原始输入。若游戏也控制灯光,双方可能互相覆盖。", + mic_sync: "麦克风键控制 Windows 默认麦克风", + mic_sync_hint: "按一下切换实际静音;此键的其他映射优先。蓝牙下可能使用电脑麦克风。", + mic_muted: "Windows 麦克风:已静音 · active", + mic_live: "Windows 麦克风:未静音 · inactive", + mic_unavailable: "Windows 麦克风:不可用", + rgb_hex: "RGB 颜色 · #RRGGBB", + rgb_hex_hint: "输入完整的六位十六进制颜色即可应用。", share: "分享键", extra_n: "额外按键 {}", tab_xbox: "Xbox 按键", diff --git a/crates/open-controller-core/src/lib.rs b/crates/open-controller-core/src/lib.rs index b55512b..269cd3f 100644 --- a/crates/open-controller-core/src/lib.rs +++ b/crates/open-controller-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod ds4; pub mod extras; pub mod i18n; pub mod mapping; +mod microphone; pub mod models; pub mod motion; pub mod profile; diff --git a/crates/open-controller-core/src/mapping.rs b/crates/open-controller-core/src/mapping.rs index ad46028..26b39de 100644 --- a/crates/open-controller-core/src/mapping.rs +++ b/crates/open-controller-core/src/mapping.rs @@ -57,6 +57,23 @@ pub mod xusb { pub const Y: u16 = 0x8000; } +/// A finger on a touchpad, in normalised 0..=65535 coordinates. Released fingers are zeroed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Touch { + pub down: bool, + pub x: u16, + pub y: u16, +} + +impl Touch { + pub fn from_position(down: bool, x: f32, y: f32) -> Self { + if !down || !x.is_finite() || !y.is_finite() { + return Self::default(); + } + Self { down, x: (x.clamp(0., 1.) * 65535.).round() as u16, y: (y.clamp(0., 1.) * 65535.).round() as u16 } + } +} + /// One gamepad's input as SDL reports it: a bit per button, sticks in -32768..=32767 with Y /// growing downwards, triggers in 0..=32767. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -65,6 +82,8 @@ pub struct PadState { /// (`extras::TOUCH_LEFT` and up). pub buttons: u64, pub axes: [i16; axis::COUNT], + #[serde(default)] + pub touches: [Touch; 2], /// How hard each pressure-sensitive button is pressed, 0 to 255, in [`PRESSURE`]'s order; all /// 0 on a controller without them. #[serde(default)] @@ -178,7 +197,9 @@ const NOISE: i32 = 2048; /// A change a person made: a button, or an axis moved further than stick noise. pub fn significant(before: &PadState, after: &PadState) -> bool { - before.buttons != after.buttons || before.axes.iter().zip(after.axes).any(|(&a, b)| (a as i32 - b as i32).abs() > NOISE) + before.buttons != after.buttons + || before.touches != after.touches + || before.axes.iter().zip(after.axes).any(|(&a, b)| (a as i32 - b as i32).abs() > NOISE) } /// XInput motor speed (0..=255) to SDL's rumble intensity (0..=65535). @@ -190,6 +211,16 @@ pub fn motor(v: u8) -> u16 { mod tests { use super::*; + #[test] + fn touch_contacts_are_bounded_and_release_clears_stale_coordinates() { + assert_eq!(Touch::from_position(true, 0., 1.), Touch { down: true, x: 0, y: 65535 }); + assert_eq!(Touch::from_position(true, -1., 2.), Touch { down: true, x: 0, y: 65535 }); + assert_eq!(Touch::from_position(false, 0.8, 0.5), Touch::default()); + assert_eq!(Touch::from_position(true, f32::NAN, 0.), Touch::default()); + let old = r#"{"buttons":0,"axes":[0,0,0,0,0,0]}"#; + assert_eq!(serde_json::from_str::(old).unwrap().touches, [Touch::default(); 2]); + } + fn state(buttons: &[u32], axes: [i16; 6]) -> PadState { PadState { buttons: buttons.iter().fold(0, |b, &i| b | 1 << i), axes, ..PadState::default() } } diff --git a/crates/open-controller-core/src/microphone.rs b/crates/open-controller-core/src/microphone.rs new file mode 100644 index 0000000..1595f39 --- /dev/null +++ b/crates/open-controller-core/src/microphone.rs @@ -0,0 +1,99 @@ +//! Windows' default recording endpoint. Only its mute flag is read; audio is never captured. +//! COM and endpoint discovery stay on a worker, away from the controller's input thread. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +pub struct Monitor { + state: Arc, + #[cfg(windows)] + tx: std::sync::mpsc::SyncSender<()>, +} + +impl Monitor { + pub fn start() -> Self { + let state = Arc::new(AtomicU8::new(0)); + #[cfg(windows)] + { + let (tx, rx) = std::sync::mpsc::sync_channel(8); + let shared = state.clone(); + let _ = std::thread::Builder::new().name("open-controller-microphone".into()).spawn(move || windows_worker(rx, shared)); + Self { state, tx } + } + #[cfg(not(windows))] + Self { state } + } + + pub fn muted(&self) -> Option { + match self.state.load(Ordering::Acquire) { + 1 => Some(false), + 2 => Some(true), + _ => None, + } + } + + pub fn toggle(&self) { + #[cfg(windows)] + let _ = self.tx.try_send(()); + } +} + +#[cfg(windows)] +fn windows_worker(rx: std::sync::mpsc::Receiver<()>, state: Arc) { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Duration; + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{IMMDeviceEnumerator, MMDeviceEnumerator, eCapture, eConsole}; + use windows::Win32::System::Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize}; + + unsafe { + if CoInitializeEx(None, COINIT_MULTITHREADED).is_err() { + return; + } + let query = |toggle: bool| -> windows::core::Result { + let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let endpoint = enumerator.GetDefaultAudioEndpoint(eCapture, eConsole)?; + let volume: IAudioEndpointVolume = endpoint.Activate(CLSCTX_ALL, None)?; + let muted = volume.GetMute()?.as_bool(); + if toggle { + volume.SetMute(!muted, std::ptr::null())?; + } + Ok(volume.GetMute()?.as_bool()) + }; + let mut toggle = false; + loop { + let value = match query(toggle) { + Ok(false) => 1, + Ok(true) => 2, + Err(_) => 0, + }; + state.store(value, Ordering::Release); + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(()) => toggle = true, + Err(RecvTimeoutError::Timeout) => toggle = false, + Err(RecvTimeoutError::Disconnected) => break, + } + } + CoUninitialize(); + } +} + +pub fn pressed(before: u64, after: u64) -> bool { + let mask = 1 << crate::mapping::button::MISC1; + before & mask == 0 && after & mask != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mic_press_toggles_once_until_released() { + let mic = 1 << crate::mapping::button::MISC1; + assert!(pressed(0, mic)); + assert!(!pressed(mic, mic)); + assert!(!pressed(mic, 0)); + assert!(!pressed(0, 1)); + assert!(pressed(0, mic)); + } +} diff --git a/crates/open-controller-core/src/profile.rs b/crates/open-controller-core/src/profile.rs index 3f0d0a2..382d104 100644 --- a/crates/open-controller-core/src/profile.rs +++ b/crates/open-controller-core/src/profile.rs @@ -386,6 +386,10 @@ pub struct Profiles { /// Left as it is for games: no Xbox controller is made for it and it is not hidden, so /// none of the profiles apply. A version that does not know this field reads it as off. pub native: bool, + /// Opt-in lighting while game input stays native. Off preserves the original no-output mode. + pub native_light: bool, + /// The DualSense mic button toggles the Windows default capture endpoint, even in native mode. + pub mic_control: bool, /// Made an Xbox controller although it is not a gamepad (a flight stick, a throttle, a /// wheel), which is otherwise left as it is (see [`Form::left_as_is`]). pub xbox: bool, @@ -437,6 +441,8 @@ pub enum Edit { Select(usize), /// Keeps the controller native, or makes it an Xbox controller again. Native(bool), + NativeLight(bool), + MicControl(bool), /// Makes a controller that is not a gamepad an Xbox controller, or leaves it as it is again. Xbox(bool), /// Says which model the controller is (see [`Profiles::model`]). @@ -449,6 +455,8 @@ impl Profiles { list: vec![Profile { name: first, ..Profile::default() }], active: 0, native: false, + native_light: false, + mic_control: false, xbox: false, model: None, output: Output::Xbox360, @@ -512,6 +520,8 @@ impl Profiles { /// Fields a newer version wrote count as a change, so they are not thrown away with it. pub fn is_default(&self) -> bool { !self.native + && !self.native_light + && !self.mic_control && !self.xbox && self.model.is_none() && self.output == Output::Xbox360 @@ -606,6 +616,8 @@ impl Profiles { Edit::Select(i) if i < self.list.len() => self.active = i, Edit::Select(_) => {} Edit::Native(on) => self.native = on, + Edit::NativeLight(on) => self.native_light = on, + Edit::MicControl(on) => self.mic_control = on, Edit::Xbox(on) => (self.xbox, self.native) = (on, false), Edit::Model(key) => self.model = key.filter(|k| !k.is_empty() && k.len() <= 40), } @@ -757,6 +769,20 @@ pub fn light_color(light: Light, brightness: u8, player: Option, battery: Op #[cfg(test)] mod tests { use super::*; + + #[test] + fn native_lighting_and_mic_control_are_opt_in_and_survive_saving() { + let mut p = Profiles::default(); + assert!(!p.native_light && !p.mic_control); + p.apply(Edit::NativeLight(true)); + assert!(!p.is_default()); + p.apply(Edit::MicControl(true)); + p.apply(Edit::Native(true)); + let loaded: Profiles = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert!(loaded.native && loaded.native_light && loaded.mic_control); + p.apply(Edit::MicControl(false)); + assert!(p.native && p.native_light); + } use crate::binding::XboxButton; #[test] diff --git a/crates/open-controller-core/src/sdl.rs b/crates/open-controller-core/src/sdl.rs index 339c8fe..2256d55 100644 --- a/crates/open-controller-core/src/sdl.rs +++ b/crates/open-controller-core/src/sdl.rs @@ -159,6 +159,9 @@ impl Sdl { SDL_EVENT_GAMEPAD_ADDED => Event::GamepadAdded(ev.gdevice.which.0), SDL_EVENT_GAMEPAD_REMOVED => Event::GamepadRemoved(ev.gdevice.which.0), SDL_EVENT_GAMEPAD_UPDATE_COMPLETE => Event::GamepadUpdated(ev.gdevice.which.0), + SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN | SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION | SDL_EVENT_GAMEPAD_TOUCHPAD_UP => { + Event::GamepadUpdated(ev.gtouchpad.which.0) + } SDL_EVENT_JOYSTICK_BATTERY_UPDATED => Event::Battery(ev.jbattery.which.0), SDL_EVENT_JOYSTICK_ADDED => Event::JoystickAdded(ev.jdevice.which.0), SDL_EVENT_JOYSTICK_REMOVED => Event::JoystickRemoved(ev.jdevice.which.0), @@ -751,7 +754,7 @@ impl Gamepad { b |= 1 << i; } } - b |= self.touch_buttons(b & 1 << button::TOUCHPAD != 0); + b |= self.touch_buttons(b & 1 << button::TOUCHPAD != 0, out); if self.wii_dpad { let js = unsafe { SDL_GetGamepadJoystick(self.gp) }; for i in button::DPAD_UP..=button::DPAD_RIGHT { @@ -775,8 +778,9 @@ impl Gamepad { } /// The touchpad's halves and two-finger touch, as the bits OpenController gives them. - fn touch_buttons(&self, clicked: bool) -> u64 { + fn touch_buttons(&self, clicked: bool, out: &mut PadState) -> u64 { unsafe { + out.touches = Default::default(); if SDL_GetNumGamepadTouchpads(self.gp) == 0 { return 0; } @@ -785,6 +789,9 @@ impl Gamepad { for (i, f) in fingers.iter_mut().enumerate().take(n as usize) { let mut p = 0f32; SDL_GetGamepadTouchpadFinger(self.gp, 0, i as i32, &mut f.down, &mut f.x, &mut f.y, &mut p); + if let Some(touch) = out.touches.get_mut(i) { + *touch = crate::mapping::Touch::from_position(f.down, f.x, f.y); + } } let (left, right, two) = touch_buttons(clicked, &fingers[..n as usize]); u64::from(left) << TOUCH_LEFT | u64::from(right) << TOUCH_RIGHT | u64::from(two) << TOUCH_TWO diff --git a/crates/open-controller-ui/Cargo.toml b/crates/open-controller-ui/Cargo.toml index 314f860..360bf1d 100644 --- a/crates/open-controller-ui/Cargo.toml +++ b/crates/open-controller-ui/Cargo.toml @@ -23,6 +23,7 @@ serde_json.workspace = true unicode-segmentation = "1" [target.'cfg(windows)'.dependencies] +raw-window-handle = "0.6" windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } [build-dependencies] diff --git a/crates/open-controller-ui/src/art.rs b/crates/open-controller-ui/src/art.rs index c9c21db..824aa43 100644 --- a/crates/open-controller-ui/src/art.rs +++ b/crates/open-controller-ui/src/art.rs @@ -834,13 +834,49 @@ fn icon(name: &str, x: f32, y: f32, r: f32) -> Option<(Ops, Option)> { }) } +fn pad_light_supported(look: Look) -> bool { + matches!(look, Look::DualSense | Look::DualSenseEdge | Look::DualShock4) +} + +fn touch_position((left, top, right, bottom): (f32, f32, f32, f32), (x, y): (f32, f32)) -> (f32, f32) { + (left + (right - left) * x, top + (bottom - top) * y) +} + /// The drawing of a controller in its current state, `scale` times the size of a 320-wide one. pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { + controller_with_touches(pad, scale, t, crate::touch_animation::Frame::immediate(pad.input.touches)) +} + +pub fn controller_with_touches(pad: &PadView, scale: f32, t: &Theme, touches: crate::touch_animation::Frame) -> AnyElement { let look = look(pad); let glyphs = glyphs(pad, look); let state: PadState = pad.input; + let mic_muted = pad.microphone_muted == Some(true); + let light = match pad.power { + open_controller_core::device::Power::Battery(v) | open_controller_core::device::Power::Charging(v) => v, + _ => None, + }; + let light = + open_controller_core::profile::light_color(pad.profile().light, pad.profile().brightness, crate::home::player(&pad.role), light); + let light_owned = pad.role != open_controller_core::Role::KeptNative || pad.profiles.native_light; + let light_ink: Hsla = rgb(u32::from(light[0]) << 16 | u32::from(light[1]) << 8 | u32::from(light[2])).into(); let k = scale * UNIT; let ink = ink(t); + // Match the configured LED hue, while keeping feedback visible at low brightness or off. + let touch_light = open_controller_core::profile::light_color( + pad.profile().light, + 100, + crate::home::player(&pad.role), + match pad.power { + open_controller_core::device::Power::Battery(v) | open_controller_core::device::Power::Charging(v) => v, + _ => None, + }, + ); + let touch_ink: Hsla = if touch_light == [0; 3] { + ink.lit + } else { + rgb(u32::from(touch_light[0]) << 16 | u32::from(touch_light[1]) << 8 | u32::from(touch_light[2])).into() + }; let g = pad_for(look); let (turn, gripped) = grip_turn(g, &state); @@ -873,7 +909,11 @@ pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { pen.fill(&g.body, linear_gradient(180., linear_color_stop(ink.body_top, 0.), linear_color_stop(ink.body_bottom, 1.))); pen.stroke(&g.body, 1., ink.edge); for (style, ops) in &g.panels { - panel(&mut pen, style, ops, k, &ink); + if style == "light" && light_owned && pad_light_supported(look) { + pen.shape(ops, light_ink.opacity(0.75), light_ink); + } else { + panel(&mut pen, style, ops, k, &ink); + } } for &[x, y, r] in &g.dots { pen.fill(&circle(x, y, r), ink.soft); @@ -881,6 +921,27 @@ pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { if let Some(tp) = &g.touchpad { let b = Some(button::TOUCHPAD); pen.shape(tp, lit_or(b, ink.part), lit_or(b, ink.edge)); + let (left, top, right, bottom) = extent(tp); + // Keep both contacts inside the inset rectangle of the curved touch surface. + let inset = 11.; + let surface = (left + inset, top + inset, right - inset, bottom - inset); + for (i, touch) in touches.fingers.iter().enumerate() { + let color = touch_ink; + for segment in touch.trail.windows(2) { + let (x0, y0) = touch_position(surface, segment[0].0); + let (x1, y1) = touch_position(surface, segment[1].0); + let fade = segment[0].1; + let path = line(x0, y0, x1, y1); + pen.stroke(&path, (4.5 * k).max(1.), color.opacity(fade * 0.18)); + pen.stroke(&path, ((0.7 + 1.6 * fade) * k).max(0.6), color.opacity(fade * 0.8)); + pen.fill(&circle(x1, y1, 0.35 + fade * 0.8), color.opacity(fade * 0.65)); + } + let Some(head) = touch.head else { continue }; + let (x, y) = touch_position(surface, head); + pen.fill(&circle(x, y, 8.), color.opacity(0.18)); + pen.stroke(&circle(x, y, if i == 0 { 5. } else { 6.5 }), (1.3 * k).max(1.), color); + pen.fill(&circle(x, y, 2.2), color); + } } // Levers set into the body, filled from the back, with their handle where it is. @@ -935,8 +996,9 @@ pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { } } for b in g.buttons.iter().filter(|b| b.grip == on_grip) { - let fill = lit_or(b.input, ink.part); - let edge = lit_or(b.input, ink.edge); + let muted = b.input == Some(button::MISC1) && mic_muted; + let fill = if muted { rgb(0xffa928).into() } else { lit_or(b.input, ink.part) }; + let edge = if muted { rgb(0xffc76a).into() } else { lit_or(b.input, ink.edge) }; let (ops, centre) = match &b.shape { &Shape::Circle(x, y, r) => { let (x, y) = turned.at(x, y); diff --git a/crates/open-controller-ui/src/demo.rs b/crates/open-controller-ui/src/demo.rs index 7c0f37b..f31ed38 100644 --- a/crates/open-controller-ui/src/demo.rs +++ b/crates/open-controller-ui/src/demo.rs @@ -18,6 +18,7 @@ fn input(buttons: &[u32], axes: [i16; axis::COUNT]) -> PadState { pub(crate) fn pad(key: PadKey, name: &str, brand: Brand, family: Family, (vendor, product): (u16, u16)) -> PadView { PadView { + microphone_muted: None, key, name: name.into(), brand, diff --git a/crates/open-controller-ui/src/detail.rs b/crates/open-controller-ui/src/detail.rs index 8ac9e9a..0947ee9 100644 --- a/crates/open-controller-ui/src/detail.rs +++ b/crates/open-controller-ui/src/detail.rs @@ -9,7 +9,7 @@ use crate::keys; use crate::report; use crate::text_field::{TextField, TextFieldEvent}; use crate::theme::{Theme, icon as glyph, radius}; -use crate::ui::{MainView, Section, Tab}; +use crate::ui::{MainView, Screen, Section, Tab}; use crate::widgets::{ Kind, body, button, caption, card, chip, display, group, icon, icon_button, keycap, row, segmented, stage, strong, switch, title, }; @@ -173,6 +173,22 @@ pub struct Rename { _events: Subscription, } +pub struct ColorInput { + key: PadKey, + color: [u8; 3], + field: Entity, + _events: Subscription, +} + +fn parse_rgb(value: &str) -> Option<[u8; 3]> { + let s = value.trim().strip_prefix('#').unwrap_or(value.trim()); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let n = u32::from_str_radix(s, 16).ok()?; + Some([(n >> 16) as u8, (n >> 8) as u8, n as u8]) +} + fn profile_name(text: &Text, name: &str, i: usize) -> String { match (name.is_empty(), i) { (false, _) => name.to_string(), @@ -270,7 +286,7 @@ impl MainView { cx.notify(); } - pub fn render_device(&mut self, pad: &PadView, _: &mut Window, cx: &mut Context) -> AnyElement { + pub fn render_device(&mut self, pad: &PadView, window: &mut Window, cx: &mut Context) -> AnyElement { let t = self.theme; let text = self.model.read(cx).text; let key = pad.key; @@ -332,7 +348,7 @@ impl MainView { if assignable && !native(pad) { sections.push((Section::Sticks, glyph::STICKS, text.nav_sticks)); } - if pad.features.light_bar && assignable { + if pad.features.light_bar && pad.store.is_some() { sections.push((Section::Light, glyph::LIGHT, text.nav_light)); } sections.push((Section::Info, glyph::INFO, text.nav_info)); @@ -360,7 +376,7 @@ impl MainView { } let content = match current { - Section::Buttons => self.buttons_section(pad, text, cx), + Section::Buttons => self.buttons_section(pad, text, window, cx), Section::Light => self.light_section(pad, text, cx), Section::Motion => self.motion_section(pad, text, cx), Section::Sticks => self.sticks_section(pad, text, cx), @@ -672,14 +688,28 @@ impl MainView { .into_any_element() } - fn buttons_section(&self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + fn buttons_section(&mut self, pad: &PadView, text: &'static Text, window: &mut Window, cx: &mut Context) -> AnyElement { let t = self.theme; - let drawing = card(&t).flex().flex_col().flex_none().overflow_hidden().child( - stage(&t).flex().justify_center().px(px(20.)).pt(px(28.)).pb(px(20.)).child(match self.learning_pose(pad) { - Some(input) => art::controller(&PadView { input, ..pad.clone() }, 1.1, &t), - None => art::controller(pad, 1.1, &t), - }), - ); + let controller = match self.learning_pose(pad) { + Some(input) => art::controller(&PadView { input, ..pad.clone() }, 1.1, &t), + None if !window.is_window_active() || cx.reduce_motion() => { + self.touch_animation = crate::touch_animation::Animation::default(); + art::controller(pad, 1.1, &t) + } + None => { + let frame = self.touch_animation.frame(pad.key, pad.input.touches, std::time::Instant::now()); + if frame.animating { + window.request_animation_frame(); + } + art::controller_with_touches(pad, 1.1, &t, frame) + } + }; + let drawing = card(&t) + .flex() + .flex_col() + .flex_none() + .overflow_hidden() + .child(stage(&t).flex().justify_center().px(px(20.)).pt(px(28.)).pb(px(20.)).child(controller)); let hint = pad.hint.map(|h| hint_text(text, h)).or_else(|| read_directly(pad).then_some(text.linux_direct_read)).map(|h| { div() .flex() @@ -702,6 +732,9 @@ impl MainView { .flex_1() .min_w(px(320.)) .child(self.extras_column(pad, text, &t, cx)) + .when(cfg!(windows) && matches!(pad.family, Family::DualSense | Family::DualSenseEdge), |d| { + d.child(self.microphone_row(pad, text, cx)) + }) .when(remappable(pad), |d| d.child(self.all_buttons_column(pad, text, &t, cx))) .when(pad.features.pressure && remappable(pad), |d| d.child(self.pressure_card(pad, text, cx))) .when(pad.store.is_some() && !kept_native(pad) && !pad.profiles.active().bindings.is_empty(), |d| { @@ -835,7 +868,31 @@ impl MainView { )) } - fn light_section(&self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + fn microphone_row(&self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + let t = self.theme; + let status = match pad.microphone_muted { + Some(true) => text.mic_muted, + Some(false) => text.mic_live, + None => text.mic_unavailable, + }; + div() + .flex() + .flex_col() + .mt(px(16.)) + .gap(px(8.)) + .child(strong(status, if pad.microphone_muted == Some(true) { t.caution } else { t.text2 })) + .child(self.toggle( + "mic-sync", + (text.mic_sync, text.mic_sync_hint), + pad.profiles.mic_control, + Edit::MicControl(!pad.profiles.mic_control), + pad.key, + cx, + )) + .into_any_element() + } + + fn light_section(&mut self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { let t = self.theme; let key = pad.key; let p = pad.profiles.active(); @@ -882,6 +939,24 @@ impl MainView { Light::Color(c) => c, _ => SWATCHES[7], }; + if self.color_input.as_ref().is_none_or(|c| c.key != key) { + let field = cx.new(|cx| TextField::new(cx, false, 7).placeholder("#RRGGBB")); + let events = cx.subscribe(&field, move |this, field, _: &TextFieldEvent, cx| { + if matches!(this.screen, Screen::Device(k, _) if k == key) + && let Some(color) = parse_rgb(&field.read(cx).text()) + { + this.edit_now(key, Edit::Light(Light::Color(color)), cx); + } + }); + self.color_input = Some(ColorInput { key, color: [0; 3], field, _events: events }); + } + let input = self.color_input.as_mut().unwrap(); + if input.color != current_color || input.field.read(cx).text().is_empty() { + input.color = current_color; + let value = format!("#{:02X}{:02X}{:02X}", current_color[0], current_color[1], current_color[2]); + input.field.update(cx, |f, cx| f.set_text(&value, cx)); + } + let color_field = input.field.clone(); let mut swatches = div().flex().flex_wrap().gap(px(8.)); for (i, c) in SWATCHES.into_iter().enumerate() { let on = color_on && current_color == c; @@ -956,6 +1031,17 @@ impl MainView { .flex_1() .min_w(px(320.)) .gap(px(4.)) + .when(kept_native(pad), |d| { + d.child(self.toggle( + "native-light", + (text.native_light, text.native_light_hint), + pad.profiles.native_light, + Edit::NativeLight(!pad.profiles.native_light), + key, + cx, + )) + .mb(px(12.)) + }) .child(option( "light-player", text.light_player, @@ -965,7 +1051,20 @@ impl MainView { cx, )) .child(option("light-color", text.light_color, None, color_on, Light::Color(current_color), cx)) - .when(color_on, |d| d.child(div().px(px(16.)).py(px(12.)).child(swatches))) + .when(color_on, |d| { + d.child( + div() + .flex() + .flex_col() + .gap(px(10.)) + .px(px(16.)) + .py(px(12.)) + .child(swatches) + .child(caption(text.rgb_hex, t.text2)) + .child(color_field) + .child(caption(text.rgb_hex_hint, t.text3)), + ) + }) .child(option( "light-battery", text.light_battery, @@ -1449,3 +1548,18 @@ impl MainView { .into_any_element() } } + +#[cfg(test)] +mod color_tests { + use super::parse_rgb; + + #[test] + fn rgb_accepts_full_colors_and_rejects_partial_or_non_hex_input() { + assert_eq!(parse_rgb("#00aAFF"), Some([0, 170, 255])); + assert_eq!(parse_rgb("000000"), Some([0, 0, 0])); + assert_eq!(parse_rgb(" FFFFFF "), Some([255; 3])); + for s in ["", "#", "#12345", "#1234567", "#GG0000", "汉字"] { + assert_eq!(parse_rgb(s), None, "{s}"); + } + } +} diff --git a/crates/open-controller-ui/src/frame.rs b/crates/open-controller-ui/src/frame.rs index ae220e7..33aa0a3 100644 --- a/crates/open-controller-ui/src/frame.rs +++ b/crates/open-controller-ui/src/frame.rs @@ -1,6 +1,6 @@ //! The window's own frame, where the desktop leaves it to the program, as GNOME does on Wayland: //! a shadow, edges to resize the window by, and minimize, maximize and close in the bar on top. -//! Where the system draws the frame (Windows, macOS, X11, KDE) none of this shows. +//! Windows uses the same title controls while keeping its system border and shadow. use crate::theme::{Theme, icon as glyph, layout, radius}; use crate::ui::MainView; @@ -19,6 +19,29 @@ const GAP: f32 = 4.; /// From the window's buttons to the side of the window. const EDGE: f32 = 10.; +pub fn custom_title_bar(window: &Window) -> bool { + cfg!(windows) || tiling(window).is_some() +} + +/// GPUI 0.3.7's Windows `zoom` only maximizes; restore explicitly through the same HWND. +fn toggle_maximized(window: &mut Window) { + #[cfg(windows)] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows_sys::Win32::UI::WindowsAndMessaging::{IsZoomed, SW_MAXIMIZE, SW_RESTORE, ShowWindowAsync}; + if let Ok(handle) = window.window_handle() + && let RawWindowHandle::Win32(handle) = handle.as_raw() + { + let hwnd = handle.hwnd.get() as windows_sys::Win32::Foundation::HWND; + unsafe { + ShowWindowAsync(hwnd, if IsZoomed(hwnd) != 0 { SW_RESTORE } else { SW_MAXIMIZE }); + } + return; + } + } + window.zoom_window(); +} + /// Which sides of the window touch the screen's edges or another window, when the program /// draws the frame; `None` when the system does. pub fn tiling(window: &Window) -> Option { @@ -124,15 +147,19 @@ impl MainView { /// moves the window, a double click maximizes, a right click opens the window menu, and the /// window's buttons sit at its ends. `row` is the bar's content, kept clear of them. pub fn title_bar(&self, bar: Div, row: Div, window: &Window, cx: &mut Context) -> Div { - if tiling(window).is_none() { + if !custom_title_bar(window) { return bar.child(row); } let t = self.theme; let (left, right) = buttons(window, cx); let can = window.window_controls(); let maximized = window.is_maximized(); - let width = inner_width(window); - let margin = (width - width.min(layout::MAX_W)) / 2.; + let margin = if cfg!(windows) { + 0. + } else { + let width = inner_width(window); + (width - width.min(layout::MAX_W)) / 2. + }; let room = |n: usize| { let own = layout::GUTTER - 6.; if n == 0 { own } else { own.max(EDGE + n as f32 * (BUTTON + GAP) + 4. - margin) } @@ -155,7 +182,7 @@ impl MainView { cx.listener(move |this, ev: &MouseDownEvent, window, _| { this.moving = ev.click_count < 2; if ev.click_count == 2 && can.maximize { - window.zoom_window(); + toggle_maximized(window); } }), ) @@ -205,7 +232,7 @@ fn window_button(b: WindowButton, maximized: bool, t: &Theme, cx: &mut Context window.minimize_window(), - WindowButton::Maximize => window.zoom_window(), + WindowButton::Maximize => toggle_maximized(window), WindowButton::Close => window.remove_window(), })) } diff --git a/crates/open-controller-ui/src/main.rs b/crates/open-controller-ui/src/main.rs index 55a922b..8cb8852 100644 --- a/crates/open-controller-ui/src/main.rs +++ b/crates/open-controller-ui/src/main.rs @@ -19,6 +19,7 @@ mod settings; mod sticks; mod text_field; mod theme; +mod touch_animation; mod tuning; mod ui; mod updates; @@ -451,7 +452,7 @@ fn forced_lang() -> Option { fn open_window(model: Entity, cx: &mut App) -> Option> { let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, size(px(1160.), px(800.)), cx))), - titlebar: Some(TitlebarOptions { title: Some("OpenController".into()), ..Default::default() }), + titlebar: Some(TitlebarOptions { title: Some("OpenController".into()), appears_transparent: cfg!(windows), ..Default::default() }), // The Wayland app id and the X11 class: the .desktop file's name, so the dock shows its icon. app_id: Some("io.github.brunovncs.open-controller".into()), window_min_size: Some(size(px(720.), px(520.))), diff --git a/crates/open-controller-ui/src/touch_animation.rs b/crates/open-controller-ui/src/touch_animation.rs new file mode 100644 index 0000000..5868e8b --- /dev/null +++ b/crates/open-controller-ui/src/touch_animation.rs @@ -0,0 +1,154 @@ +//! Presentation-only touch smoothing. Raw controller input is never changed. + +use open_controller_core::PadKey; +use open_controller_core::mapping::Touch; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +type Position = (f32, f32); +const TRAIL_LIFE: Duration = Duration::from_millis(240); +const RESPONSE: f32 = 0.014; +const MIN_STEP: f32 = 0.0007; +const MAX_SAMPLES: usize = 48; + +#[derive(Default)] +pub struct Animation { + key: Option, + last: Option, + fingers: [Finger; 2], +} + +#[derive(Default)] +struct Finger { + down: bool, + position: Position, + sampled: Position, + trail: VecDeque<(Instant, Position)>, +} + +#[derive(Default)] +pub struct Frame { + pub fingers: [FingerFrame; 2], + pub animating: bool, +} + +#[derive(Default)] +pub struct FingerFrame { + pub head: Option, + /// Oldest to newest; each point carries its remaining lifetime, from zero to one. + pub trail: Vec<(Position, f32)>, +} + +fn position(touch: Touch) -> Position { + (f32::from(touch.x) / 65535., f32::from(touch.y) / 65535.) +} + +fn distance(a: Position, b: Position) -> f32 { + (a.0 - b.0).hypot(a.1 - b.1) +} + +impl Frame { + pub fn immediate(touches: [Touch; 2]) -> Self { + Self { + fingers: touches.map(|touch| FingerFrame { head: touch.down.then(|| position(touch)), trail: Vec::new() }), + animating: false, + } + } +} + +impl Animation { + pub fn frame(&mut self, key: PadKey, touches: [Touch; 2], now: Instant) -> Frame { + // A different page/controller or a long-hidden preview starts with fresh contacts. + if self.key != Some(key) || self.last.is_some_and(|last| now.duration_since(last) > TRAIL_LIFE) { + *self = Self { key: Some(key), ..Self::default() }; + } + let dt = self.last.map_or(0., |last| now.duration_since(last).as_secs_f32()); + self.last = Some(now); + let blend = 1. - (-dt / RESPONSE).exp(); + let mut frame = Frame::default(); + for ((finger, touch), painted) in self.fingers.iter_mut().zip(touches).zip(frame.fingers.iter_mut()) { + while finger.trail.front().is_some_and(|(time, _)| now.duration_since(*time) >= TRAIL_LIFE) { + finger.trail.pop_front(); + } + if touch.down { + let target = position(touch); + if !finger.down { + // Never connect the end of one gesture to the start of the next. + finger.trail.clear(); + finger.position = target; + finger.sampled = target; + finger.trail.push_back((now, target)); + } else { + finger.position.0 += (target.0 - finger.position.0) * blend; + finger.position.1 += (target.1 - finger.position.1) * blend; + if distance(finger.position, target) < MIN_STEP { + finger.position = target; + } else { + frame.animating = true; + } + if distance(finger.position, finger.sampled) >= MIN_STEP { + finger.trail.push_back((now, finger.position)); + finger.sampled = finger.position; + while finger.trail.len() > MAX_SAMPLES { + finger.trail.pop_front(); + } + } + } + painted.head = Some(finger.position); + } + finger.down = touch.down; + painted.trail = finger + .trail + .iter() + .map(|(time, point)| (*point, 1. - now.duration_since(*time).as_secs_f32() / TRAIL_LIFE.as_secs_f32())) + .collect(); + frame.animating |= !painted.trail.is_empty(); + } + frame + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contacts_and_gestures_do_not_join_and_released_trails_stop_animating() { + let mut animation = Animation::default(); + let key = PadKey::Slot(0); + let now = Instant::now(); + let touch = |x| Touch { down: true, x, y: 32768 }; + animation.frame(key, [touch(1000), touch(60000)], now); + let moved = animation.frame(key, [touch(30000), touch(40000)], now + Duration::from_millis(16)); + assert!(moved.fingers[0].head.unwrap().0 < moved.fingers[1].head.unwrap().0); + let released = animation.frame(key, [Touch::default(); 2], now + Duration::from_millis(32)); + assert!(released.fingers.iter().all(|f| f.head.is_none())); + assert!(released.animating); + let new = animation.frame(key, [touch(65000), Touch::default()], now + Duration::from_millis(48)); + assert_eq!(new.fingers[0].trail.len(), 1); + animation.frame(key, [Touch::default(); 2], now + Duration::from_millis(64)); + let faded = animation.frame(key, [Touch::default(); 2], now + Duration::from_millis(320)); + assert!(!faded.animating); + assert!(faded.fingers.iter().all(|f| f.trail.is_empty())); + } + + #[test] + fn smoothing_uses_elapsed_time_instead_of_frame_count() { + let finish = |steps: u32| { + let mut animation = Animation::default(); + let key = PadKey::Slot(0); + let now = Instant::now(); + let start = Touch { down: true, x: 0, y: 0 }; + let end = Touch { x: 65535, ..start }; + animation.frame(key, [start, Touch::default()], now); + let mut frame = Frame::default(); + for step in 1..=steps { + frame = + animation.frame(key, [end, Touch::default()], now + Duration::from_secs_f64(0.04 * f64::from(step) / f64::from(steps))); + } + frame.fingers[0].head.unwrap().0 + }; + assert!((finish(2) - finish(8)).abs() < 0.0001); + assert!(finish(8) > 0.9 && finish(8) < 1.); + } +} diff --git a/crates/open-controller-ui/src/ui.rs b/crates/open-controller-ui/src/ui.rs index b1e3f1b..80e7021 100644 --- a/crates/open-controller-ui/src/ui.rs +++ b/crates/open-controller-ui/src/ui.rs @@ -157,6 +157,8 @@ pub struct MainView { /// The bar on top was pressed where the window draws its own frame: moving the mouse now /// moves the window. pub moving: bool, + pub color_input: Option, + pub touch_animation: crate::touch_animation::Animation, _subscriptions: Vec, } @@ -174,14 +176,16 @@ impl MainView { let activation = cx.observe_window_activation(window, |this, window, cx| { // Behind a game, the drawings need not follow every frame of input. this.send(ToTray::Focused(window.is_window_active()), cx); + // Drop the old gesture immediately on blur; never replay it on reactivation. + this.touch_animation = crate::touch_animation::Animation::default(); // GPUI tells of light and dark changing but not of the accent colour, which is // picked in Windows' settings: coming back from them makes the window active. let now = theme(window); if now.accent != this.theme.accent { crate::theme::accent_changed(); this.theme = now; - cx.notify(); } + cx.notify(); }); let focus = cx.focus_handle(); focus.focus(window, cx); @@ -211,6 +215,8 @@ impl MainView { brand: brand(window.scale_factor()), held: 0, moving: false, + color_input: None, + touch_animation: crate::touch_animation::Animation::default(), _subscriptions: vec![observe, theme_change, activation], }; view.read_installed(cx); @@ -559,7 +565,7 @@ impl MainView { /// The bar across the top: where you are, and the way to the settings. fn top_bar(&self, window: &Window, cx: &mut Context) -> AnyElement { let t = self.theme; - let framed = crate::frame::tiling(window).is_some(); + let framed = crate::frame::custom_title_bar(window); let attention = self.needs_attention(cx); let on_settings = self.screen == Screen::Settings; let mark = img(self.brand.1.clone()).size(px(BRAND)).flex_none(); @@ -592,10 +598,11 @@ impl MainView { .items_center() .justify_between() .w_full() - .max_w(px(layout::MAX_W)) + .when(!cfg!(windows), |d| d.max_w(px(layout::MAX_W))) // The brand's own padding and the gear's hit area sit in the gutter. .px(px(layout::GUTTER - 6.)) .child(home) + .when(cfg!(windows), |d| d.child(div().flex_1().h_full().window_control_area(gpui::WindowControlArea::Drag))) .child(gear); self.title_bar(bar, row, window, cx).into_any_element() } From 44ec1ef2f8d62bc91a35459578aa0d490c482728 Mon Sep 17 00:00:00 2001 From: slkass <78346291+slkass@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:52:28 +0800 Subject: [PATCH 2/6] Declare the Windows Variant feature used by microphone activation --- crates/open-controller-core/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/open-controller-core/Cargo.toml b/crates/open-controller-core/Cargo.toml index 68bce9d..53e5017 100644 --- a/crates/open-controller-core/Cargo.toml +++ b/crates/open-controller-core/Cargo.toml @@ -26,7 +26,7 @@ sdl3-sys = { version = "0.7.2", default-features = false, features = [ ] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.62.2", features = ["Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_System_Com", "Win32_System_Com_StructuredStorage"] } +windows = { version = "0.62.2", features = ["Win32_Media_Audio", "Win32_Media_Audio_Endpoints", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Variant"] } # Checks a downloaded driver installer against the hash it had when this version was made. sha2 = "0.10" windows-sys = { workspace = true, features = [ From c9a624aa37c4c7bb74eaf96fd138675e7a37da78 Mon Sep 17 00:00:00 2001 From: slkass <78346291+slkass@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:19:16 +0800 Subject: [PATCH 3/6] Add Controller Lab foundation and DualSense trigger effect trials --- README.md | 15 ++ .../examples/windows/loopback.rs | 43 ++- crates/open-controller-core/src/dsu.rs | 11 + crates/open-controller-core/src/engine.rs | 173 ++++++++++-- crates/open-controller-core/src/i18n.rs | 8 +- crates/open-controller-core/src/i18n/lab.rs | 217 +++++++++++++++ crates/open-controller-core/src/i18n/zh.rs | 3 +- crates/open-controller-core/src/ipc.rs | 3 +- crates/open-controller-core/src/lab.rs | 221 ++++++++++++++++ crates/open-controller-core/src/lib.rs | 3 + crates/open-controller-core/src/profile.rs | 6 + crates/open-controller-core/src/sdl.rs | 5 + .../src/trigger_effect.rs | 71 +++++ crates/open-controller-ui/src/demo.rs | 1 + crates/open-controller-ui/src/detail.rs | 4 + crates/open-controller-ui/src/lab.rs | 248 ++++++++++++++++++ crates/open-controller-ui/src/main.rs | 1 + crates/open-controller-ui/src/ui.rs | 36 ++- crates/open-controller/src/main.rs | 1 + 19 files changed, 1025 insertions(+), 45 deletions(-) create mode 100644 crates/open-controller-core/src/dsu.rs create mode 100644 crates/open-controller-core/src/i18n/lab.rs create mode 100644 crates/open-controller-core/src/lab.rs create mode 100644 crates/open-controller-core/src/trigger_effect.rs create mode 100644 crates/open-controller-ui/src/lab.rs diff --git a/README.md b/README.md index ddaf7df..6de3bc2 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,21 @@ device. This reads the endpoint's mute flag and never records audio. Windows uses one application-drawn title bar with the usual window controls. +### Controller Lab + +The controller's Lab page has optional tools, saved with each profile and off by default: + +- **Trigger Lab:** independent DualSense/Edge L2 and R2 resistance, vibration and trigger-break + effects. Adjust the start/end zones, force and frequency, try them for five seconds, stop + them immediately or opt into persistent profile effects. A trial restores the profile after + its timeout; disconnect and normal exit clear effects owned by OpenController. Output goes + through SDL, including USB/Bluetooth framing and checksums. + +Use matching resident/UI binaries (IPC protocol 2). Trials restore the saved profile settings. + +Protocol references: [SDL's gamepad effect example](https://github.com/libsdl-org/SDL/blob/main/test/testcontroller.c), +and [DualSense trigger encoding](https://gist.github.com/Nielk1/6d54cc2c00d2201ccb8c2720ad7538db). + ## Supported controllers Controllers are read through [SDL 3](https://libsdl.org), which speaks each one's own protocol, diff --git a/crates/open-controller-core/examples/windows/loopback.rs b/crates/open-controller-core/examples/windows/loopback.rs index 327f5fd..b54b31d 100644 --- a/crates/open-controller-core/examples/windows/loopback.rs +++ b/crates/open-controller-core/examples/windows/loopback.rs @@ -9,7 +9,8 @@ use windows_sys::Win32::Foundation::FILETIME; use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_F24}; use windows_sys::Win32::UI::Input::XboxController::{ - XINPUT_GAMEPAD_A, XINPUT_GAMEPAD_LEFT_SHOULDER, XINPUT_GAMEPAD_X, XINPUT_GAMEPAD_Y, XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, XInputSetState, + XINPUT_GAMEPAD_A, XINPUT_GAMEPAD_LEFT_SHOULDER, XINPUT_GAMEPAD_X, XINPUT_GAMEPAD_Y, XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, + XInputSetState, }; /// The Xbox set, two back paddles and the touchpad click. @@ -50,7 +51,7 @@ fn attach_one(first: bool) -> (SDL_JoystickID, *mut SDL_Joystick) { naxes: 6, nbuttons: BUTTON_MASK.count_ones() as u16, ntouchpads: 1, - nsensors: 1, + nsensors: sensors.len() as u16, button_mask: BUTTON_MASK, axis_mask: 0x3F, name: c"Loopback Controller".as_ptr(), @@ -95,8 +96,14 @@ fn main() { let mut profiles = Profiles::default(); profiles.apply(Edit::Bind { button: RIGHT_PADDLE1, action: Some(Action::Xbox(XboxButton::Y)) }); profiles.apply(Edit::Bind { button: LEFT_PADDLE1, action: Some(Action::Keys(Chord { mods: 0, key: VK_F24 })) }); - let config = - Config { data_dir: dir, hide: false, profiles: [(PROFILE.to_string(), profiles.clone())].into(), driver: Default::default(), split_joycons: false, mappings: Default::default() }; + let config = Config { + data_dir: dir, + hide: false, + profiles: [(PROFILE.to_string(), profiles.clone())].into(), + driver: Default::default(), + split_joycons: false, + mappings: Default::default(), + }; let engine = Engine::start(config, || {}); wait_for("the engine", Duration::from_secs(10), || engine.snapshot().running.then_some(())); if !matches!(engine.snapshot().vigem, open_controller_core::Driver::Ready { .. }) { @@ -168,7 +175,9 @@ fn main() { unsafe { SDL_SetJoystickVirtualButton(joy, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER.0, false) }; check(s.Gamepad.wButtons & XINPUT_GAMEPAD_LEFT_SHOULDER == 0, "the left bumper made into the left trigger pulls it, and is not LB"); engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone())); - wait_for("everything let go", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0 && s.Gamepad.bLeftTrigger == 0)); + wait_for("everything let go", Duration::from_millis(500), || { + xinput(slot).filter(|s| s.Gamepad.wButtons == 0 && s.Gamepad.bLeftTrigger == 0) + }); let pad = engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE)); check( @@ -220,8 +229,9 @@ fn main() { engine.send(Command::Watch(true)); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, true, 0.2, 0.5, 1.0) }; wait_for("touch coordinates in the preview", Duration::from_secs(1), || { - engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) - && p.input.touches[0] == open_controller_core::mapping::Touch::from_position(true, 0.2, 0.5)) + engine.snapshot().pads.into_iter().find(|p| { + p.store.as_deref() == Some(PROFILE) && p.input.touches[0] == open_controller_core::mapping::Touch::from_position(true, 0.2, 0.5) + }) }); check(true, "touching without clicking reaches the preview with its position"); button(TOUCHPAD, true); @@ -240,9 +250,11 @@ fn main() { }); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, true, 0.7, 0.2, 1.0) }; wait_for("the second finger independently reaches the preview", Duration::from_secs(1), || { - engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) - && !p.input.touches[0].down - && p.input.touches[1] == open_controller_core::mapping::Touch::from_position(true, 0.7, 0.2)) + engine.snapshot().pads.into_iter().find(|p| { + p.store.as_deref() == Some(PROFILE) + && !p.input.touches[0].down + && p.input.touches[1] == open_controller_core::mapping::Touch::from_position(true, 0.7, 0.2) + }) }); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, false, 0.7, 0.2, 0.0) }; wait_for("both fingers release", Duration::from_secs(1), || { @@ -324,7 +336,9 @@ fn main() { gyro(0.0, 100); check( untoggled == 0 && toggled < -20000 && paused == 0 && resumed < -20000 && toggled_off == 0, - &format!("a toggle turns the gyro on and off ({untoggled}, {toggled}, {toggled_off}), and the off button stops it while held ({paused})"), + &format!( + "a toggle turns the gyro on and off ({untoggled}, {toggled}, {toggled_off}), and the off button stops it while held ({paused})" + ), ); profiles.apply(Edit::Gyro(Gyro::default())); @@ -345,9 +359,12 @@ fn main() { profiles.apply(Edit::Sticks(Sticks { left: Shape { deadzone: 20, ..Shape::default() }, right: Some(own), ..Sticks::default() })); engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone())); unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_RIGHTX.0, 26500) }; - let tilted = wait_for("the right stick at full tilt", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.sThumbRX == 32767)); + let tilted = + wait_for("the right stick at full tilt", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.sThumbRX == 32767)); unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_RIGHTX.0, 5000) }; - let right_small = wait_for("a small right stick", Duration::from_millis(500), || xinput(slot).filter(|s| s.Gamepad.sThumbRX > 0 && s.Gamepad.sThumbRX < 10000)); + let right_small = wait_for("a small right stick", Duration::from_millis(500), || { + xinput(slot).filter(|s| s.Gamepad.sThumbRX > 0 && s.Gamepad.sThumbRX < 10000) + }); unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_RIGHTX.0, 0) }; check( tilted.Gamepad.sThumbRX == 32767 && right_small.Gamepad.sThumbRX > 5000, diff --git a/crates/open-controller-core/src/dsu.rs b/crates/open-controller-core/src/dsu.rs new file mode 100644 index 0000000..89606c1 --- /dev/null +++ b/crates/open-controller-core/src/dsu.rs @@ -0,0 +1,11 @@ +//! DSU status shared by the Lab snapshot. +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Status { + pub running: bool, + pub port: u16, + pub clients: usize, + pub error: Option, +} diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index e61a7d7..2b8028d 100644 --- a/crates/open-controller-core/src/engine.rs +++ b/crates/open-controller-core/src/engine.rs @@ -133,6 +133,7 @@ pub enum PadKey { } pub enum Command { + TriggerPreview(PadKey, Option<[crate::lab::TriggerEffect; 2]>), SetHiding(bool), /// Rumble a controller briefly, to tell which one is which. Identify(PadKey), @@ -185,6 +186,8 @@ pub enum Role { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PadView { + #[serde(default)] + pub lab_status: crate::lab::Status, /// Actual mute flag of Windows' default input endpoint, not a latched button press. #[serde(default)] pub microphone_muted: Option, @@ -396,6 +399,7 @@ struct Model { /// A connected device, as SDL sees it. struct Phys { + tools: Tools, pad: Gamepad, model: Model, link: Link, @@ -433,6 +437,30 @@ struct Phys { tilt_at: Option, } +struct Tools { + runtime: crate::lab::Runtime, + trial: Option<(Instant, [crate::lab::TriggerEffect; 2])>, + last_effect: Option<([crate::lab::TriggerEffect; 2], Instant)>, + error: Option, +} +impl Tools { + fn wanted(&mut self, cfg: crate::lab::Config, owned: bool, now: Instant) -> ([crate::lab::TriggerEffect; 2], bool) { + let expired = self.trial.is_some_and(|(until, _)| now >= until); + if expired || !owned { + self.trial = None; + } + let effects = if owned { + self.trial.map(|(_, e)| e).unwrap_or(if cfg.adaptive_enabled { cfg.adaptive } else { Default::default() }) + } else { + Default::default() + }; + (effects, expired) + } + fn new(_now: Instant) -> Self { + Self { runtime: Default::default(), trial: None, last_effect: None, error: None } + } +} + impl Phys { /// What its motion sensors read, for a virtual DualShock 4: at rest when they are off. fn motion(&self) -> Motion { @@ -719,6 +747,7 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang core.handheld_buttons(); core.poll_sixaxis(now); core.forward(now); + core.update_tools(now); core.macro_buttons(); core.feedback(now); core.housekeeping(now); @@ -826,6 +855,15 @@ impl Core { fn command(&mut self, c: Command, now: Instant) { match c { + Command::TriggerPreview(key, effects) => { + if let Some(id) = self.device_for(key) + && let Some(p) = self.phys.get_mut(&id) + && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) + { + p.tools.trial = effects.map(|e| (now + Duration::from_secs(5), e.map(crate::lab::TriggerEffect::clean))); + self.changed = true; + } + } Command::SetHiding(on) => { self.hiding = on; if on { @@ -1356,6 +1394,7 @@ impl Core { model.store = format!("handheld:{}", m.name); } let mut p = Phys { + tools: Tools::new(Instant::now()), model, link, identity, @@ -1381,6 +1420,7 @@ impl Core { pad, }; p.pad.read(&mut p.state); + p.tools.runtime.output = p.state; let xbox = matches!(p.pad.pad_type(), device::PadType::Xbox360 | device::PadType::XboxOne); p.kind = if devnode::is_virtual(&p.path) { Kind::Own @@ -1525,6 +1565,9 @@ impl Core { self.gone_halves.push((p.pad.pad_type() == device::PadType::JoyConRight, p.path.clone(), now)); } self.updated.retain(|&d| d != id); + if p.tools.last_effect.is_some() { + let _ = p.pad.trigger_effect(Default::default()); + } self.macro_held.remove(&id); if let Kind::Slot(s) = p.kind { self.roster.detach(id, now); @@ -1611,7 +1654,7 @@ impl Core { fn report_of(&self, dev: DeviceId) -> XusbReport { let Some(p) = self.phys.get(&dev) else { return XusbReport::default() }; let profile = self.profile_of(&p.model.store); - let mut state = p.state; + let mut state = p.tools.runtime.output; if let Some(pr) = profile { state.buttons &= !binding::taken(&pr.bindings); // The real triggers, before pressure can move them too. @@ -1634,7 +1677,7 @@ impl Core { r.thumb_ry = (i32::from(r.thumb_ry) + i32::from(p.aim_out.1)).clamp(-32768, 32767) as i16; } if let Some(pr) = profile { - binding::overlay(&pr.bindings, p.state.buttons, &mut r); + binding::overlay(&pr.bindings, p.tools.runtime.output.buttons, &mut r); } if let Some(b) = self.macro_held.get(&dev) { b.press(&mut r); @@ -1795,22 +1838,6 @@ impl Core { let aim_before = p.aim_out; let (rate_before, accel_before) = (p.rate, p.accel); p.pad.read(&mut p.state); - let mic_owner = match p.kind { - Kind::Slot(s) => self.roster.get(s).and_then(|s| s.source) == Some(id), - Kind::Native => true, - _ => false, - }; - if mic_owner - && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) - && self.profiles.get(&p.model.store).is_some_and(|x| x.mic_control) - && !self - .profiles - .get(&p.model.store) - .is_some_and(|x| x.for_program(&self.foreground).bindings.contains_key(&(crate::mapping::button::MISC1 as u8))) - && crate::microphone::pressed(before.buttons, p.state.buttons) - { - self.microphone.toggle(); - } if p.model.family == Family::Handheld && p.kind == Kind::Native { p.state.buttons |= self.handheld_held; } @@ -1846,9 +1873,6 @@ impl Core { } let (kind, state) = (p.kind, p.state); // Any button can type keys, not only the extra ones. - if kind == Kind::Native && before.buttons != state.buttons { - self.type_keys(Owner::Device(id), id, before.buttons, state.buttons); - } if let Kind::Slot(s) = kind { // Stick noise on the idle connection of a controller connected twice must not // pull the slot over to it. @@ -1858,9 +1882,6 @@ impl Core { } if self.roster.get(s).and_then(|x| x.source) == Some(id) && !self.is_native(s) { self.resend(s); - if before.buttons != state.buttons || !self.held.is_empty() { - self.type_keys(Owner::Slot(s), id, before.buttons, state.buttons); - } } } } @@ -1868,6 +1889,73 @@ impl Core { self.updated.clear(); } + fn update_tools(&mut self, now: Instant) { + let ids: Vec = self.phys.keys().copied().collect(); + for id in ids { + let (owned, mapped) = match self.phys[&id].kind { + Kind::Slot(s) => (self.roster.get(s).and_then(|slot| slot.source) == Some(id), !self.is_native(s)), + Kind::Native => (true, false), + _ => (false, false), + }; + let cfg = self.profile_of(&self.phys[&id].model.store).map(|p| p.lab).unwrap_or_default(); + let p = self.phys.get_mut(&id).unwrap(); + let before = p.tools.runtime.output; + if owned { + if !p.tools.runtime.active { + p.tools.runtime = Default::default(); + } + let _mouse = p.tools.runtime.update(p.state, cfg, mapped, p.rate, now); + p.tools.runtime.active = true; + } else { + p.tools.runtime.active = false; + p.tools.trial = None; + } + let (effects, expired) = p.tools.wanted(cfg, owned, now); + self.changed |= expired; + if matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) { + let active = effects.iter().any(|e| e.mode != crate::lab::EffectMode::Off && e.strength > 0); + let due = match p.tools.last_effect { + None => active || p.tools.trial.is_some(), + Some((last, at)) => { + last != effects || (active || p.tools.error.is_some()) && now.duration_since(at) >= Duration::from_secs(1) + } + }; + if due { + let error = p.pad.trigger_effect(effects).err(); + self.changed |= error != p.tools.error; + p.tools.error = error; + p.tools.last_effect = Some((effects, now)); + } + } + let after = p.tools.runtime.output; + let kind = p.kind; + let mic_press = owned + && crate::microphone::pressed(before.buttons, after.buttons) + && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) + && self.profiles.get(&p.model.store).is_some_and(|x| { + x.mic_control && !x.for_program(&self.foreground).bindings.contains_key(&(crate::mapping::button::MISC1 as u8)) + }); + if mic_press { + self.microphone.toggle(); + } + if owned && before != after { + self.input_changed = true; + if let Kind::Slot(s) = kind + && mapped + { + self.resend(s); + } + } + if owned && (before.buttons != after.buttons || !self.held.is_empty()) { + match kind { + Kind::Slot(s) if mapped => self.type_keys(Owner::Slot(s), id, before.buttons, after.buttons), + Kind::Native => self.type_keys(Owner::Device(id), id, before.buttons, after.buttons), + _ => {} + } + } + } + } + fn feedback(&mut self, now: Instant) { while let Ok(fb) = self.feedback_rx.try_recv() { let Some((&s, t)) = self.targets.iter_mut().find(|(_, t)| t.serial == fb.serial) else { continue }; @@ -2183,6 +2271,15 @@ impl Core { view.role = role; view.hidden = slot.devices.iter().any(|d| self.is_hidden(*d)); view.input = source.map(|p| p.state).unwrap_or_default(); + view.lab_status = source + .map(|p| crate::lab::Status { + testing: p.tools.trial.is_some(), + trigger_error: p.tools.error.clone(), + dsu: Default::default(), + output: p.tools.runtime.output, + led: p.led, + }) + .unwrap_or_default(); view.gyro = source.and_then(|p| p.rate); view.aim = source.map_or((0, 0), |p| p.aim_out); view.can_power_off = source.is_some_and(|p| p.link == Link::Bluetooth && device::bluetooth_address(&p.identity).is_some()); @@ -2200,6 +2297,13 @@ impl Core { let player = device::xinput_slot(&p.path).or_else(|| if VIRTUAL_PADS { None } else { p.pad.player() }); view.role = Role::Native { player }; view.input = p.state; + view.lab_status = crate::lab::Status { + testing: p.tools.trial.is_some(), + trigger_error: p.tools.error.clone(), + dsu: Default::default(), + output: p.tools.runtime.output, + led: p.led, + }; pads.push(view); } // In player order, whoever holds the slot: games number them that way. The sort is @@ -2262,6 +2366,7 @@ impl Core { // A controller that copies another's ids goes by the name of the model its owner chose. let alias = model.and_then(|m| models::alias(m.vendor, m.product, chosen)); PadView { + lab_status: Default::default(), microphone_muted: model.filter(|m| matches!(m.family, Family::DualSense | Family::DualSenseEdge)).and(self.microphone_seen), key, name: alias.map(|a| a.4.to_string()).or_else(|| model.map(|m| m.name.clone())).unwrap_or_default(), @@ -2297,6 +2402,11 @@ impl Core { } fn shutdown(&mut self) { + for p in self.phys.values() { + if p.tools.last_effect.is_some() { + let _ = p.pad.trigger_effect(Default::default()); + } + } for p in self.phys.values().filter(|p| !(matches!(p.kind, Kind::Slot(_)) && native(&self.profiles, &p.model.store, p.model.form))) { p.pad.rumble(0, 0, 0); } @@ -2351,6 +2461,21 @@ fn slot_role(native: bool, remaining: Option, target: Option(line: &[u8], skipped: &mut u /// What the window asks of the resident process. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum ToTray { + TriggerPreview(PadKey, Option<[crate::lab::TriggerEffect; 2]>), SetHiding(bool), SetAutostart(bool), SetCheckUpdates(bool), diff --git a/crates/open-controller-core/src/lab.rs b/crates/open-controller-core/src/lab.rs new file mode 100644 index 0000000..3d07252 --- /dev/null +++ b/crates/open-controller-core/src/lab.rs @@ -0,0 +1,221 @@ +//! Optional controller tools. All defaults preserve direct gamepad input. +use crate::mapping::{PadState, button}; +use serde::{Deserialize, Serialize}; +use std::time::Instant; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TouchMode { + #[default] + Off, + Mouse, + Trackball, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TouchMouse { + pub mode: TouchMode, + pub sensitivity: u16, + pub friction_ms: u16, + pub tap: bool, + pub scroll: bool, +} +impl Default for TouchMouse { + fn default() -> Self { + Self { mode: TouchMode::Off, sensitivity: 100, friction_ms: 180, tap: true, scroll: true } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Flick { + pub enabled: bool, + pub counts_per_turn: u16, + pub duration_ms: u16, + pub threshold: u8, + pub gyro_mouse: bool, + pub gyro_sensitivity: u16, +} +impl Default for Flick { + fn default() -> Self { + Self { enabled: false, counts_per_turn: 3600, duration_ms: 80, threshold: 85, gyro_mouse: true, gyro_sensitivity: 10 } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TriggerMode { + #[default] + Normal, + Hair, + TwoStage, + HipFire, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TriggerInput { + pub mode: TriggerMode, + pub soft: u8, + pub full: u8, + pub hysteresis: u8, + pub delay_ms: u16, + pub soft_button: u8, + pub full_button: u8, + pub exclusive: bool, +} +impl Default for TriggerInput { + fn default() -> Self { + Self { + mode: TriggerMode::Normal, + soft: 25, + full: 90, + hysteresis: 3, + delay_ms: 100, + soft_button: button::LEFT_SHOULDER as u8, + full_button: button::SOUTH as u8, + exclusive: true, + } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum EffectMode { + #[default] + Off, + Resistance, + Vibration, + Click, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TriggerEffect { + pub mode: EffectMode, + pub start: u8, + pub end: u8, + pub strength: u8, + pub frequency: u8, +} +impl Default for TriggerEffect { + fn default() -> Self { + Self { mode: EffectMode::Off, start: 3, end: 7, strength: 3, frequency: 20 } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum LightMode { + #[default] + Off, + Rainbow, + Breathe, + Rumble, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DynamicLight { + pub mode: LightMode, + pub period_ms: u16, + pub idle_seconds: u16, +} +impl Default for DynamicLight { + fn default() -> Self { + Self { mode: LightMode::Off, period_ms: 4000, idle_seconds: 0 } + } +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + pub touch: TouchMouse, + pub flick: Flick, + pub triggers: [TriggerInput; 2], + pub debounce_ms: u16, + pub dsu: bool, + pub dynamic: DynamicLight, + pub adaptive_enabled: bool, + pub adaptive: [TriggerEffect; 2], +} +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Status { + pub testing: bool, + pub trigger_error: Option, + pub dsu: crate::dsu::Status, + pub output: PadState, + pub led: Option<[u8; 3]>, +} +impl Config { + pub fn clean(mut self) -> Self { + self.touch.sensitivity = self.touch.sensitivity.clamp(10, 400); + self.touch.friction_ms = self.touch.friction_ms.clamp(40, 1000); + self.flick.counts_per_turn = self.flick.counts_per_turn.clamp(100, 60000); + self.flick.duration_ms = self.flick.duration_ms.clamp(20, 300); + self.flick.threshold = self.flick.threshold.clamp(50, 99); + self.flick.gyro_sensitivity = self.flick.gyro_sensitivity.clamp(1, 100); + for t in &mut self.triggers { + t.soft = t.soft.clamp(1, 90); + t.full = t.full.clamp(t.soft + 1, 100); + t.hysteresis = t.hysteresis.clamp(1, 20); + t.delay_ms = t.delay_ms.clamp(10, 1000); + t.soft_button = t.soft_button.min(63); + t.full_button = t.full_button.min(63); + } + self.debounce_ms = self.debounce_ms.min(100); + self.dynamic.period_ms = self.dynamic.period_ms.clamp(500, 30000); + self.dynamic.idle_seconds = self.dynamic.idle_seconds.min(600); + self.adaptive = self.adaptive.map(TriggerEffect::clean); + self + } +} +impl TriggerEffect { + pub fn clean(mut self) -> Self { + self.start = self.start.min(9); + self.end = self.end.clamp(1, 9); + self.strength = self.strength.min(8); + self.frequency = self.frequency.clamp(1, 100); + if self.mode == EffectMode::Click { + self.start = self.start.clamp(2, 7); + self.end = self.end.clamp(self.start + 1, 8); + } + self + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Mouse { + pub dx: f32, + pub dy: f32, + pub wheel: f32, + pub buttons: u8, + pub tap: bool, +} + +#[derive(Default)] +pub struct Runtime { + pub output: PadState, + pub active: bool, +} +impl Runtime { + pub fn update(&mut self, raw: PadState, _cfg: Config, _mapped: bool, _rate: Option<[f32; 3]>, _now: Instant) -> Mouse { + self.output = raw; + Mouse::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lab_settings_survive_profiles_and_old_profiles_keep_every_tool_off() { + let mut profiles = crate::profile::Profiles::default(); + let cfg = Config { dsu: true, debounce_ms: 15, ..Default::default() }; + profiles.apply(crate::profile::Edit::Lab(cfg)); + assert!(!profiles.is_default()); + let loaded: crate::profile::Profiles = serde_json::from_str(&serde_json::to_string(&profiles).unwrap()).unwrap(); + assert_eq!(loaded.active().lab, cfg); + let old: crate::profile::Profile = serde_json::from_str(r#"{"name":"old"}"#).unwrap(); + assert_eq!(old.lab, Config::default()); + } + #[test] + fn defaults_leave_input_and_mouse_untouched() { + let mut r = Runtime::default(); + let mut p = PadState { buttons: 7, ..Default::default() }; + p.axes[2] = 12345; + let m = r.update(p, Config::default(), true, None, Instant::now()); + assert_eq!(r.output, p); + assert_eq!((m.dx, m.dy, m.buttons, m.tap), (0., 0., 0, false)); + } +} diff --git a/crates/open-controller-core/src/lib.rs b/crates/open-controller-core/src/lib.rs index 269cd3f..e47d1d3 100644 --- a/crates/open-controller-core/src/lib.rs +++ b/crates/open-controller-core/src/lib.rs @@ -11,8 +11,10 @@ pub mod crash; pub mod device; pub mod ds3; pub mod ds4; +pub mod dsu; pub mod extras; pub mod i18n; +pub mod lab; pub mod mapping; mod microphone; pub mod models; @@ -21,6 +23,7 @@ pub mod profile; pub mod rating; pub mod report; pub mod roster; +pub mod trigger_effect; mod engine; pub mod handheld; diff --git a/crates/open-controller-core/src/profile.rs b/crates/open-controller-core/src/profile.rs index 382d104..9d4acd8 100644 --- a/crates/open-controller-core/src/profile.rs +++ b/crates/open-controller-core/src/profile.rs @@ -333,6 +333,8 @@ pub struct Profile { pub gyro: Gyro, pub sticks: Sticks, pub pressure: Pressure, + #[serde(deserialize_with = "lenient")] + pub lab: crate::lab::Config, /// Executable names (`game.exe`, lowercase) that make this the profile in use while one of /// them is the program in front. pub programs: Vec, @@ -352,6 +354,7 @@ impl Default for Profile { gyro: Gyro::default(), sticks: Sticks::default(), pressure: Pressure::default(), + lab: crate::lab::Config::default(), programs: Vec::new(), extra: Map::new(), } @@ -413,6 +416,7 @@ impl Default for Profiles { /// A change to a controller's profiles, as the window asks for it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Edit { + Lab(crate::lab::Config), /// Assigns an extra button in the profile in use, or clears it. Bind { button: u8, @@ -532,6 +536,7 @@ impl Profiles { pub fn apply(&mut self, edit: Edit) { match edit { + Edit::Lab(lab) => self.active_mut().lab = lab.clean(), // The same limit `sanitised` keeps: buttons are bits of a u64. Edit::Bind { button: 64.., .. } => {} Edit::Bind { button, action } => { @@ -636,6 +641,7 @@ impl Profiles { p.gyro = clean_gyro(p.gyro); p.sticks = clean_sticks(p.sticks); p.pressure = settle_pressure(Pressure::default(), clean_pressure(p.pressure)); + p.lab = p.lab.clean(); free_dpad_stick(p); p.programs = std::mem::take(&mut p.programs).iter().map(|x| program_name(x)).filter(|x| !x.is_empty()).take(MAX_PROGRAMS).collect(); diff --git a/crates/open-controller-core/src/sdl.rs b/crates/open-controller-core/src/sdl.rs index 2256d55..fb5fc73 100644 --- a/crates/open-controller-core/src/sdl.rs +++ b/crates/open-controller-core/src/sdl.rs @@ -861,6 +861,11 @@ impl Gamepad { unsafe { SDL_SetGamepadLED(self.gp, r, g, b) }; } + pub fn trigger_effect(&self, effects: [crate::lab::TriggerEffect; 2]) -> Result<(), String> { + let data = crate::trigger_effect::report(effects); + if unsafe { SDL_SendGamepadEffect(self.gp, data.as_ptr().cast(), data.len() as i32) } { Ok(()) } else { Err(error()) } + } + pub fn player(&self) -> Option { u8::try_from(unsafe { SDL_GetGamepadPlayerIndex(self.gp) }).ok() } diff --git a/crates/open-controller-core/src/trigger_effect.rs b/crates/open-controller-core/src/trigger_effect.rs new file mode 100644 index 0000000..afca332 --- /dev/null +++ b/crates/open-controller-core/src/trigger_effect.rs @@ -0,0 +1,71 @@ +//! DualSense's documented 11-byte trigger commands, sent through SDL's transport/CRC handling. +//! Only Feedback (0x21), Weapon (0x25), Vibration (0x26) and Off (0x05) are emitted. +//! Layout references: SDL testcontroller.c and Nielk1's public trigger protocol documentation: +//! https://gist.github.com/Nielk1/6d54cc2c00d2201ccb8c2720ad7538db +use crate::lab::{EffectMode, TriggerEffect}; + +pub fn encode(effect: TriggerEffect) -> [u8; 11] { + let e = effect.clean(); + let mut data = [0; 11]; + data[0] = 0x05; + if e.mode == EffectMode::Off || e.strength == 0 { + return data; + } + let force = u32::from(e.strength - 1); + if e.mode == EffectMode::Click { + data[0] = 0x25; + data[1..3].copy_from_slice(&((1u16 << e.start) | (1u16 << e.end)).to_le_bytes()); + data[3] = force as u8; + } else { + data[0] = if e.mode == EffectMode::Vibration { 0x26 } else { 0x21 }; + let zones = (0x03ffu16 << e.start) & 0x03ff; + data[1..3].copy_from_slice(&zones.to_le_bytes()); + let packed = (e.start..10).fold(0u32, |bits, zone| bits | (force << (3 * zone))); + data[3..7].copy_from_slice(&packed.to_le_bytes()); + if e.mode == EffectMode::Vibration { + data[9] = e.frequency; + } + } + data +} + +/// SDL DS5EffectsState payload: flags, ten audio/rumble bytes, R2 then L2, remaining fields zero. +pub fn report(left_right: [TriggerEffect; 2]) -> [u8; 47] { + let mut data = [0; 47]; + data[0] = 0x0c; + data[10..21].copy_from_slice(&encode(left_right[1])); + data[21..32].copy_from_slice(&encode(left_right[0])); + data +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn standard_modes_pack_zones_and_never_touch_audio_or_lights() { + let rigid = TriggerEffect { mode: EffectMode::Resistance, start: 3, strength: 8, ..Default::default() }; + let bytes = encode(rigid); + assert_eq!(&bytes[..3], &[0x21, 0xf8, 3]); + let packed = u32::from_le_bytes(bytes[3..7].try_into().unwrap()); + for zone in 0..10 { + assert_eq!((packed >> (zone * 3)) & 7, if zone >= 3 { 7 } else { 0 }); + } + let pulse = TriggerEffect { mode: EffectMode::Vibration, frequency: 35, ..rigid }; + assert_eq!(encode(pulse)[9], 35); + let r = report([rigid, pulse]); + assert_eq!(r[0], 12); + assert!(r[1..10].iter().all(|v| *v == 0)); + assert_eq!(r[10], 0x26); + assert_eq!(r[21], 0x21); + assert!(r[32..].iter().all(|v| *v == 0)); + assert_eq!(encode(TriggerEffect::default())[0], 5); + } + #[test] + fn click_bounds_and_zero_strength_are_safe() { + let e = TriggerEffect { mode: EffectMode::Click, start: 255, end: 0, strength: 255, ..Default::default() }; + let data = encode(e); + assert_eq!(data[0], 0x25); + assert_eq!(u16::from_le_bytes([data[1], data[2]]), (1 << 7) | (1 << 8)); + assert_eq!(encode(TriggerEffect { strength: 0, ..e })[0], 5); + } +} diff --git a/crates/open-controller-ui/src/demo.rs b/crates/open-controller-ui/src/demo.rs index f31ed38..abda83a 100644 --- a/crates/open-controller-ui/src/demo.rs +++ b/crates/open-controller-ui/src/demo.rs @@ -19,6 +19,7 @@ fn input(buttons: &[u32], axes: [i16; axis::COUNT]) -> PadState { pub(crate) fn pad(key: PadKey, name: &str, brand: Brand, family: Family, (vendor, product): (u16, u16)) -> PadView { PadView { microphone_muted: None, + lab_status: Default::default(), key, name: name.into(), brand, diff --git a/crates/open-controller-ui/src/detail.rs b/crates/open-controller-ui/src/detail.rs index 0947ee9..23c46bd 100644 --- a/crates/open-controller-ui/src/detail.rs +++ b/crates/open-controller-ui/src/detail.rs @@ -352,6 +352,9 @@ impl MainView { sections.push((Section::Light, glyph::LIGHT, text.nav_light)); } sections.push((Section::Info, glyph::INFO, text.nav_info)); + if pad.store.is_some() { + sections.insert(sections.len() - 1, (Section::Lab, glyph::SETTINGS, text.lab.nav)); + } let current = if sections.iter().any(|s| s.0 == self.section) { self.section } else { Section::Buttons }; let mut rail = div().flex().flex_col().flex_none().w(px(196.)).gap(px(4.)); for (i, (s, g, label)) in sections.into_iter().enumerate() { @@ -376,6 +379,7 @@ impl MainView { } let content = match current { + Section::Lab => self.lab_section(pad, text, cx), Section::Buttons => self.buttons_section(pad, text, window, cx), Section::Light => self.light_section(pad, text, cx), Section::Motion => self.motion_section(pad, text, cx), diff --git a/crates/open-controller-ui/src/lab.rs b/crates/open-controller-ui/src/lab.rs new file mode 100644 index 0000000..00f766f --- /dev/null +++ b/crates/open-controller-ui/src/lab.rs @@ -0,0 +1,248 @@ +//! Controller Lab: explicit opt-ins and per-profile settings for desktop/motion/trigger tools. +use crate::text_field::{TextField, TextFieldEvent}; +use crate::theme::Theme; +use crate::ui::{MainView, Screen, Section}; +use crate::widgets::{self, Kind, button, caption, card, strong}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, AppContext, Context, Div, Entity, IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, + Subscription, div, px, +}; +use open_controller_core::extras::Family; +use open_controller_core::i18n::Text; +use open_controller_core::ipc::ToTray; +use open_controller_core::lab::{Config, EffectMode}; +use open_controller_core::profile::Edit; +use open_controller_core::{PadKey, PadView}; + +pub struct Number { + key: (PadKey, usize), + range: (u16, u16), + last: u16, + field: Entity, + _events: Subscription, +} +fn panel(t: &Theme) -> Div { + card(t).p(px(16.)).flex().flex_col().gap(px(14.)) +} +#[allow(clippy::too_many_arguments)] +fn choices( + id: &'static str, + labels: &[&'static str], + values: &[T], + picked: T, + key: PadKey, + t: &Theme, + set: impl Fn(&mut Config, T) + Copy + 'static, + cx: &mut Context, +) -> Div { + widgets::segmented( + id, + values.iter().zip(labels).map(|(&v, &label)| (v, SharedString::from(label))).collect(), + picked, + t, + cx, + move |this, value, cx| this.lab_edit(key, |cfg| set(cfg, value), cx), + ) +} + +impl MainView { + fn lab_edit(&mut self, key: PadKey, edit: impl FnOnce(&mut Config), cx: &mut Context) { + if let Some(pad) = self.pad(key, cx) { + let mut cfg = pad.profiles.active().lab; + edit(&mut cfg); + self.edit_now(key, Edit::Lab(cfg.clean()), cx); + } + } + #[allow(clippy::too_many_arguments)] + fn lab_number( + &mut self, + id: &'static str, + label: &'static str, + value: u16, + range: (u16, u16), + step: u16, + pad: &PadView, + set: impl Fn(&mut Config, u16) + Copy + 'static, + cx: &mut Context, + ) -> Div { + let t = self.theme; + let key = pad.key; + let profile = pad.profiles.active; + let tab = self.lab_tab; + let range = (range.0.min(range.1), range.1); + if self.lab_numbers.get(id).is_none_or(|n| n.key != (key, profile) || n.range != range) { + let field = cx.new(|cx| TextField::new(cx, false, 5).placeholder(format!("{}–{}", range.0, range.1))); + let events = cx.subscribe(&field, move |this, entity, event, cx| { + if this.section != Section::Lab + || this.lab_tab != tab + || !matches!(this.screen, Screen::Device(k, _) if k == key) + || !this.pad(key, cx).is_some_and(|p| p.profiles.active == profile) + { + return; + } + if matches!(event, TextFieldEvent::Submit) { + let fallback = this.lab_numbers.get(id).map_or(value, |n| n.last); + let accepted = entity.read(cx).text().trim().parse::().unwrap_or(fallback).clamp(range.0, range.1); + this.lab_edit(key, |cfg| set(cfg, accepted), cx); + entity.update(cx, |field, cx| field.set_text(&accepted.to_string(), cx)); + return; + } + if let Ok(value) = entity.read(cx).text().trim().parse::() + && (range.0..=range.1).contains(&value) + { + this.lab_edit(key, |cfg| set(cfg, value), cx); + } + }); + field.update(cx, |field, cx| field.set_text(&value.to_string(), cx)); + self.lab_numbers.insert(id, Number { key: (key, profile), range, last: value, field, _events: events }); + } + let number = self.lab_numbers.get_mut(id).unwrap(); + if number.last != value { + number.last = value; + number.field.update(cx, |field, cx| field.set_text(&value.to_string(), cx)); + } + let field = number.field.clone(); + div().flex().flex_col().gap(px(6.)).child(caption(label, t.text2)).child( + div() + .flex() + .items_center() + .gap(px(8.)) + .child(button((id, 0usize), "−", Kind::Standard, &t).on_click( + cx.listener(move |this, _, _, cx| this.lab_edit(key, |cfg| set(cfg, value.saturating_sub(step).max(range.0)), cx)), + )) + .child(div().w(px(96.)).child(field)) + .child(button((id, 1usize), "+", Kind::Standard, &t).on_click( + cx.listener(move |this, _, _, cx| this.lab_edit(key, |cfg| set(cfg, value.saturating_add(step).min(range.1)), cx)), + )), + ) + } + + pub fn lab_section(&mut self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + let t = self.theme; + let l = text.lab; + let tabs = widgets::segmented( + "lab-tabs", + l.tabs.iter().take(1).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), + self.lab_tab, + &t, + cx, + |this, tab, cx| { + this.lab_tab = tab; + cx.notify(); + }, + ); + let content = self.lab_effects(pad, text, cx); + div().flex().flex_col().gap(px(16.)).child(caption(l.intro, t.text2)).child(tabs).child(content).into_any_element() + } + + fn lab_effects(&mut self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + let t = self.theme; + let l = text.lab; + let cfg = pad.profiles.active().lab; + let key = pad.key; + if !matches!(pad.family, Family::DualSense | Family::DualSenseEdge) { + return panel(&t).child(caption(l.unsupported, t.text2)).into_any_element(); + } + let mut cards = div().flex().flex_wrap().gap(px(12.)); + const IDS: [[&str; 5]; 2] = [ + ["lab-l-effect", "lab-l-start", "lab-l-end", "lab-l-force", "lab-l-freq"], + ["lab-r-effect", "lab-r-start", "lab-r-end", "lab-r-force", "lab-r-freq"], + ]; + for (side, ids) in IDS.iter().enumerate() { + let e = cfg.adaptive[side]; + let modes = choices( + ids[0], + &l.effects, + &[EffectMode::Off, EffectMode::Resistance, EffectMode::Vibration, EffectMode::Click], + e.mode, + key, + &t, + move |c, mode| c.adaptive[side].mode = mode, + cx, + ); + let start = self.lab_number( + ids[1], + l.start, + u16::from(e.start), + (if e.mode == EffectMode::Click { 2 } else { 0 }, if e.mode == EffectMode::Click { 7 } else { 9 }), + 1, + pad, + move |c, v| c.adaptive[side].start = v as u8, + cx, + ); + let end = self.lab_number( + ids[2], + l.end, + u16::from(e.end), + (u16::from(e.start) + 1, 8), + 1, + pad, + move |c, v| c.adaptive[side].end = v as u8, + cx, + ); + let force = self.lab_number( + ids[3], + l.strength, + u16::from(e.strength), + (0, 8), + 1, + pad, + move |c, v| c.adaptive[side].strength = v as u8, + cx, + ); + let frequency = self.lab_number( + ids[4], + l.frequency, + u16::from(e.frequency), + (1, 100), + 1, + pad, + move |c, v| c.adaptive[side].frequency = v as u8, + cx, + ); + cards = cards.child( + panel(&t).flex_1().min_w(px(270.)).child(strong(if side == 0 { l.left } else { l.right }, t.text)).child(modes).when( + e.mode != EffectMode::Off, + |d| { + d.child(start) + .when(e.mode == EffectMode::Click, |d| d.child(end)) + .child(force) + .when(e.mode == EffectMode::Vibration, |d| d.child(frequency)) + }, + ), + ); + } + div() + .flex() + .flex_col() + .gap(px(12.)) + .child(caption(l.effect_note, t.text2)) + .child( + div() + .flex() + .flex_wrap() + .gap(px(8.)) + .child( + button("lab-try", l.test, Kind::Primary, &t) + .on_click(cx.listener(move |this, _, _, cx| this.send(ToTray::TriggerPreview(key, Some(cfg.adaptive)), cx))), + ) + .child(button("lab-stop", l.stop, Kind::Standard, &t).on_click(cx.listener(move |this, _, _, cx| { + this.lab_edit(key, |c| c.adaptive_enabled = false, cx); + this.send(ToTray::TriggerPreview(key, None), cx); + }))), + ) + .when(pad.lab_status.testing, |d| d.child(caption(l.testing, t.accent))) + .when_some(pad.lab_status.trigger_error.clone(), |d, error| d.child(caption(error, t.caution))) + .child(self.toggle( + "lab-effect-persist", + (l.persist, ""), + cfg.adaptive_enabled, + Edit::Lab(Config { adaptive_enabled: !cfg.adaptive_enabled, ..cfg }), + key, + cx, + )) + .child(cards) + .into_any_element() + } +} diff --git a/crates/open-controller-ui/src/main.rs b/crates/open-controller-ui/src/main.rs index 8cb8852..cd59e68 100644 --- a/crates/open-controller-ui/src/main.rs +++ b/crates/open-controller-ui/src/main.rs @@ -11,6 +11,7 @@ mod frame; mod home; mod icons; mod keys; +mod lab; mod learn; mod programs; mod report; diff --git a/crates/open-controller-ui/src/ui.rs b/crates/open-controller-ui/src/ui.rs index 80e7021..8eb5de5 100644 --- a/crates/open-controller-ui/src/ui.rs +++ b/crates/open-controller-ui/src/ui.rs @@ -41,6 +41,7 @@ pub enum Screen { #[derive(Clone, Copy, PartialEq, Eq)] pub enum Section { + Lab, Buttons, Motion, Sticks, @@ -159,6 +160,8 @@ pub struct MainView { pub moving: bool, pub color_input: Option, pub touch_animation: crate::touch_animation::Animation, + pub lab_tab: u8, + pub lab_numbers: HashMap<&'static str, crate::lab::Number>, _subscriptions: Vec, } @@ -217,6 +220,8 @@ impl MainView { moving: false, color_input: None, touch_animation: crate::touch_animation::Animation::default(), + lab_tab: 0, + lab_numbers: HashMap::new(), _subscriptions: vec![observe, theme_change, activation], }; view.read_installed(cx); @@ -275,6 +280,8 @@ impl MainView { } fn reset_page(&mut self) { + self.lab_tab = 0; + self.lab_numbers.clear(); self.editor = None; self.confirm_reset = false; self.profile_menu = false; @@ -455,8 +462,11 @@ impl MainView { let pressed = held & !self.held; self.held = held; let listening = self.editor.as_ref().is_some_and(|e| e.recording); - if pressed != 0 && !listening && self.renaming.is_none() && self.measuring.is_none() { - let button = pressed.trailing_zeros() as u8; + if let Some(button) = followed_extra(self.section, pad.profile().lab.touch.mode, pressed) + && !listening + && self.renaming.is_none() + && self.measuring.is_none() + { self.section = Section::Buttons; if self.editor.as_ref().is_none_or(|e| e.button != button) { self.edit(pad, button, cx); @@ -608,8 +618,16 @@ impl MainView { } } -/// The extra buttons held on a controller, as bits. A button past the 64 bits, which a resident -/// process of another version could send, is left out. +/// A newly pressed extra button can select its editor, unless a tool owns the gesture. +fn followed_extra(section: Section, _touch: open_controller_core::lab::TouchMode, pressed: u64) -> Option { + // Tools have their own live input. A touchpad mouse gesture must not open the mapper. + if section == Section::Lab { + return None; + } + (pressed != 0).then(|| pressed.trailing_zeros() as u8) +} + +/// The extra buttons held on a controller, excluding indexes outside its 64-bit state. fn extras_held(pad: &PadView) -> u64 { let mask = pad.extras.iter().fold(0u64, |m, &b| m | 1u64.checked_shl(u32::from(b)).unwrap_or(0)); pad.input.buttons & mask @@ -678,6 +696,16 @@ mod tests { use open_controller_core::binding::{Chord, XboxButton}; use std::time::Duration; + #[test] + fn lab_and_touch_mouse_do_not_automatically_open_the_button_mapper() { + use open_controller_core::extras::{RIGHT_PADDLE1, TOUCH_LEFT, TOUCH_RIGHT, TOUCH_TWO, TOUCHPAD}; + use open_controller_core::lab::TouchMode; + for button in [TOUCHPAD, TOUCH_LEFT, TOUCH_RIGHT, TOUCH_TWO, RIGHT_PADDLE1] { + assert_eq!(followed_extra(Section::Lab, TouchMode::Off, 1u64 << button), None); + } + assert_eq!(followed_extra(Section::Info, TouchMode::Off, 1u64 << TOUCHPAD), Some(TOUCHPAD)); + } + fn editor() -> Editor { Editor { button: 16, diff --git a/crates/open-controller/src/main.rs b/crates/open-controller/src/main.rs index ababae5..ace8cb3 100644 --- a/crates/open-controller/src/main.rs +++ b/crates/open-controller/src/main.rs @@ -250,6 +250,7 @@ impl Control { } ToTray::Focused(on) => return self.engine.send(Command::Focused(on)), ToTray::Identify(key) => return self.engine.send(Command::Identify(key)), + ToTray::TriggerPreview(key, effects) => return self.engine.send(Command::TriggerPreview(key, effects)), ToTray::PowerOff(key) => return self.engine.send(Command::PowerOff(key)), ToTray::SwapPlayers(a, b) => return self.engine.send(Command::SwapPlayers(a, b)), ToTray::Edit { store, edit } => { From 6ad0d3c850233c293523d8c4e3011249de57a326 Mon Sep 17 00:00:00 2001 From: slkass <78346291+slkass@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:20:55 +0800 Subject: [PATCH 4/6] Add opt-in touchpad mouse, trackball and gesture navigation guards --- README.md | 5 + crates/open-controller-core/src/desktop.rs | 88 +++++++++++ crates/open-controller-core/src/engine.rs | 20 ++- crates/open-controller-core/src/lab.rs | 165 ++++++++++++++++++++- crates/open-controller-core/src/lib.rs | 1 + crates/open-controller-ui/src/lab.rs | 71 ++++++++- crates/open-controller-ui/src/ui.rs | 14 +- 7 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 crates/open-controller-core/src/desktop.rs diff --git a/README.md b/README.md index 6de3bc2..8459d4d 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,11 @@ The controller's Lab page has optional tools, saved with each profile and off by them immediately or opt into persistent profile effects. A trial restores the profile after its timeout; disconnect and normal exit clear effects owned by OpenController. Output goes through SDL, including USB/Bluetooth framing and checksums. +- **Touchpad mouse (Windows):** relative movement, tap-to-click, physical left/right clicks, + two-finger scrolling and a trackball mode with adjustable inertia decay. This is shared + desktop input; disable it in the Lab when a game should handle the touchpad itself. + +Desktop mouse output uses a worker and a watchdog to release abandoned clicks. Use matching resident/UI binaries (IPC protocol 2). Trials restore the saved profile settings. diff --git a/crates/open-controller-core/src/desktop.rs b/crates/open-controller-core/src/desktop.rs new file mode 100644 index 0000000..9f557d2 --- /dev/null +++ b/crates/open-controller-core/src/desktop.rs @@ -0,0 +1,88 @@ +//! Desktop mouse output is isolated from the input loop. A watchdog releases abandoned clicks. +use crate::lab::Mouse; + +pub struct Desktop { + #[cfg(windows)] + tx: crossbeam_channel::Sender<(u32, Mouse)>, +} +impl Desktop { + pub fn start() -> Self { + #[cfg(windows)] + { + let (tx, rx) = crossbeam_channel::bounded(256); + let _ = std::thread::Builder::new().name("open-controller-mouse".into()).spawn(move || worker(rx)); + Self { tx } + } + #[cfg(not(windows))] + Self {} + } + pub fn send(&self, id: u32, mouse: Mouse) { + #[cfg(windows)] + let _ = self.tx.try_send((id, mouse)); + #[cfg(not(windows))] + let _ = (id, mouse); + } +} + +#[cfg(windows)] +fn worker(rx: crossbeam_channel::Receiver<(u32, Mouse)>) { + use std::collections::HashMap; + use std::time::{Duration, Instant}; + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, + MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, SendInput, + }; + let emit = |dx: i32, dy: i32, data: u32, flags: u32| { + let input = INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { mi: MOUSEINPUT { dx, dy, mouseData: data, dwFlags: flags, time: 0, dwExtraInfo: 0 } }, + }; + unsafe { SendInput(1, &input, size_of::() as i32) }; + }; + let mut owners: HashMap = HashMap::new(); + let mut held = 0u8; + let mut residual = (0f32, 0f32, 0f32); + loop { + let mut tap = false; + let mut disconnected = false; + match rx.recv_timeout(Duration::from_millis(20)) { + Ok((id, m)) => { + owners.insert(id, (m.buttons & 3, Instant::now())); + residual.0 += m.dx.clamp(-20000., 20000.); + residual.1 += m.dy.clamp(-20000., 20000.); + residual.2 += m.wheel.clamp(-2400., 2400.); + tap = m.tap; + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + owners.clear(); + disconnected = true; + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + } + owners.retain(|_, (_, at)| at.elapsed() < Duration::from_millis(250)); + let buttons = owners.values().fold(0, |bits, (b, _)| bits | b); + for (mask, down, up) in [(1, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), (2, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP)] { + if (held ^ buttons) & mask != 0 { + emit(0, 0, 0, if buttons & mask != 0 { down } else { up }); + } + } + held = buttons; + let (dx, dy, wheel) = (residual.0.trunc() as i32, residual.1.trunc() as i32, residual.2.trunc() as i32); + residual.0 -= dx as f32; + residual.1 -= dy as f32; + residual.2 -= wheel as f32; + if dx != 0 || dy != 0 { + emit(dx, dy, 0, MOUSEEVENTF_MOVE); + } + if wheel != 0 { + emit(0, 0, wheel as u32, MOUSEEVENTF_WHEEL); + } + if tap && held & 1 == 0 { + emit(0, 0, 0, MOUSEEVENTF_LEFTDOWN); + emit(0, 0, 0, MOUSEEVENTF_LEFTUP); + } + if disconnected { + break; + } + } +} diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index 2b8028d..c3ce142 100644 --- a/crates/open-controller-core/src/engine.rs +++ b/crates/open-controller-core/src/engine.rs @@ -442,6 +442,7 @@ struct Tools { trial: Option<(Instant, [crate::lab::TriggerEffect; 2])>, last_effect: Option<([crate::lab::TriggerEffect; 2], Instant)>, error: Option, + mouse_on: bool, } impl Tools { fn wanted(&mut self, cfg: crate::lab::Config, owned: bool, now: Instant) -> ([crate::lab::TriggerEffect; 2], bool) { @@ -457,7 +458,7 @@ impl Tools { (effects, expired) } fn new(_now: Instant) -> Self { - Self { runtime: Default::default(), trial: None, last_effect: None, error: None } + Self { runtime: Default::default(), trial: None, last_effect: None, error: None, mouse_on: false } } } @@ -572,6 +573,7 @@ impl Target { } struct Core { + desktop: crate::desktop::Desktop, microphone: crate::microphone::Monitor, microphone_seen: Option, bus: Option>, @@ -671,6 +673,7 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang HANDHELD_STOP.store(false, Ordering::Relaxed); let handheld_reader = handheld::start(handheld_tx, &HANDHELD_STOP); let mut core = Core { + desktop: crate::desktop::Desktop::start(), microphone: crate::microphone::Monitor::start(), microphone_seen: None, bus: None, @@ -1565,6 +1568,7 @@ impl Core { self.gone_halves.push((p.pad.pad_type() == device::PadType::JoyConRight, p.path.clone(), now)); } self.updated.retain(|&d| d != id); + self.desktop.send(id, Default::default()); if p.tools.last_effect.is_some() { let _ = p.pad.trigger_effect(Default::default()); } @@ -1904,9 +1908,18 @@ impl Core { if !p.tools.runtime.active { p.tools.runtime = Default::default(); } - let _mouse = p.tools.runtime.update(p.state, cfg, mapped, p.rate, now); + let mouse = p.tools.runtime.update(p.state, cfg, mapped, p.rate, now); p.tools.runtime.active = true; + let mouse_on = cfg!(windows) && cfg.touch.mode != crate::lab::TouchMode::Off; + if mouse_on || p.tools.mouse_on { + self.desktop.send(id, mouse); + } + p.tools.mouse_on = mouse_on; } else { + if p.tools.mouse_on { + self.desktop.send(id, Default::default()); + p.tools.mouse_on = false; + } p.tools.runtime.active = false; p.tools.trial = None; } @@ -2402,7 +2415,8 @@ impl Core { } fn shutdown(&mut self) { - for p in self.phys.values() { + for (&id, p) in &self.phys { + self.desktop.send(id, Default::default()); if p.tools.last_effect.is_some() { let _ = p.pad.trigger_effect(Default::default()); } diff --git a/crates/open-controller-core/src/lab.rs b/crates/open-controller-core/src/lab.rs index 3d07252..2cf417c 100644 --- a/crates/open-controller-core/src/lab.rs +++ b/crates/open-controller-core/src/lab.rs @@ -1,7 +1,7 @@ //! Optional controller tools. All defaults preserve direct gamepad input. use crate::mapping::{PadState, button}; use serde::{Deserialize, Serialize}; -use std::time::Instant; +use std::time::{Duration, Instant}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum TouchMode { @@ -182,21 +182,178 @@ pub struct Mouse { pub tap: bool, } +#[derive(Default)] +struct TouchState { + finger: Option, + last: Option<(f32, f32)>, + velocity: (f32, f32), + moved: Option, + start: Option, + travel: f32, + clicked: bool, + click_button: u8, + last_scroll: Option, +} +impl TouchState { + fn update(&mut self, pad: &PadState, cfg: TouchMouse, now: Instant, dt: f32) -> Mouse { + if cfg.mode == TouchMode::Off { + *self = Self::default(); + return Mouse::default(); + } + let mut out = Mouse::default(); + let selected = self.finger.filter(|&i| pad.touches[i].down).or_else(|| pad.touches.iter().position(|t| t.down)); + let switched = self.finger.is_some() && selected.is_some() && self.finger != selected; + if switched { + self.last = None; + self.velocity = (0., 0.); + } + self.finger = selected; + let finger = selected.map(|i| pad.touches[i]).unwrap_or_default(); + let two = pad.touches.iter().all(|t| t.down); + let current = finger.down.then_some((f32::from(finger.x) / 65535., f32::from(finger.y) / 65535.)); + let clicked = pad.pressed(button::TOUCHPAD); + if clicked && self.click_button == 0 { + self.click_button = if two { 2 } else { 1 }; + self.clicked = true; + } + if !clicked { + self.click_button = 0; + } + out.buttons = self.click_button; + if two && cfg.scroll { + let y = pad.touches.iter().map(|t| f32::from(t.y) / 65535.).sum::() / 2.; + if let Some(before) = self.last_scroll { + out.wheel = (before - y) * 1200. * f32::from(cfg.sensitivity) / 100.; + } + self.last_scroll = Some(y); + self.last = current; + self.velocity = (0., 0.); + self.clicked = true; + return out; + } + self.last_scroll = None; + match (self.last, current) { + (None, Some(_)) => { + self.start = Some(now); + self.travel = 0.; + self.clicked = clicked || switched; + self.velocity = (0., 0.); + self.moved = Some(now); + } + (Some(before), Some(at)) => { + let delta = (at.0 - before.0, at.1 - before.1); + self.travel += delta.0.hypot(delta.1); + let scale = 1900. * f32::from(cfg.sensitivity) / 100.; + (out.dx, out.dy) = (delta.0 * scale, delta.1 * scale); + if delta.0.hypot(delta.1) > 0.0001 && dt > 0. { + let sample_dt = self.moved.map_or(dt, |at| now.duration_since(at).as_secs_f32().clamp(0.001, 0.05)); + self.velocity = ((out.dx / sample_dt).clamp(-12000., 12000.), (out.dy / sample_dt).clamp(-12000., 12000.)); + self.moved = Some(now); + } + } + (Some(_), None) => { + out.tap = cfg.tap + && !self.clicked + && self.travel < 0.015 + && self.start.is_some_and(|t| now.duration_since(t) <= Duration::from_millis(180)); + if !self.moved.is_some_and(|t| now.duration_since(t) < Duration::from_millis(40)) { + self.velocity = (0., 0.); + } + self.start = None; + } + _ => {} + } + if current.is_none() && cfg.mode == TouchMode::Trackball { + let decay = (-dt * 1000. / f32::from(cfg.friction_ms)).exp(); + let integral = f32::from(cfg.friction_ms) / 1000. * (1. - decay); + out.dx += self.velocity.0 * integral; + out.dy += self.velocity.1 * integral; + self.velocity.0 *= decay; + self.velocity.1 *= decay; + if self.velocity.0.hypot(self.velocity.1) < 1. { + self.velocity = (0., 0.); + } + } + self.last = current; + out + } +} + #[derive(Default)] pub struct Runtime { pub output: PadState, pub active: bool, + config: Config, + last: Option, + touch: TouchState, } impl Runtime { - pub fn update(&mut self, raw: PadState, _cfg: Config, _mapped: bool, _rate: Option<[f32; 3]>, _now: Instant) -> Mouse { - self.output = raw; - Mouse::default() + pub fn update(&mut self, raw: PadState, cfg: Config, _mapped: bool, _rate: Option<[f32; 3]>, now: Instant) -> Mouse { + let cfg = cfg.clean(); + if self.config.touch != cfg.touch { + self.touch = TouchState::default(); + } + self.config = cfg; + let dt = self.last.map_or(0.004, |last| now.duration_since(last).as_secs_f32().min(0.05)); + self.last = Some(now); + let output = raw; + let mouse = self.touch.update(&raw, cfg.touch, now, dt); + self.output = output; + mouse } } #[cfg(test)] mod tests { use super::*; + use crate::mapping::Touch; + + #[test] + fn touch_mouse_taps_scrolls_and_hands_off_fingers_without_a_jump() { + let now = Instant::now(); + let cfg = TouchMouse { mode: TouchMode::Mouse, ..Default::default() }; + let mut touch = TouchState::default(); + let mut p = PadState::default(); + p.touches[0] = Touch::from_position(true, 0.2, 0.3); + touch.update(&p, cfg, now, 0.004); + p.touches[0] = Touch::default(); + assert!(touch.update(&p, cfg, now + Duration::from_millis(70), 0.004).tap); + p.touches = [Touch::from_position(true, 0.2, 0.5), Touch::from_position(true, 0.8, 0.5)]; + touch.update(&p, cfg, now + Duration::from_millis(80), 0.004); + p.touches[0].y = 20000; + p.touches[1].y = 20000; + let scroll = touch.update(&p, cfg, now + Duration::from_millis(90), 0.01); + assert!(scroll.wheel > 0.); + assert_eq!((scroll.dx, scroll.dy), (0., 0.)); + p.touches[0] = Touch::default(); + let handoff = touch.update(&p, cfg, now + Duration::from_millis(100), 0.01); + assert_eq!((handoff.dx, handoff.dy), (0., 0.)); + p.touches[1].x = 60000; + assert!(touch.update(&p, cfg, now + Duration::from_millis(110), 0.01).dx > 0.); + p.touches[1] = Touch::default(); + assert!(!touch.update(&p, cfg, now + Duration::from_millis(120), 0.01).tap); + } + + #[test] + fn trackball_coasts_then_stops_and_retouched_fingers_cancel_inertia() { + let now = Instant::now(); + let cfg = TouchMouse { mode: TouchMode::Trackball, ..Default::default() }; + let mut touch = TouchState::default(); + let mut p = PadState::default(); + p.touches[0] = Touch::from_position(true, 0.2, 0.5); + touch.update(&p, cfg, now, 0.004); + p.touches[0].x = 20000; + touch.update(&p, cfg, now + Duration::from_millis(20), 0.02); + p.touches[0] = Touch::default(); + let first = touch.update(&p, cfg, now + Duration::from_millis(24), 0.004); + let later = touch.update(&p, cfg, now + Duration::from_millis(40), 0.004); + assert!(first.dx > 0. && later.dx > 0. && later.dx < first.dx); + p.touches[0] = Touch::from_position(true, 0.8, 0.5); + let again = touch.update(&p, cfg, now + Duration::from_millis(44), 0.004); + assert_eq!((again.dx, again.dy), (0., 0.)); + p.touches[0] = Touch::default(); + assert_eq!(touch.update(&p, cfg, now + Duration::from_millis(100), 0.004).dx, 0.); + } #[test] fn lab_settings_survive_profiles_and_old_profiles_keep_every_tool_off() { diff --git a/crates/open-controller-core/src/lib.rs b/crates/open-controller-core/src/lib.rs index e47d1d3..d0a293d 100644 --- a/crates/open-controller-core/src/lib.rs +++ b/crates/open-controller-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod binding; pub mod crash; +mod desktop; pub mod device; pub mod ds3; pub mod ds4; diff --git a/crates/open-controller-ui/src/lab.rs b/crates/open-controller-ui/src/lab.rs index 00f766f..d211279 100644 --- a/crates/open-controller-ui/src/lab.rs +++ b/crates/open-controller-ui/src/lab.rs @@ -11,7 +11,7 @@ use gpui::{ use open_controller_core::extras::Family; use open_controller_core::i18n::Text; use open_controller_core::ipc::ToTray; -use open_controller_core::lab::{Config, EffectMode}; +use open_controller_core::lab::{Config, EffectMode, TouchMode}; use open_controller_core::profile::Edit; use open_controller_core::{PadKey, PadView}; @@ -121,9 +121,11 @@ impl MainView { pub fn lab_section(&mut self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { let t = self.theme; let l = text.lab; + let key = pad.key; + let cfg = pad.profiles.active().lab; let tabs = widgets::segmented( "lab-tabs", - l.tabs.iter().take(1).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), + l.tabs.iter().take(2).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), self.lab_tab, &t, cx, @@ -132,7 +134,70 @@ impl MainView { cx.notify(); }, ); - let content = self.lab_effects(pad, text, cx); + let content = match self.lab_tab { + 0 => self.lab_effects(pad, text, cx), + 1 => { + if !cfg!(windows) { + return panel(&t).child(caption(l.windows, t.text2)).into_any_element(); + } + let mode = choices( + "lab-touch-mode", + &l.touch_modes, + &[TouchMode::Off, TouchMode::Mouse, TouchMode::Trackball], + cfg.touch.mode, + key, + &t, + |c, mode| c.touch.mode = mode, + cx, + ); + let sens = self.lab_number( + "lab-touch-sens", + l.sensitivity, + cfg.touch.sensitivity, + (10, 400), + 10, + pad, + |c, v| c.touch.sensitivity = v, + cx, + ); + let friction = self.lab_number( + "lab-touch-friction", + l.friction, + cfg.touch.friction_ms, + (40, 1000), + 20, + pad, + |c, v| c.touch.friction_ms = v, + cx, + ); + panel(&t) + .child(caption(l.touch_note, t.text2)) + .child(mode) + .child(sens) + .when(cfg.touch.mode == TouchMode::Trackball, |d| d.child(friction)) + .child(self.toggle( + "lab-touch-tap", + (l.tap, ""), + cfg.touch.tap, + Edit::Lab(Config { touch: open_controller_core::lab::TouchMouse { tap: !cfg.touch.tap, ..cfg.touch }, ..cfg }), + key, + cx, + )) + .child(self.toggle( + "lab-touch-scroll", + (l.scroll, ""), + cfg.touch.scroll, + Edit::Lab(Config { + touch: open_controller_core::lab::TouchMouse { scroll: !cfg.touch.scroll, ..cfg.touch }, + ..cfg + }), + key, + cx, + )) + .into_any_element() + } + _ => self.lab_effects(pad, text, cx), + }; div().flex().flex_col().gap(px(16.)).child(caption(l.intro, t.text2)).child(tabs).child(content).into_any_element() } diff --git a/crates/open-controller-ui/src/ui.rs b/crates/open-controller-ui/src/ui.rs index 8eb5de5..de7d55c 100644 --- a/crates/open-controller-ui/src/ui.rs +++ b/crates/open-controller-ui/src/ui.rs @@ -619,11 +619,17 @@ impl MainView { } /// A newly pressed extra button can select its editor, unless a tool owns the gesture. -fn followed_extra(section: Section, _touch: open_controller_core::lab::TouchMode, pressed: u64) -> Option { +fn followed_extra(section: Section, touch: open_controller_core::lab::TouchMode, mut pressed: u64) -> Option { // Tools have their own live input. A touchpad mouse gesture must not open the mapper. if section == Section::Lab { return None; } + if touch != open_controller_core::lab::TouchMode::Off { + use open_controller_core::extras::{TOUCH_LEFT, TOUCH_RIGHT, TOUCH_TWO, TOUCHPAD}; + for button in [TOUCHPAD, TOUCH_LEFT, TOUCH_RIGHT, TOUCH_TWO] { + pressed &= !(1u64 << button); + } + } (pressed != 0).then(|| pressed.trailing_zeros() as u8) } @@ -703,6 +709,12 @@ mod tests { for button in [TOUCHPAD, TOUCH_LEFT, TOUCH_RIGHT, TOUCH_TWO, RIGHT_PADDLE1] { assert_eq!(followed_extra(Section::Lab, TouchMode::Off, 1u64 << button), None); } + for mode in [TouchMode::Mouse, TouchMode::Trackball] { + for button in [TOUCHPAD, TOUCH_LEFT, TOUCH_RIGHT, TOUCH_TWO] { + assert_eq!(followed_extra(Section::Buttons, mode, 1u64 << button), None); + } + assert_eq!(followed_extra(Section::Info, mode, (1u64 << TOUCHPAD) | (1u64 << RIGHT_PADDLE1)), Some(RIGHT_PADDLE1)); + } assert_eq!(followed_extra(Section::Info, TouchMode::Off, 1u64 << TOUCHPAD), Some(TOUCHPAD)); } From e1e9bf30992337ad1da40da55ed02404130427fc Mon Sep 17 00:00:00 2001 From: slkass <78346291+slkass@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:21:28 +0800 Subject: [PATCH 5/6] Add Flick Stick turns and optional gyro mouse aiming --- README.md | 10 +- crates/open-controller-core/src/engine.rs | 9 +- crates/open-controller-core/src/lab.rs | 121 +++++++++++++++++++++- crates/open-controller-ui/src/lab.rs | 88 +++++++++++++++- 4 files changed, 216 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8459d4d..477ee60 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,8 @@ the left trigger is pulled (aiming down sights) or while a chosen button is held also work as a switch: one press turns the gyro on, the next turns it off. The game still gets the button. A half turn a second is the stick all the way at 100 % sensitivity; a small deadzone keeps a still hand still, a filter smooths slow movement without delaying fast turns, and the stick -keeps working. Each friend aims with their own controller, which a gyro mapped to the mouse would -not allow. +keeps working. Each friend aims with their own controller; the separate Flick Stick option +instead uses the shared desktop mouse. The aim can be tuned further: a sensitivity of its own while the left trigger is pulled, a slower or faster up and down, and a button that pauses the gyro while held, to put the controller back @@ -307,6 +307,12 @@ The controller's Lab page has optional tools, saved with each profile and off by - **Touchpad mouse (Windows):** relative movement, tap-to-click, physical left/right clicks, two-finger scrolling and a trackball mode with adjustable inertia decay. This is shared desktop input; disable it in the Lab when a game should handle the touchpad itself. +- **Flick Stick (Windows, mapping mode):** the right stick's direction starts a turn, rotation + around its rim continues it, and the gyro can provide fine aim. Counts per full turn, flick + duration, activation threshold and gyro sensitivity are adjustable. Calibrate counts against + the game's mouse sensitivity. The virtual right stick is consumed while this is enabled. + +Native input stays native. Flick Stick and input remapping require mapping mode. Desktop mouse output uses a worker and a watchdog to release abandoned clicks. diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index c3ce142..75e9025 100644 --- a/crates/open-controller-core/src/engine.rs +++ b/crates/open-controller-core/src/engine.rs @@ -1195,7 +1195,10 @@ impl Core { let ds4 = self.output_of(&p.model.store) == Output::DualShock4; let gyro = matches!(p.kind, Kind::Slot(s) if !self.is_native(s)) && p.model.features.motion - && (ds4 || self.profile_of(&p.model.store).is_some_and(|pr| pr.gyro.mode != GyroMode::Off)); + && (ds4 + || cfg!(windows) + && self.profile_of(&p.model.store).is_some_and(|pr| pr.lab.flick.enabled && pr.lab.flick.gyro_mouse) + || self.profile_of(&p.model.store).is_some_and(|pr| pr.gyro.mode != GyroMode::Off)); (gyro, gyro && ds4) }; let p = self.phys.get_mut(&id).unwrap(); @@ -1676,7 +1679,7 @@ impl Core { } let mut r = mapping::to_xusb(&state); // The gyro adds to the right stick, so the stick still turns and the gyro fine-tunes. - if p.aim_out != (0, 0) { + if p.aim_out != (0, 0) && !profile.is_some_and(|pr| cfg!(windows) && pr.lab.flick.enabled) { r.thumb_rx = (i32::from(r.thumb_rx) + i32::from(p.aim_out.0)).clamp(-32768, 32767) as i16; r.thumb_ry = (i32::from(r.thumb_ry) + i32::from(p.aim_out.1)).clamp(-32768, 32767) as i16; } @@ -1910,7 +1913,7 @@ impl Core { } let mouse = p.tools.runtime.update(p.state, cfg, mapped, p.rate, now); p.tools.runtime.active = true; - let mouse_on = cfg!(windows) && cfg.touch.mode != crate::lab::TouchMode::Off; + let mouse_on = cfg!(windows) && (cfg.touch.mode != crate::lab::TouchMode::Off || mapped && cfg.flick.enabled); if mouse_on || p.tools.mouse_on { self.desktop.send(id, mouse); } diff --git a/crates/open-controller-core/src/lab.rs b/crates/open-controller-core/src/lab.rs index 2cf417c..366d1f7 100644 --- a/crates/open-controller-core/src/lab.rs +++ b/crates/open-controller-core/src/lab.rs @@ -1,6 +1,7 @@ //! Optional controller tools. All defaults preserve direct gamepad input. -use crate::mapping::{PadState, button}; +use crate::mapping::{PadState, axis, button}; use serde::{Deserialize, Serialize}; +use std::f32::consts::{PI, TAU}; use std::time::{Duration, Instant}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -279,6 +280,66 @@ impl TouchState { } } +#[derive(Default)] +struct FlickState { + angle: Option, + remaining: f32, + total: f32, + progress: f32, +} +fn wrap(angle: f32) -> f32 { + (angle + PI).rem_euclid(TAU) - PI +} +impl FlickState { + fn update(&mut self, pad: &PadState, cfg: Flick, rate: Option<[f32; 3]>, dt: f32) -> (f32, f32) { + if !cfg.enabled { + *self = Self::default(); + return (0., 0.); + } + let x = f32::from(pad.axes[axis::RIGHT_X]) / 32768.; + let y = f32::from(pad.axes[axis::RIGHT_Y]) / 32768.; + let mut dx = 0.; + let mut dy = 0.; + if x.hypot(y) >= f32::from(cfg.threshold) / 100. { + let angle = x.atan2(-y); + if let Some(before) = self.angle { + dx += wrap(angle - before) / TAU * f32::from(cfg.counts_per_turn); + } else { + self.total = self.remaining + angle / TAU * f32::from(cfg.counts_per_turn); + self.remaining = self.total; + self.progress = 0.; + } + self.angle = Some(angle); + } else if x.hypot(y) < f32::from(cfg.threshold.saturating_sub(10)) / 100. { + self.angle = None; + } + if self.remaining.abs() > 0.001 { + let before = self.progress; + self.progress = (self.progress + dt * 1000. / f32::from(cfg.duration_ms)).min(1.); + let ease = |t: f32| t * t * (3. - 2. * t); + let step = self.total * (ease(self.progress) - ease(before)); + dx += step; + self.remaining -= step; + if self.progress >= 1. { + self.remaining = 0.; + } + } + if cfg.gyro_mouse + && let Some([pitch, yaw, _]) = rate + && pitch.is_finite() + && yaw.is_finite() + { + let quiet = |rate: f32| { + let degrees = rate.to_degrees(); + if degrees.abs() < 0.05 { 0. } else { degrees } + }; + dx -= quiet(yaw) * dt * f32::from(cfg.gyro_sensitivity); + dy -= quiet(pitch) * dt * f32::from(cfg.gyro_sensitivity); + } + (dx, dy) + } +} + #[derive(Default)] pub struct Runtime { pub output: PadState, @@ -286,18 +347,34 @@ pub struct Runtime { config: Config, last: Option, touch: TouchState, + flick: FlickState, } impl Runtime { - pub fn update(&mut self, raw: PadState, cfg: Config, _mapped: bool, _rate: Option<[f32; 3]>, now: Instant) -> Mouse { + pub fn update(&mut self, raw: PadState, cfg: Config, mapped: bool, rate: Option<[f32; 3]>, now: Instant) -> Mouse { let cfg = cfg.clean(); if self.config.touch != cfg.touch { self.touch = TouchState::default(); } + if self.config.flick != cfg.flick { + self.flick = FlickState::default(); + } self.config = cfg; let dt = self.last.map_or(0.004, |last| now.duration_since(last).as_secs_f32().min(0.05)); self.last = Some(now); - let output = raw; - let mouse = self.touch.update(&raw, cfg.touch, now, dt); + let mut output = raw; + let mut mouse = self.touch.update(&raw, cfg.touch, now, dt); + if mapped { + let flick = if cfg!(windows) { cfg.flick } else { Flick::default() }; + let (dx, dy) = self.flick.update(&raw, flick, rate, dt); + mouse.dx += dx; + mouse.dy += dy; + if flick.enabled { + output.axes[axis::RIGHT_X] = 0; + output.axes[axis::RIGHT_Y] = 0; + } + } else { + self.flick = FlickState::default(); + } self.output = output; mouse } @@ -355,6 +432,32 @@ mod tests { assert_eq!(touch.update(&p, cfg, now + Duration::from_millis(100), 0.004).dx, 0.); } + #[test] + fn rapid_flicks_keep_the_unfinished_turn() { + let mut f = FlickState::default(); + let cfg = Flick { enabled: true, gyro_mouse: false, ..Default::default() }; + let mut p = PadState::default(); + p.axes[axis::RIGHT_X] = 32767; + let mut sum = f.update(&p, cfg, None, 0.004).0; + let rest = PadState::default(); + sum += f.update(&rest, cfg, None, 0.004).0; + for _ in 0..20 { + sum += f.update(&p, cfg, None, 0.004).0; + } + assert!((sum - 1800.).abs() < 0.1); + } + + #[test] + fn gyro_mouse_ignores_sensor_noise_and_invalid_samples() { + let mut f = FlickState::default(); + let cfg = Flick { enabled: true, ..Default::default() }; + let p = PadState::default(); + assert_eq!(f.update(&p, cfg, Some([0.0001, -0.0001, 0.]), 0.01), (0., 0.)); + assert_eq!(f.update(&p, cfg, Some([f32::NAN, 1., 0.]), 0.01), (0., 0.)); + let motion = f.update(&p, cfg, Some([1., 0., 0.]), 0.01); + assert!(motion.1 < -5.); + } + #[test] fn lab_settings_survive_profiles_and_old_profiles_keep_every_tool_off() { let mut profiles = crate::profile::Profiles::default(); @@ -367,6 +470,16 @@ mod tests { assert_eq!(old.lab, Config::default()); } #[test] + fn flick_wraps_at_back_and_finishes_a_quarter_turn() { + assert!((wrap(-PI + 0.01 - (PI - 0.01)) - 0.02).abs() < 0.001); + let mut f = FlickState::default(); + let mut p = PadState::default(); + p.axes[axis::RIGHT_X] = 32767; + let cfg = Flick { enabled: true, gyro_mouse: false, ..Default::default() }; + let sum: f32 = (0..20).map(|_| f.update(&p, cfg, None, 0.004).0).sum(); + assert!((sum - 900.).abs() < 0.1); + } + #[test] fn defaults_leave_input_and_mouse_untouched() { let mut r = Runtime::default(); let mut p = PadState { buttons: 7, ..Default::default() }; diff --git a/crates/open-controller-ui/src/lab.rs b/crates/open-controller-ui/src/lab.rs index d211279..e2c1f34 100644 --- a/crates/open-controller-ui/src/lab.rs +++ b/crates/open-controller-ui/src/lab.rs @@ -13,7 +13,7 @@ use open_controller_core::i18n::Text; use open_controller_core::ipc::ToTray; use open_controller_core::lab::{Config, EffectMode, TouchMode}; use open_controller_core::profile::Edit; -use open_controller_core::{PadKey, PadView}; +use open_controller_core::{PadKey, PadView, Role}; pub struct Number { key: (PadKey, usize), @@ -125,7 +125,7 @@ impl MainView { let cfg = pad.profiles.active().lab; let tabs = widgets::segmented( "lab-tabs", - l.tabs.iter().take(2).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), + l.tabs.iter().take(3).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), self.lab_tab, &t, cx, @@ -134,6 +134,7 @@ impl MainView { cx.notify(); }, ); + let native = matches!(pad.role, Role::KeptNative | Role::Native { .. }); let content = match self.lab_tab { 0 => self.lab_effects(pad, text, cx), 1 => { @@ -196,9 +197,90 @@ impl MainView { )) .into_any_element() } + 2 => { + if !cfg!(windows) { + return panel(&t).child(caption(l.windows, t.text2)).into_any_element(); + } + let counts = self.lab_number( + "lab-flick-counts", + l.counts, + cfg.flick.counts_per_turn, + (100, 60000), + 100, + pad, + |c, v| c.flick.counts_per_turn = v, + cx, + ); + let duration = self.lab_number( + "lab-flick-duration", + l.duration, + cfg.flick.duration_ms, + (20, 300), + 10, + pad, + |c, v| c.flick.duration_ms = v, + cx, + ); + let threshold = self.lab_number( + "lab-flick-threshold", + l.threshold, + u16::from(cfg.flick.threshold), + (50, 99), + 5, + pad, + |c, v| c.flick.threshold = v as u8, + cx, + ); + let sens = self.lab_number( + "lab-flick-gyro-sens", + l.gyro_sens, + cfg.flick.gyro_sensitivity, + (1, 100), + 1, + pad, + |c, v| c.flick.gyro_sensitivity = v, + cx, + ); + panel(&t) + .child(caption(l.flick_note, t.text2)) + .child(self.toggle( + "lab-flick-on", + (l.flick_on, ""), + cfg.flick.enabled, + Edit::Lab(Config { flick: open_controller_core::lab::Flick { enabled: !cfg.flick.enabled, ..cfg.flick }, ..cfg }), + key, + cx, + )) + .child(counts) + .child(duration) + .child(threshold) + .when(pad.features.motion, |d| { + d.child(self.toggle( + "lab-flick-gyro", + (l.gyro, ""), + cfg.flick.gyro_mouse, + Edit::Lab(Config { + flick: open_controller_core::lab::Flick { gyro_mouse: !cfg.flick.gyro_mouse, ..cfg.flick }, + ..cfg + }), + key, + cx, + )) + .child(sens) + }) + .into_any_element() + } _ => self.lab_effects(pad, text, cx), }; - div().flex().flex_col().gap(px(16.)).child(caption(l.intro, t.text2)).child(tabs).child(content).into_any_element() + div() + .flex() + .flex_col() + .gap(px(16.)) + .child(caption(l.intro, t.text2)) + .when(native, |d| d.child(caption(l.native, t.caution))) + .child(tabs) + .child(content) + .into_any_element() } fn lab_effects(&mut self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { From 5c1c798a93c90cba2100edc9afcf160e1eb078ba Mon Sep 17 00:00:00 2001 From: slkass <78346291+slkass@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:22:17 +0800 Subject: [PATCH 6/6] Add Hair Trigger, two-stage trigger mapping and timed Hip Fire --- README.md | 3 + .../examples/windows/loopback.rs | 86 ++++++++++++ crates/open-controller-core/src/lab.rs | 127 ++++++++++++++++++ crates/open-controller-ui/src/lab.rs | 122 ++++++++++++++++- 4 files changed, 336 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 477ee60..e44dc47 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,9 @@ The controller's Lab page has optional tools, saved with each profile and off by around its rim continues it, and the gyro can provide fine aim. Counts per full turn, flick duration, activation threshold and gyro sensitivity are adjustable. Calibrate counts against the game's mouse sensitivity. The virtual right stick is consumed while this is enabled. +- **Trigger mapping (mapping mode):** Hair Trigger uses short pull/release reversals; two-stage + mapping assigns buttons to soft/full pulls; Hip Fire delays the soft action so a quick full + pull can bypass it. The chosen buttons also use their existing keyboard/gamepad assignments. Native input stays native. Flick Stick and input remapping require mapping mode. diff --git a/crates/open-controller-core/examples/windows/loopback.rs b/crates/open-controller-core/examples/windows/loopback.rs index b54b31d..b03058e 100644 --- a/crates/open-controller-core/examples/windows/loopback.rs +++ b/crates/open-controller-core/examples/windows/loopback.rs @@ -91,6 +91,90 @@ fn check(ok: bool, what: &str) { } } +fn lab_inputs(engine: &Engine, joy: *mut SDL_Joystick, slot: u32, original: &Profiles) { + use open_controller_core::lab::{Config as Lab, TriggerInput, TriggerMode}; + use open_controller_core::mapping::button; + let mut profiles = original.clone(); + for b in 0..64 { + profiles.apply(Edit::Bind { button: b, action: None }); + } + let mut cfg = Lab::default(); + cfg.triggers[0] = TriggerInput { mode: TriggerMode::Hair, soft: 5, ..Default::default() }; + profiles.apply(Edit::Lab(cfg)); + engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone())); + wait_for("Hair Trigger profile", Duration::from_secs(1), || { + engine + .snapshot() + .pads + .into_iter() + .find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.triggers[0].mode == TriggerMode::Hair) + }); + let pull = |percent: i32| { + unsafe { SDL_SetJoystickVirtualAxis(joy, SDL_GAMEPAD_AXIS_LEFT_TRIGGER.0, (percent * 65535 / 100 - 32768) as i16) }; + }; + pull(50); + wait_for("hair press", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 255)); + pull(40); + wait_for("hair release by reversal", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 0)); + pull(45); + wait_for("hair rearm before returning to zero", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 255)); + pull(0); + wait_for("hair released", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.bLeftTrigger == 0)); + check(true, "Hair Trigger presses, reverses and rearms through real XInput"); + cfg.triggers[0] = TriggerInput { + mode: TriggerMode::TwoStage, + soft_button: button::WEST as u8, + full_button: button::SOUTH as u8, + ..Default::default() + }; + profiles.apply(Edit::Lab(cfg)); + engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone())); + wait_for("two-stage profile", Duration::from_secs(1), || { + engine + .snapshot() + .pads + .into_iter() + .find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.triggers[0].mode == TriggerMode::TwoStage) + }); + pull(50); + wait_for("soft-pull X", Duration::from_secs(1), || { + xinput(slot).filter(|s| { + s.Gamepad.wButtons & XINPUT_GAMEPAD_X != 0 && s.Gamepad.wButtons & XINPUT_GAMEPAD_A == 0 && s.Gamepad.bLeftTrigger == 0 + }) + }); + pull(100); + wait_for("full-pull A replaces X", Duration::from_secs(1), || { + xinput(slot).filter(|s| s.Gamepad.wButtons & XINPUT_GAMEPAD_A != 0 && s.Gamepad.wButtons & XINPUT_GAMEPAD_X == 0) + }); + pull(0); + wait_for("both stage actions released", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0)); + check(true, "two-stage actions reach XInput independently and release cleanly"); + cfg.triggers[0].mode = TriggerMode::HipFire; + cfg.triggers[0].delay_ms = 100; + profiles.apply(Edit::Lab(cfg)); + engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone())); + wait_for("Hip Fire profile", Duration::from_secs(1), || { + engine + .snapshot() + .pads + .into_iter() + .find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.triggers[0].mode == TriggerMode::HipFire) + }); + let start = Instant::now(); + pull(50); + wait_for("timed soft action without another report", Duration::from_secs(1), || { + xinput(slot).filter(|s| s.Gamepad.wButtons & XINPUT_GAMEPAD_X != 0) + }); + check(start.elapsed() >= Duration::from_millis(90), "Hip Fire waits before its soft action without blocking input"); + pull(0); + wait_for("Hip Fire released", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0)); + engine.send(Command::SetProfiles(PROFILE.into(), original.clone())); + wait_for("original profile restored", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab == Lab::default()) + }); + engine.send(Command::Watch(false)); +} + fn main() { let dir = std::env::temp_dir().join("open-controller-loopback"); let mut profiles = Profiles::default(); @@ -263,6 +347,8 @@ fn main() { check(true, "both touchpad fingers and their independent releases reach the preview"); engine.send(Command::Watch(false)); + lab_inputs(&engine, joy, slot, &profiles); + // A profile for a program: in front, its assignments; away, the chosen profile's again. profiles.apply(Edit::Select(0)); profiles.apply(Edit::AddProgram(1, "loopback-game.exe".into())); diff --git a/crates/open-controller-core/src/lab.rs b/crates/open-controller-core/src/lab.rs index 366d1f7..8b081cb 100644 --- a/crates/open-controller-core/src/lab.rs +++ b/crates/open-controller-core/src/lab.rs @@ -183,6 +183,59 @@ pub struct Mouse { pub tap: bool, } +#[derive(Default)] +struct TriggerState { + hair: bool, + extreme: f32, + soft_since: Option, +} +impl TriggerState { + fn apply(&mut self, state: &mut PadState, side: usize, cfg: TriggerInput, now: Instant) { + let a = if side == 0 { axis::LEFT_TRIGGER } else { axis::RIGHT_TRIGGER }; + let v = f32::from(state.axes[a].max(0)) / 32767. * 100.; + match cfg.mode { + TriggerMode::Normal => *self = Self::default(), + TriggerMode::Hair => { + if v < 1. { + self.hair = false; + self.extreme = 0.; + } else if self.hair { + self.extreme = self.extreme.max(v); + if v <= self.extreme - f32::from(cfg.hysteresis) { + self.hair = false; + self.extreme = v; + } + } else { + self.extreme = self.extreme.min(v); + if v >= f32::from(cfg.soft) && v >= self.extreme + f32::from(cfg.hysteresis) { + self.hair = true; + self.extreme = v; + } + } + state.axes[a] = if self.hair { 32767 } else { 0 }; + } + TriggerMode::TwoStage | TriggerMode::HipFire => { + let soft = v >= f32::from(cfg.soft); + let full = v >= f32::from(cfg.full); + if soft { + self.soft_since.get_or_insert(now); + } else { + self.soft_since = None; + } + let ready = cfg.mode != TriggerMode::HipFire + || self.soft_since.is_some_and(|t| now.duration_since(t) >= Duration::from_millis(u64::from(cfg.delay_ms))); + if soft && ready && !(cfg.exclusive && full) { + state.buttons |= 1u64 << cfg.soft_button; + } + if full { + state.buttons |= 1u64 << cfg.full_button; + } + state.axes[a] = 0; + } + } + } +} + #[derive(Default)] struct TouchState { finger: Option, @@ -346,6 +399,7 @@ pub struct Runtime { pub active: bool, config: Config, last: Option, + triggers: [TriggerState; 2], touch: TouchState, flick: FlickState, } @@ -358,12 +412,18 @@ impl Runtime { if self.config.flick != cfg.flick { self.flick = FlickState::default(); } + if self.config.triggers != cfg.triggers { + self.triggers = Default::default(); + } self.config = cfg; let dt = self.last.map_or(0.004, |last| now.duration_since(last).as_secs_f32().min(0.05)); self.last = Some(now); let mut output = raw; let mut mouse = self.touch.update(&raw, cfg.touch, now, dt); if mapped { + for (side, trigger) in self.triggers.iter_mut().enumerate() { + trigger.apply(&mut output, side, cfg.triggers[side], now); + } let flick = if cfg!(windows) { cfg.flick } else { Flick::default() }; let (dx, dy) = self.flick.update(&raw, flick, rate, dt); mouse.dx += dx; @@ -374,6 +434,7 @@ impl Runtime { } } else { self.flick = FlickState::default(); + self.triggers = Default::default(); } self.output = output; mouse @@ -432,6 +493,60 @@ mod tests { assert_eq!(touch.update(&p, cfg, now + Duration::from_millis(100), 0.004).dx, 0.); } + #[test] + fn two_stage_and_hip_fire_actions_do_not_leave_buttons_held() { + let now = Instant::now(); + let cfg = TriggerInput { mode: TriggerMode::TwoStage, ..Default::default() }; + let mut t = TriggerState::default(); + let sample = |value| { + let mut p = PadState::default(); + p.axes[axis::LEFT_TRIGGER] = value; + p + }; + let mut soft = sample(17000); + t.apply(&mut soft, 0, cfg, now); + assert!(soft.pressed(u32::from(cfg.soft_button))); + assert!(!soft.pressed(u32::from(cfg.full_button))); + assert_eq!(soft.axes[axis::LEFT_TRIGGER], 0); + let mut full = sample(32767); + t.apply(&mut full, 0, cfg, now); + assert!(!full.pressed(u32::from(cfg.soft_button))); + assert!(full.pressed(u32::from(cfg.full_button))); + let mut released = sample(0); + t.apply(&mut released, 0, cfg, now); + assert_eq!(released.buttons, 0); + let mut hip = TriggerState::default(); + let cfg = TriggerInput { mode: TriggerMode::HipFire, ..cfg }; + let mut pending = sample(17000); + hip.apply(&mut pending, 0, cfg, now); + assert_eq!(pending.buttons, 0); + let mut held = sample(17000); + hip.apply(&mut held, 0, cfg, now + Duration::from_millis(110)); + assert!(held.pressed(u32::from(cfg.soft_button))); + let mut quick = TriggerState::default(); + let mut full = sample(32767); + quick.apply(&mut full, 0, cfg, now); + assert!(full.pressed(u32::from(cfg.full_button))); + assert!(!full.pressed(u32::from(cfg.soft_button))); + } + + #[test] + fn native_mode_keeps_input_even_with_mapping_tools_configured() { + let mut r = Runtime::default(); + let mut p = PadState { buttons: 3, ..Default::default() }; + p.axes[axis::LEFT_TRIGGER] = 18000; + p.axes[axis::RIGHT_X] = 32767; + let cfg = Config { + flick: Flick { enabled: true, ..Default::default() }, + debounce_ms: 100, + triggers: [TriggerInput { mode: TriggerMode::TwoStage, ..Default::default() }; 2], + ..Default::default() + }; + let mouse = r.update(p, cfg, false, Some([1.; 3]), Instant::now()); + assert_eq!(r.output, p); + assert_eq!((mouse.dx, mouse.dy), (0., 0.)); + } + #[test] fn rapid_flicks_keep_the_unfinished_turn() { let mut f = FlickState::default(); @@ -470,6 +585,18 @@ mod tests { assert_eq!(old.lab, Config::default()); } #[test] + fn hair_releases_and_rearms_without_returning_to_zero() { + let mut t = TriggerState::default(); + let n = Instant::now(); + let cfg = TriggerInput { mode: TriggerMode::Hair, ..Default::default() }; + let mut state = PadState::default(); + for (v, expected) in [(16000, 32767), (15000, 0), (17000, 32767), (0, 0)] { + state.axes[axis::LEFT_TRIGGER] = v; + t.apply(&mut state, 0, cfg, n); + assert_eq!(state.axes[axis::LEFT_TRIGGER], expected); + } + } + #[test] fn flick_wraps_at_back_and_finishes_a_quarter_turn() { assert!((wrap(-PI + 0.01 - (PI - 0.01)) - 0.02).abs() < 0.001); let mut f = FlickState::default(); diff --git a/crates/open-controller-ui/src/lab.rs b/crates/open-controller-ui/src/lab.rs index e2c1f34..855184d 100644 --- a/crates/open-controller-ui/src/lab.rs +++ b/crates/open-controller-ui/src/lab.rs @@ -11,7 +11,8 @@ use gpui::{ use open_controller_core::extras::Family; use open_controller_core::i18n::Text; use open_controller_core::ipc::ToTray; -use open_controller_core::lab::{Config, EffectMode, TouchMode}; +use open_controller_core::lab::{Config, EffectMode, TouchMode, TriggerMode}; +use open_controller_core::mapping::{axis, button as b}; use open_controller_core::profile::Edit; use open_controller_core::{PadKey, PadView, Role}; @@ -22,6 +23,9 @@ pub struct Number { field: Entity, _events: Subscription, } +fn field(label: &'static str, control: Div, t: &Theme) -> Div { + div().flex().flex_col().gap(px(6.)).child(strong(label, t.text)).child(control) +} fn panel(t: &Theme) -> Div { card(t).p(px(16.)).flex().flex_col().gap(px(14.)) } @@ -125,7 +129,7 @@ impl MainView { let cfg = pad.profiles.active().lab; let tabs = widgets::segmented( "lab-tabs", - l.tabs.iter().take(3).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), + l.tabs.iter().take(4).enumerate().map(|(i, &label)| (i as u8, label.into())).collect(), self.lab_tab, &t, cx, @@ -270,6 +274,13 @@ impl MainView { }) .into_any_element() } + 3 => { + let mut rows = div().flex().flex_wrap().gap(px(12.)); + for side in 0..2 { + rows = rows.child(self.lab_trigger_input(pad, side, text, cx)); + } + div().flex().flex_col().gap(px(12.)).child(caption(l.input_note, t.text2)).child(rows).into_any_element() + } _ => self.lab_effects(pad, text, cx), }; div() @@ -392,4 +403,111 @@ impl MainView { .child(cards) .into_any_element() } + + fn lab_trigger_input(&mut self, pad: &PadView, side: usize, text: &'static Text, cx: &mut Context) -> Div { + let t = self.theme; + let l = text.lab; + let cfg = pad.profiles.active().lab; + let trig = cfg.triggers[side]; + let key = pad.key; + const IDS: [[&str; 8]; 2] = [ + [ + "lab-l-input", + "lab-l-soft", + "lab-l-full", + "lab-l-hyst", + "lab-l-delay", + "lab-l-soft-action", + "lab-l-full-action", + "lab-l-exclusive", + ], + [ + "lab-r-input", + "lab-r-soft", + "lab-r-full", + "lab-r-hyst", + "lab-r-delay", + "lab-r-soft-action", + "lab-r-full-action", + "lab-r-exclusive", + ], + ]; + let ids = IDS[side]; + let mode = choices( + ids[0], + &l.input_modes, + &[TriggerMode::Normal, TriggerMode::Hair, TriggerMode::TwoStage, TriggerMode::HipFire], + trig.mode, + key, + &t, + move |c, mode| { + if c.triggers[side].mode == TriggerMode::Normal { + c.triggers[side].soft_button = if side == 0 { b::LEFT_SHOULDER } else { b::RIGHT_SHOULDER } as u8; + c.triggers[side].full_button = if side == 0 { b::SOUTH } else { b::EAST } as u8; + } + c.triggers[side].mode = mode; + if mode == TriggerMode::Hair { + c.triggers[side].soft = 5; + } + }, + cx, + ); + let soft = self.lab_number(ids[1], l.soft, u16::from(trig.soft), (1, 90), 5, pad, move |c, v| c.triggers[side].soft = v as u8, cx); + let full = self.lab_number( + ids[2], + l.full, + u16::from(trig.full), + (u16::from(trig.soft) + 1, 100), + 5, + pad, + move |c, v| c.triggers[side].full = v as u8, + cx, + ); + let hyst = self.lab_number( + ids[3], + l.hysteresis, + u16::from(trig.hysteresis), + (1, 20), + 1, + pad, + move |c, v| c.triggers[side].hysteresis = v as u8, + cx, + ); + let delay = self.lab_number(ids[4], l.delay, trig.delay_ms, (10, 1000), 10, pad, move |c, v| c.triggers[side].delay_ms = v, cx); + let labels = ["A / ×", "B / ○", "X / □", "Y / △", "LB / L1", "RB / R1", "LS / L3", "RS / R3"]; + let values = [ + b::SOUTH as u8, + b::EAST as u8, + b::WEST as u8, + b::NORTH as u8, + b::LEFT_SHOULDER as u8, + b::RIGHT_SHOULDER as u8, + b::LEFT_STICK as u8, + b::RIGHT_STICK as u8, + ]; + let soft_action = choices(ids[5], &labels, &values, trig.soft_button, key, &t, move |c, v| c.triggers[side].soft_button = v, cx); + let full_action = choices(ids[6], &labels, &values, trig.full_button, key, &t, move |c, v| c.triggers[side].full_button = v, cx); + let mut replacement = cfg; + replacement.triggers[side].exclusive = !trig.exclusive; + let a = if side == 0 { axis::LEFT_TRIGGER } else { axis::RIGHT_TRIGGER }; + let raw = u32::from(pad.input.axes[a].max(0) as u16) * 100 / 32767; + let out = u32::from(pad.lab_status.output.axes[a].max(0) as u16) * 100 / 32767; + panel(&t) + .flex_1() + .min_w(px(270.)) + .child(strong(if side == 0 { l.left } else { l.right }, t.text)) + .child(caption(format!("{}: {raw}% → {}: {out}%", l.raw, l.output), t.accent)) + .child(mode) + .when(trig.mode != TriggerMode::Normal, |d| { + d.child(soft) + .when(trig.mode == TriggerMode::Hair, |d| d.child(hyst)) + .when(matches!(trig.mode, TriggerMode::TwoStage | TriggerMode::HipFire), |d| { + d.child(full) + .child(field(l.soft_action, soft_action, &t)) + .child(field(l.full_action, full_action, &t)) + .child(self.toggle(ids[7], (l.exclusive, ""), trig.exclusive, Edit::Lab(replacement), key, cx)) + }) + .when(trig.mode == TriggerMode::HipFire, |d| d.child(delay)) + }) + } }