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..7676188 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 @@ -279,6 +279,59 @@ 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. + +### Controller Lab + +The controller's Lab page has seven 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. +- **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. +- **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. +- **Button debounce (mapping mode):** require a stable press/release for 0–100 ms before output. + Timers settle without another controller report. Zero preserves the original latency. +- **DSU motion:** a single loopback-only UDP server at `127.0.0.1:26760`, implementing Cemuhook + protocol 1001 for up to four enabled controllers, subscriptions and their expiry, CRC32, + touch, buttons, gyro in degrees/second and acceleration in g. Add a DSU controller in the + emulator and enable motion. Binding failures and client counts are shown in the Lab. +- **Dynamic lighting:** rainbow, breathing, rumble-linked colour and optional idle fade. These + respect the Light page's brightness and native-mode output opt-in, and stop when lights are + turned off. The drawing and touch feedback follow the output colour. + +Native input stays native: only explicitly enabled desktop, sensor-sharing and hardware-effect +tools run there. Flick Stick, trigger remapping and debounce need mapping mode. Use matching +resident/UI binaries (IPC protocol 2). The input loop does not wait on UDP clients or mouse output. + +Protocol references: [SDL's gamepad effect example](https://github.com/libsdl-org/SDL/blob/main/test/testcontroller.c), +[DualSense trigger encoding](https://gist.github.com/Nielk1/6d54cc2c00d2201ccb8c2720ad7538db), +and the [Cemuhook protocol](https://v1993.github.io/cemuhook-protocol/). + ## 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..53e5017 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", "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 = [ diff --git a/crates/open-controller-core/examples/windows/loopback.rs b/crates/open-controller-core/examples/windows/loopback.rs index 485c116..4904b51 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. @@ -39,7 +40,10 @@ fn attach() -> (SDL_JoystickID, *mut SDL_Joystick) { fn attach_one(first: bool) -> (SDL_JoystickID, *mut SDL_Joystick) { // SDL copies these when attaching. let touchpads = [SDL_VirtualJoystickTouchpadDesc { nfingers: 2, ..Default::default() }]; - let sensors = [SDL_VirtualJoystickSensorDesc { r#type: SDL_SENSOR_GYRO, rate: 250.0 }]; + let sensors = [ + SDL_VirtualJoystickSensorDesc { r#type: SDL_SENSOR_GYRO, rate: 250.0 }, + SDL_VirtualJoystickSensorDesc { r#type: SDL_SENSOR_ACCEL, rate: 250.0 }, + ]; let desc = SDL_VirtualJoystickDesc { version: size_of::() as u32, r#type: SDL_JOYSTICK_TYPE_GAMEPAD.0 as u16, @@ -50,7 +54,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(), @@ -90,13 +94,151 @@ 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)); + cfg = Lab { debounce_ms: 80, ..Default::default() }; + profiles.apply(Edit::Lab(cfg)); + engine.send(Command::SetProfiles(PROFILE.into(), profiles.clone())); + wait_for("debounce profile", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.profile().lab.debounce_ms == 80) + }); + let start = Instant::now(); + unsafe { SDL_SetJoystickVirtualButton(joy, SDL_GAMEPAD_BUTTON_SOUTH.0, true) }; + wait_for("stable debounced A", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.wButtons & XINPUT_GAMEPAD_A != 0)); + check(start.elapsed() >= Duration::from_millis(70), "button debounce settles on the timer and reaches XInput"); + unsafe { SDL_SetJoystickVirtualButton(joy, SDL_GAMEPAD_BUTTON_SOUTH.0, false) }; + wait_for("debounced A release", Duration::from_secs(1), || xinput(slot).filter(|s| s.Gamepad.wButtons == 0)); + cfg = Lab { dsu: true, ..Default::default() }; + profiles.apply(Edit::Lab(cfg)); + engine.send(Command::SetProfiles(PROFILE.into(), profiles)); + engine.send(Command::Watch(true)); + wait_for("DSU server", Duration::from_secs(2), || { + let pad = engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE))?; + assert!(pad.lab_status.dsu.error.is_none(), "DSU bind failed: {:?}", pad.lab_status.dsu.error); + let gyro = [0., 1., 0.]; + let accel = [0., open_controller_core::ds4::STANDARD_GRAVITY, 0.]; + unsafe { + SDL_SendJoystickVirtualSensorData(joy, SDL_SENSOR_GYRO, 1, gyro.as_ptr(), 3); + SDL_SendJoystickVirtualSensorData(joy, SDL_SENSOR_ACCEL, 1, accel.as_ptr(), 3); + } + (pad.lab_status.dsu.running && pad.gyro.is_some_and(|g| (g[1] - 1.).abs() < 0.001)).then_some(()) + }); + let client = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut request = Vec::from(*b"DSUC"); + request.extend(1001u16.to_le_bytes()); + request.extend(12u16.to_le_bytes()); + request.extend([0; 4]); + request.extend(123u32.to_le_bytes()); + request.extend(0x100002u32.to_le_bytes()); + request.extend([0; 8]); + let crc = open_controller_core::dsu::crc32(&request); + request[8..12].copy_from_slice(&crc.to_le_bytes()); + client.send_to(&request, "127.0.0.1:26760").unwrap(); + let mut bytes = [0; 512]; + let n = client.recv(&mut bytes).unwrap(); + assert_eq!(n, 100); + assert_eq!(&bytes[..4], b"DSUS"); + check( + (f32::from_le_bytes(bytes[84..88].try_into().unwrap()) - 1.).abs() < 0.01 + && (f32::from_le_bytes(bytes[92..96].try_into().unwrap()) - 1f32.to_degrees()).abs() < 0.01, + "SDL sensors reach a DSU client in g and degrees/second", + ); + 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(); 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 +310,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( @@ -217,7 +361,14 @@ 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 +380,25 @@ 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)); + + 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)); @@ -303,7 +473,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())); @@ -324,9 +496,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/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/dsu.rs b/crates/open-controller-core/src/dsu.rs new file mode 100644 index 0000000..61e17e0 --- /dev/null +++ b/crates/open-controller-core/src/dsu.rs @@ -0,0 +1,364 @@ +//! Loopback-only Cemuhook/DSU 1001 server. Protocol: https://v1993.github.io/cemuhook-protocol/ +//! UDP work, subscriptions and checksums stay off the controller input thread. +use crate::ds4::{Motion, STANDARD_GRAVITY}; +use crate::mapping::{PadState, axis, button}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::{SocketAddr, UdpSocket}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const VERSION: u16 = 1001; +const INFO: u32 = 0x100001; +const DATA: u32 = 0x100002; +#[derive(Clone, Copy, Debug)] +pub struct Frame { + pub state: PadState, + pub motion: Motion, + pub connection: u8, + pub battery: u8, + pub mac: [u8; 6], + pub timestamp_us: u64, + pub touch_ids: [u8; 2], +} +#[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, +} +struct Update { + enabled: bool, + frames: [Option; 4], +} +pub struct Server { + tx: crossbeam_channel::Sender, + status: Arc>, +} +impl Server { + pub fn start() -> Self { + Self::with_port(26760) + } + fn with_port(port: u16) -> Self { + let (tx, rx) = crossbeam_channel::bounded(1); + let status = Arc::new(Mutex::new(Status::default())); + let shared = status.clone(); + let _ = std::thread::Builder::new().name("open-controller-dsu".into()).spawn(move || serve(rx, port, shared)); + Self { tx, status } + } + pub fn update(&self, enabled: bool, frames: [Option; 4]) { + let _ = self.tx.try_send(Update { enabled, frames }); + } + pub fn status(&self) -> Option { + self.status.try_lock().ok().map(|s| s.clone()) + } +} + +pub fn crc32(bytes: &[u8]) -> u32 { + let mut crc = !0u32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb88320 & (0u32.wrapping_sub(crc & 1))); + } + } + !crc +} +fn packet(magic: &[u8; 4], id: u32, kind: u32, payload: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(20 + payload.len()); + bytes.extend(magic); + bytes.extend(VERSION.to_le_bytes()); + bytes.extend(((payload.len() + 4) as u16).to_le_bytes()); + bytes.extend([0; 4]); + bytes.extend(id.to_le_bytes()); + bytes.extend(kind.to_le_bytes()); + bytes.extend(payload); + let crc = crc32(&bytes); + bytes[8..12].copy_from_slice(&crc.to_le_bytes()); + bytes +} +fn request(bytes: &[u8]) -> Option<(u32, &[u8])> { + if bytes.len() < 20 || &bytes[..4] != b"DSUC" || u16::from_le_bytes(bytes[4..6].try_into().ok()?) != VERSION { + return None; + } + let len = usize::from(u16::from_le_bytes(bytes[6..8].try_into().ok()?)) + 16; + if !(20..=512).contains(&len) || bytes.len() < len { + return None; + } + let mut checked = bytes[..len].to_vec(); + checked[8..12].fill(0); + if crc32(&checked) != u32::from_le_bytes(bytes[8..12].try_into().ok()?) { + return None; + } + Some((u32::from_le_bytes(bytes[16..20].try_into().ok()?), &bytes[20..len])) +} +fn info(slot: u8, frame: Option) -> [u8; 12] { + let mut bytes = [0; 12]; + bytes[0] = slot; + if let Some(f) = frame { + bytes[1] = 2; + bytes[2] = 2; + bytes[3] = f.connection; + bytes[4..10].copy_from_slice(&f.mac); + bytes[10] = f.battery; + } + bytes +} +fn data(slot: u8, f: Frame, sequence: u32) -> [u8; 80] { + let mut bytes = [0; 80]; + bytes[..12].copy_from_slice(&info(slot, Some(f))); + bytes[11] = 1; + bytes[12..16].copy_from_slice(&sequence.to_le_bytes()); + let button_lists = [ + [ + button::DPAD_LEFT, + button::DPAD_DOWN, + button::DPAD_RIGHT, + button::DPAD_UP, + button::START, + button::RIGHT_STICK, + button::LEFT_STICK, + button::BACK, + ], + [button::NORTH, button::EAST, button::SOUTH, button::WEST, button::RIGHT_SHOULDER, button::LEFT_SHOULDER, 62, 63], + ]; + for (i, list) in button_lists.iter().enumerate() { + for (bit, &b) in list.iter().take(if i == 0 { 8 } else { 6 }).enumerate() { + if f.state.pressed(b) { + bytes[16 + i] |= 1 << (7 - bit); + } + } + } + bytes[18] = u8::from(f.state.pressed(button::GUIDE)); + bytes[19] = u8::from(f.state.pressed(button::TOUCHPAD)); + for (i, &a) in [axis::LEFT_X, axis::LEFT_Y, axis::RIGHT_X, axis::RIGHT_Y].iter().enumerate() { + let value = i32::from(f.state.axes[a]); + let value = if i % 2 == 1 { -value } else { value }; + bytes[20 + i] = ((value + 32768).clamp(0, 65535) >> 8) as u8; + } + for (i, &b) in [ + button::DPAD_LEFT, + button::DPAD_DOWN, + button::DPAD_RIGHT, + button::DPAD_UP, + button::NORTH, + button::EAST, + button::SOUTH, + button::WEST, + button::RIGHT_SHOULDER, + button::LEFT_SHOULDER, + ] + .iter() + .enumerate() + { + bytes[24 + i] = if f.state.pressed(b) { 255 } else { 0 }; + } + for (at, a, mask) in [(34, axis::RIGHT_TRIGGER, 2), (35, axis::LEFT_TRIGGER, 1)] { + bytes[at] = (i32::from(f.state.axes[a].max(0)) * 255 / 32767) as u8; + if bytes[at] > 0 { + bytes[17] |= mask; + } + } + for (i, touch) in f.state.touches.iter().enumerate() { + let at = 36 + i * 6; + bytes[at] = u8::from(touch.down); + bytes[at + 1] = f.touch_ids[i]; + bytes[at + 2..at + 4].copy_from_slice(&((u32::from(touch.x) * 1919 / 65535) as u16).to_le_bytes()); + bytes[at + 4..at + 6].copy_from_slice(&((u32::from(touch.y) * 941 / 65535) as u16).to_le_bytes()); + } + bytes[48..56].copy_from_slice(&f.timestamp_us.to_le_bytes()); + // SDL: X right, Y up, Z toward player. DSU: X right, Y forward, Z up. + let accel = [f.motion.accel[0], -f.motion.accel[2], f.motion.accel[1]].map(|v| v / STANDARD_GRAVITY); + let gyro = [f.motion.gyro[0], f.motion.gyro[1], f.motion.gyro[2]].map(f32::to_degrees); + for (i, value) in accel.into_iter().chain(gyro).enumerate() { + bytes[56 + i * 4..60 + i * 4].copy_from_slice(&value.to_le_bytes()); + } + bytes +} +struct Client { + slots: u8, + touched: Instant, + sequence: u32, +} +fn serve(rx: crossbeam_channel::Receiver, port: u16, status: Arc>) { + let id = std::process::id() ^ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().subsec_nanos(); + let mut socket: Option = None; + let mut clients: HashMap = HashMap::new(); + let mut enabled = false; + let mut frames = [None; 4]; + let mut retry = Instant::now(); + let mut last_send = Instant::now(); + let mut error = None; + loop { + match rx.recv_timeout(if enabled { Duration::from_millis(8) } else { Duration::from_millis(200) }) { + Ok(update) => { + enabled = update.enabled; + frames = update.frames; + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, + Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + } + let now = Instant::now(); + if !enabled { + socket = None; + clients.clear(); + error = None; + } else if socket.is_none() && now >= retry { + retry = now + Duration::from_secs(1); + match UdpSocket::bind((std::net::Ipv4Addr::LOCALHOST, port)).and_then(|s| { + s.set_nonblocking(true)?; + Ok(s) + }) { + Ok(s) => { + socket = Some(s); + error = None; + } + Err(e) => error = Some(e.to_string()), + } + } + if let Some(socket) = &socket { + let mut buffer = [0; 512]; + for _ in 0..32 { + let Ok((n, peer)) = socket.recv_from(&mut buffer) else { break }; + if !peer.ip().is_loopback() { + continue; + } + let Some((kind, payload)) = request(&buffer[..n]) else { continue }; + match kind { + 0x100000 => { + let _ = socket.send_to(&packet(b"DSUS", id, kind, &VERSION.to_le_bytes()), peer); + } + INFO if payload.len() >= 4 => { + let count = u32::from_le_bytes(payload[..4].try_into().unwrap()) as usize; + if count > 4 || payload.len() < 4 + count { + continue; + } + for &slot in &payload[4..4 + count] { + if slot < 4 { + let _ = socket.send_to(&packet(b"DSUS", id, kind, &info(slot, frames[usize::from(slot)])), peer); + } + } + } + DATA if payload.len() >= 8 => { + let mask = if payload[0] == 0 { + 15 + } else { + let mut slots = 0u8; + if payload[0] & 1 != 0 && payload[1] < 4 { + slots |= 1 << payload[1]; + } + if payload[0] & 2 != 0 { + for (slot, frame) in frames.iter().enumerate() { + if frame.is_some_and(|f| f.mac.as_slice() == &payload[2..8]) { + slots |= 1 << slot; + } + } + } + slots + }; + if clients.len() < 16 || clients.contains_key(&peer) { + let client = clients.entry(peer).or_insert(Client { slots: 0, touched: now, sequence: 0 }); + client.slots |= mask; + client.touched = now; + } + } + _ => {} + } + } + clients.retain(|_, c| now.duration_since(c.touched) < Duration::from_secs(5)); + if now.duration_since(last_send) >= Duration::from_millis(8) { + last_send = now; + for (peer, client) in &mut clients { + for (slot, frame) in frames.iter().enumerate() { + if client.slots & (1 << slot) != 0 + && let Some(frame) = frame + { + client.sequence = client.sequence.wrapping_add(1); + let _ = socket.send_to(&packet(b"DSUS", id, DATA, &data(slot as u8, *frame, client.sequence)), peer); + } + } + } + } + } + if let Ok(mut shared) = status.lock() { + *shared = Status { + running: socket.is_some(), + port: socket.as_ref().and_then(|s| s.local_addr().ok()).map_or(port, |a| a.port()), + clients: clients.len(), + error: error.clone(), + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn crc_and_request_validation() { + assert_eq!(crc32(b"123456789"), 0xcbf43926); + let mut bytes = packet(b"DSUC", 5, INFO, &[1, 0, 0, 0, 0]); + assert!(request(&bytes).is_some()); + bytes[20] ^= 1; + assert!(request(&bytes).is_none()); + assert!(request(&bytes[..15]).is_none()); + } + #[test] + fn motion_packet_has_standard_size_units_and_touch() { + let f = Frame { + state: PadState::default(), + motion: Motion::REST, + connection: 2, + battery: 5, + mac: [2; 6], + timestamp_us: 123, + touch_ids: [0, 1], + }; + let p = packet(b"DSUS", 1, DATA, &data(0, f, 3)); + assert_eq!(p.len(), 100); + assert_eq!(&p[..4], b"DSUS"); + assert_eq!(&p[40..44], &[128; 4], "all DSU sticks are centred at 128"); + assert_eq!(f32::from_le_bytes(p[84..88].try_into().unwrap()), 1.); + assert_eq!(u64::from_le_bytes(p[68..76].try_into().unwrap()), 123); + } + #[test] + fn real_udp_version_info_and_subscription() { + let server = Server::with_port(0); + let frame = Frame { + state: PadState::default(), + motion: Motion::REST, + connection: 1, + battery: 5, + mac: [2; 6], + timestamp_us: 42, + touch_ids: [0, 1], + }; + server.update(true, [Some(frame), None, None, None]); + let deadline = Instant::now() + Duration::from_secs(2); + let port = loop { + if let Some(s) = server.status() + && s.running + { + break s.port; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + client.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let mut bytes = [0; 512]; + client.send_to(&packet(b"DSUC", 1, 0x100000, &[]), ("127.0.0.1", port)).unwrap(); + let n = client.recv(&mut bytes).unwrap(); + assert_eq!(n, 22); + client.send_to(&packet(b"DSUC", 1, INFO, &[1, 0, 0, 0, 0]), ("127.0.0.1", port)).unwrap(); + let n = client.recv(&mut bytes).unwrap(); + assert_eq!(n, 32); + assert_eq!(bytes[21], 2); + client.send_to(&packet(b"DSUC", 1, DATA, &[1, 0, 0, 0, 0, 0, 0, 0]), ("127.0.0.1", port)).unwrap(); + let n = client.recv(&mut bytes).unwrap(); + assert_eq!(n, 100); + assert_eq!(bytes[31], 1); + } +} diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index 4c95d24..23708e2 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,11 @@ 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, pub key: PadKey, pub name: String, pub brand: Brand, @@ -393,6 +399,7 @@ struct Model { /// A connected device, as SDL sees it. struct Phys { + tools: Tools, pad: Gamepad, model: Model, link: Link, @@ -430,6 +437,47 @@ 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, + activity: Instant, + motion_accel: Option<[f32; 3]>, + motion_us: u64, + touches: [bool; 2], + touch_ids: [u8; 2], + mouse_on: bool, +} +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, + activity: now, + motion_accel: None, + motion_us: 0, + touches: [false; 2], + touch_ids: [0, 1], + mouse_on: false, + } + } +} + impl Phys { /// What its motion sensors read, for a virtual DualShock 4: at rest when they are off. fn motion(&self) -> Motion { @@ -541,6 +589,14 @@ impl Target { } struct Core { + desktop: crate::desktop::Desktop, + dsu: crate::dsu::Server, + dsu_seen: crate::dsu::Status, + dsu_slots: [Option; 4], + dsu_at: Instant, + lights_at: Instant, + 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 +694,14 @@ 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(), + dsu: crate::dsu::Server::start(), + dsu_seen: Default::default(), + dsu_slots: Default::default(), + dsu_at: Instant::now(), + lights_at: Instant::now(), + microphone: crate::microphone::Monitor::start(), + microphone_seen: None, bus: None, bus_version: None, bus_error: None, @@ -712,9 +776,15 @@ 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); + 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); @@ -814,6 +884,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 { @@ -1140,10 +1219,16 @@ impl Core { let (gyro, accel) = { let p = &self.phys[&id]; let ds4 = self.output_of(&p.model.store) == Output::DualShock4; - let gyro = matches!(p.kind, Kind::Slot(s) if !self.is_native(s)) + let mapped = matches!(p.kind, Kind::Slot(s) if !self.is_native(s)); + let lab = self.profile_of(&p.model.store).map(|pr| pr.lab).unwrap_or_default(); + let gyro = !matches!(p.kind, Kind::Own | Kind::Pending) && p.model.features.motion - && (ds4 || self.profile_of(&p.model.store).is_some_and(|pr| pr.gyro.mode != GyroMode::Off)); - (gyro, gyro && ds4) + && (lab.dsu + || (mapped + && (ds4 + || cfg!(windows) && lab.flick.enabled && lab.flick.gyro_mouse + || self.profile_of(&p.model.store).is_some_and(|pr| pr.gyro.mode != GyroMode::Off)))); + (gyro, gyro && (mapped && ds4 || lab.dsu)) }; let p = self.phys.get_mut(&id).unwrap(); if (p.gyro_on, p.accel_on) == (gyro, accel) { @@ -1344,6 +1429,7 @@ impl Core { model.store = format!("handheld:{}", m.name); } let mut p = Phys { + tools: Tools::new(Instant::now()), model, link, identity, @@ -1369,6 +1455,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 @@ -1513,6 +1600,10 @@ 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()); + } self.macro_held.remove(&id); if let Kind::Slot(s) = p.kind { self.roster.detach(id, now); @@ -1599,7 +1690,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. @@ -1617,12 +1708,12 @@ 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; } 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); @@ -1694,12 +1785,22 @@ impl Core { /// battery's or none, blinking once a second when the battery is nearly empty. Only a change /// is sent. fn update_lights(&mut self, now: Instant) { + if now.duration_since(self.lights_at) < Duration::from_millis(25) { + return; + } + self.lights_at = now; // Half a second on, half off. let blink_off = now.duration_since(self.started).as_millis() % 1000 >= 500; 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, @@ -1717,11 +1818,28 @@ impl Core { let color = if pr.low_battery_flash && low && blink_off { [0, 0, 0] } else { - profile::light_color(pr.light, pr.brightness, player, battery) + let base = profile::light_color(pr.light, pr.brightness, player, battery); + if pr.light == crate::profile::Light::Off { + base + } else { + let rumble = match p.kind { + Kind::Slot(s) => self.targets.get(&s).map_or(0, |t| t.rumble.0.max(t.rumble.1)), + _ => 0, + }; + crate::lab::dynamic_color( + pr.lab.dynamic, + base, + pr.brightness, + now.duration_since(self.started), + now.duration_since(p.tools.activity), + rumble, + ) + } }; if p.led != Some(color) { p.pad.set_led(color); p.led = Some(color); + self.input_changed = true; } } } @@ -1812,9 +1930,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. @@ -1824,9 +1939,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); - } } } } @@ -1834,6 +1946,159 @@ impl Core { self.updated.clear(); } + fn update_tools(&mut self, now: Instant) { + let ids: Vec = self.phys.keys().copied().collect(); + let mut dsu_frames = Vec::new(); + 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; + 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); + } + p.tools.mouse_on = mouse_on; + if p.state.buttons != 0 + || p.state.touches.iter().any(|t| t.down) + || p.state.axes.iter().any(|v| i32::from(*v).abs() > 6000) + || p.rate.is_some_and(|rate| rate.iter().any(|v| v.abs() > 0.01)) + { + p.tools.activity = now; + } + } 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; + } + 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)); + } + } + if owned && cfg.dsu && p.rate.is_some() && p.accel.is_some() { + if p.accel != p.tools.motion_accel { + p.tools.motion_accel = p.accel; + p.tools.motion_us = now.duration_since(self.started).as_micros() as u64; + } + for i in 0..2 { + let down = p.state.touches[i].down; + if down && !p.tools.touches[i] { + p.tools.touch_ids[i] = p.tools.touch_ids[i].wrapping_add(2); + } + p.tools.touches[i] = down; + } + let source_key = dsu_source_key(&p.model.store, &p.path); + let hash = source_key.bytes().fold(0xcbf29ce484222325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x100000001b3)); + let mut mac = [0; 6]; + mac.copy_from_slice(&hash.to_le_bytes()[..6]); + mac[0] = (mac[0] & 0xfc) | 2; + let battery = match p.power { + Power::Charging(_) => 0xee, + Power::Battery(Some(v)) => { + if v >= 95 { + 5 + } else if v >= 60 { + 4 + } else if v >= 30 { + 3 + } else if v >= 10 { + 2 + } else { + 1 + } + } + _ => 0, + }; + dsu_frames.push(( + source_key, + crate::dsu::Frame { + state: p.state, + motion: p.motion(), + connection: if p.link == Link::Bluetooth { 2 } else { 1 }, + battery, + mac, + timestamp_us: p.tools.motion_us, + touch_ids: p.tools.touch_ids, + }, + )); + } + 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), + _ => {} + } + } + } + let dsu_enabled = self.profiles.values().any(|p| p.for_program(&self.foreground).lab.dsu); + if now.duration_since(self.dsu_at) >= Duration::from_millis(if dsu_enabled { 8 } else { 200 }) { + self.dsu_at = now; + let mut frames = [None; 4]; + dsu_frames.sort_by(|a, b| a.0.cmp(&b.0)); + for (store, frame) in &dsu_frames { + let slot = self.dsu_slots.iter().position(|s| s.as_ref() == Some(store)).or_else(|| { + self.dsu_slots.iter().position(|s| s.is_none() || !dsu_frames.iter().any(|(key, _)| s.as_ref() == Some(key))) + }); + if let Some(slot) = slot { + self.dsu_slots[slot] = Some(store.clone()); + frames[slot] = Some(*frame); + } + } + self.dsu.update(dsu_enabled, frames); + } + if let Some(status) = self.dsu.status() + && status != self.dsu_seen + { + self.dsu_seen = status; + self.changed = true; + } + } + 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 }; @@ -2149,6 +2414,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: self.dsu_seen.clone(), + 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()); @@ -2166,6 +2440,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: self.dsu_seen.clone(), + 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 @@ -2228,6 +2509,8 @@ 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(), brand: model.map(|m| m.brand).unwrap_or(Brand::Other), @@ -2262,6 +2545,12 @@ impl Core { } fn shutdown(&mut self) { + 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()); + } + } 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); } @@ -2314,8 +2603,32 @@ fn slot_role(native: bool, remaining: Option, target: Option String { + if store.starts_with("model:") { format!("{store}@{path}") } else { store.to_string() } +} + #[cfg(test)] mod tests { + #[test] + fn dsu_distinguishes_identical_serialless_pads_but_keeps_serialled_links_together() { + assert_ne!(super::dsu_source_key("model:1234:5678", "usb-a"), super::dsu_source_key("model:1234:5678", "usb-b")); + assert_eq!(super::dsu_source_key("serial:abc", "usb"), super::dsu_source_key("serial:abc", "bluetooth")); + } + #[test] + fn trigger_trials_restore_the_profile_and_stop_on_disconnect() { + use crate::lab::{Config, EffectMode, TriggerEffect}; + use std::time::{Duration, Instant}; + let now = Instant::now(); + let mut tools = super::Tools::new(now); + let saved = [TriggerEffect { mode: EffectMode::Resistance, ..Default::default() }; 2]; + let trial = [TriggerEffect { mode: EffectMode::Vibration, ..Default::default() }; 2]; + let cfg = Config { adaptive_enabled: true, adaptive: saved, ..Default::default() }; + tools.trial = Some((now + Duration::from_secs(5), trial)); + assert_eq!(tools.wanted(cfg, true, now).0, trial); + assert_eq!(tools.wanted(cfg, true, now + Duration::from_secs(6)), (saved, true)); + assert_eq!(tools.wanted(cfg, false, now).0, [TriggerEffect::default(); 2]); + assert_eq!(tools.wanted(Config::default(), true, now).0, [TriggerEffect::default(); 2]); + } use super::*; use crate::profile::Edit; diff --git a/crates/open-controller-core/src/i18n.rs b/crates/open-controller-core/src/i18n.rs index 236f730..03f2ff9 100644 --- a/crates/open-controller-core/src/i18n.rs +++ b/crates/open-controller-core/src/i18n.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; +pub mod lab; mod zh; pub use zh::ZH; @@ -32,6 +33,7 @@ impl Lang { } pub struct Text { + pub lab: &'static lab::Text, pub controllers: &'static str, pub controllers_one: &'static str, pub controllers_many: &'static str, @@ -121,6 +123,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, @@ -480,6 +491,7 @@ pub struct Text { } pub const EN: Text = Text { + lab: &lab::EN, controllers: "Controllers", controllers_one: "1 controller", controllers_many: "controllers", @@ -532,7 +544,7 @@ pub const EN: Text = Text { as_native: "The controller itself, no Xbox controller", keep_native: "Keep native: games see the real controller.", native_close_game: "Close the game before switching.", - native_profiles_off: "Profiles, gyro and remapping are off while it stays native.", + native_profiles_off: "Game input stays native. Optional Controller Lab tools can be enabled separately.", native_all_alike: "Applies to every controller like this one.", native_not_hidden: "With Hide original controllers off, this only removes its Xbox controller.", features: "Also has", @@ -584,6 +596,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 {}", @@ -948,6 +969,7 @@ pub const EN: Text = Text { }; pub const PT: Text = Text { + lab: &lab::PT, controllers: "Controles", controllers_one: "1 controle", controllers_many: "controles", @@ -1000,7 +1022,7 @@ pub const PT: Text = Text { as_native: "O próprio controle, sem controle de Xbox", keep_native: "Deixar nativo: os jogos veem o controle de verdade.", native_close_game: "Feche o jogo antes de trocar.", - native_profiles_off: "Em nativo, perfis, gyro e remapeamento ficam desligados.", + native_profiles_off: "A entrada do jogo fica nativa. As ferramentas do Laboratório podem ser ligadas separadamente.", native_all_alike: "Vale para todos os controles iguais a este.", native_not_hidden: "Com Esconder os controles originais desligado, isso só tira o controle de Xbox dele.", features: "Também tem", @@ -1052,6 +1074,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/lab.rs b/crates/open-controller-core/src/i18n/lab.rs new file mode 100644 index 0000000..c965358 --- /dev/null +++ b/crates/open-controller-core/src/i18n/lab.rs @@ -0,0 +1,217 @@ +//! Labels for the optional controller tools, kept together to make all three locales complete. +pub struct Text { + pub nav: &'static str, + pub tabs: [&'static str; 7], + pub intro: &'static str, + pub native: &'static str, + pub windows: &'static str, + pub unsupported: &'static str, + pub effects: [&'static str; 4], + pub test: &'static str, + pub stop: &'static str, + pub persist: &'static str, + pub testing: &'static str, + pub effect_note: &'static str, + pub start: &'static str, + pub end: &'static str, + pub strength: &'static str, + pub frequency: &'static str, + pub sensitivity: &'static str, + pub friction: &'static str, + pub tap: &'static str, + pub scroll: &'static str, + pub touch_modes: [&'static str; 3], + pub touch_note: &'static str, + pub flick_on: &'static str, + pub counts: &'static str, + pub duration: &'static str, + pub threshold: &'static str, + pub gyro: &'static str, + pub gyro_sens: &'static str, + pub flick_note: &'static str, + pub input_modes: [&'static str; 4], + pub soft: &'static str, + pub full: &'static str, + pub hysteresis: &'static str, + pub delay: &'static str, + pub soft_action: &'static str, + pub full_action: &'static str, + pub exclusive: &'static str, + pub input_note: &'static str, + pub debounce: &'static str, + pub debounce_note: &'static str, + pub dsu_on: &'static str, + pub dsu_note: &'static str, + pub waiting: &'static str, + pub clients: &'static str, + pub light_modes: [&'static str; 4], + pub period: &'static str, + pub idle: &'static str, + pub light_note: &'static str, + pub left: &'static str, + pub right: &'static str, + pub raw: &'static str, + pub output: &'static str, +} +pub const EN: Text = Text { + nav: "Controller Lab", + tabs: ["Trigger Lab", "Touchpad mouse", "Flick Stick", "Trigger mapping", "Debounce", "DSU motion", "Dynamic lighting"], + intro: "Optional tools for the selected profile. Each feature starts off; settings are saved automatically.", + native: "Native mode keeps original gamepad input. Flick Stick, trigger mapping and debounce take effect in mapping mode; the other tools can be enabled separately.", + windows: "Desktop mouse output is currently available on Windows.", + unsupported: "Adaptive trigger effects require a DualSense or DualSense Edge.", + effects: ["Off", "Resistance", "Vibration", "Trigger break"], + test: "Try for 5 seconds", + stop: "Stop and disable", + persist: "Apply effects with this profile", + testing: "Trying effects — the profile's effects return after 5 seconds.", + effect_note: "Adjust L2 and R2 independently. Try is temporary; Stop clears the effects. Games can also write adaptive trigger effects.", + start: "Start zone (0–9)", + end: "End zone (0–9)", + strength: "Strength (0–8)", + frequency: "Frequency (Hz)", + sensitivity: "Sensitivity (%)", + friction: "Inertia decay (ms)", + tap: "Tap to left-click", + scroll: "Two-finger scrolling", + touch_modes: ["Off", "Mouse", "Trackball"], + touch_note: "Slide to move. Click the touchpad to hold left mouse, or use two fingers for right mouse. Trackball keeps moving briefly after release.", + flick_on: "Enable Flick Stick", + counts: "Mouse counts per full turn", + duration: "Flick duration (ms)", + threshold: "Stick activation (%)", + gyro: "Gyroscope fine aim", + gyro_sens: "Mouse counts per degree", + flick_note: "Push the right stick toward a direction to turn, rotate it to keep turning, and release to reset. Calibrate counts in each game's mouse sensitivity; turn off mouse acceleration in the game.", + input_modes: ["Original analog", "Hair Trigger", "Two-stage", "Hip Fire"], + soft: "Soft / press threshold (%)", + full: "Full-pull threshold (%)", + hysteresis: "Reversal distance (%)", + delay: "Soft-action delay (ms)", + soft_action: "Soft action", + full_action: "Full action", + exclusive: "Full action replaces soft action", + input_note: "Hair Trigger reacts to short reversals. Two-stage assigns buttons to soft/full pulls; Hip Fire delays the soft action so a quick full pull can skip it. Existing button assignments also apply.", + debounce: "Stable time (ms; 0 = off)", + debounce_note: "Accept a press or release only after this stable interval. Helps contact chatter, while adding the same amount of input delay. Applies to mapped gamepad and keyboard output.", + dsu_on: "Share motion with emulators", + dsu_note: "DSU / Cemuhook 1001 at 127.0.0.1:26760. Add a DSU controller in your emulator and enable motion. Up to four controllers; gyroscope and acceleration remain active while sharing.", + waiting: "Waiting for motion data", + clients: "Connected clients", + light_modes: ["Static colour", "Rainbow", "Breathing", "Rumble-linked"], + period: "Cycle period (ms)", + idle: "Fade after idle seconds (0 = off)", + light_note: "Uses the Light page's colour and brightness. Idle fade takes five seconds. Native mode also needs its lighting switch on; turning the light off stops dynamic effects.", + left: "L2 / LT", + right: "R2 / RT", + raw: "Input", + output: "Output", +}; +pub const ZH: Text = Text { + nav: "手柄实验室", + tabs: ["Trigger Lab", "触摸板鼠标", "Flick Stick", "扳机映射", "按键防抖", "DSU 体感", "动态灯效"], + intro: "当前配置方案的可选工具。各项默认关闭,修改后自动保存。", + native: "原生模式保留真实手柄输入。Flick Stick、扳机映射和防抖需切换到映射模式才生效;其余工具可单独开启。", + windows: "桌面鼠标输出目前支持 Windows。", + unsupported: "自适应扳机效果需要 DualSense 或 DualSense Edge。", + effects: ["关闭", "阻力", "脉冲振动", "段落扳机"], + test: "试用 5 秒", + stop: "停止并关闭", + persist: "随此配置应用扳机效果", + testing: "正在试用,5 秒后恢复配置中的效果。", + effect_note: "左右扳机分别调节。试用为临时效果;停止会清除效果。游戏也可能设置自适应扳机。", + start: "阻力起点(0–9 档)", + end: "段落终点(0–9 档)", + strength: "强度(0–8)", + frequency: "频率(Hz)", + sensitivity: "灵敏度(%)", + friction: "惯性衰减(毫秒)", + tap: "轻点触摸板单击左键", + scroll: "双指滚动", + touch_modes: ["关闭", "鼠标", "轨迹球"], + touch_note: "滑动移动鼠标;按下触摸板可按住鼠标左键,双指按下为右键。轨迹球在抬手后继续滑行并减速。", + flick_on: "启用 Flick Stick", + counts: "转一整圈的鼠标计数", + duration: "甩动过渡时间(毫秒)", + threshold: "摇杆启动阈值(%)", + gyro: "陀螺仪微调瞄准", + gyro_sens: "每度旋转的鼠标计数", + flick_note: "右摇杆推向某个方向即可转向,沿外圈旋转可连续转动,回中后复位。请按各游戏的鼠标灵敏度校准转圈计数,并关闭游戏鼠标加速。", + input_modes: ["原始模拟量", "Hair Trigger", "双段扳机", "Hip Fire"], + soft: "半按 / 起按阈值(%)", + full: "全按阈值(%)", + hysteresis: "回弹响应距离(%)", + delay: "半按动作延迟(毫秒)", + soft_action: "半按动作", + full_action: "全按动作", + exclusive: "全按时替代半按动作", + input_note: "Hair Trigger 对短距离回弹立即响应。双段扳机分别映射半按和全按;Hip Fire 延后半按动作,让快速全按可跳过半按。已有按键映射仍然生效。", + debounce: "稳定时间(毫秒;0 为关闭)", + debounce_note: "按下和松开持续稳定达到此时间后才输出。可减少触点抖动,同时会增加相同长度的输入延迟。作用于映射后的手柄及键盘输出。", + dsu_on: "向模拟器共享体感数据", + dsu_note: "DSU / Cemuhook 1001,地址 127.0.0.1:26760。在模拟器中添加 DSU 手柄并启用体感。最多四个手柄;共享期间持续读取陀螺仪和加速度。", + waiting: "等待传感器数据", + clients: "已连接客户端", + light_modes: ["静态颜色", "彩虹渐变", "呼吸", "震动联动"], + period: "循环周期(毫秒)", + idle: "闲置多少秒后渐暗(0 为关闭)", + light_note: "使用灯光页的颜色和亮度。闲置后在 5 秒内渐暗。原生模式需开启灯光控制;关闭灯光时动态效果也停止。", + left: "L2 / LT", + right: "R2 / RT", + raw: "实际输入", + output: "映射输出", +}; +pub const PT: Text = Text { + nav: "Laboratório", + tabs: ["Trigger Lab", "Mouse do touchpad", "Flick Stick", "Gatilhos", "Antirrepique", "Movimento DSU", "Luz dinâmica"], + intro: "Ferramentas opcionais do perfil escolhido. Começam desligadas; as mudanças são salvas automaticamente.", + native: "O modo nativo mantém a entrada original. Flick Stick, gatilhos e antirrepique funcionam no modo de mapeamento; as outras ferramentas podem ser ligadas separadamente.", + windows: "A saída de mouse está disponível no Windows.", + unsupported: "Efeitos de gatilho exigem DualSense ou DualSense Edge.", + effects: ["Desligado", "Resistência", "Vibração", "Clique do gatilho"], + test: "Testar por 5 segundos", + stop: "Parar e desligar", + persist: "Aplicar efeitos com este perfil", + testing: "Testando: volta aos efeitos do perfil após 5 segundos.", + effect_note: "Ajuste L2 e R2 separadamente. O teste é temporário; Parar limpa os efeitos. Jogos também podem definir efeitos.", + start: "Zona inicial (0–9)", + end: "Zona final (0–9)", + strength: "Força (0–8)", + frequency: "Frequência (Hz)", + sensitivity: "Sensibilidade (%)", + friction: "Decaimento da inércia (ms)", + tap: "Toque para clicar", + scroll: "Rolagem com dois dedos", + touch_modes: ["Desligado", "Mouse", "Trackball"], + touch_note: "Deslize para mover. Clique no touchpad para segurar o botão esquerdo; dois dedos para o direito. Trackball continua movendo após soltar.", + flick_on: "Ligar Flick Stick", + counts: "Contagens do mouse por volta", + duration: "Duração do giro (ms)", + threshold: "Limiar do analógico (%)", + gyro: "Mira fina pelo giroscópio", + gyro_sens: "Contagens do mouse por grau", + flick_note: "Empurre o analógico direito para virar e gire na borda para continuar. Solte para reiniciar. Calibre por jogo e desative a aceleração do mouse no jogo.", + input_modes: ["Analógico original", "Hair Trigger", "Dois estágios", "Hip Fire"], + soft: "Limiar leve (%)", + full: "Limiar total (%)", + hysteresis: "Distância de retorno (%)", + delay: "Espera da ação leve (ms)", + soft_action: "Ação leve", + full_action: "Ação total", + exclusive: "Ação total substitui a leve", + input_note: "Hair Trigger reage a retornos curtos. Dois estágios atribui botões aos cursos leve e total. Hip Fire atrasa a ação leve. Os mapeamentos existentes também se aplicam.", + debounce: "Tempo estável (ms; 0 = desligado)", + debounce_note: "Aceita pressionar ou soltar somente após este intervalo estável. Reduz repiques, mas adiciona o mesmo atraso à entrada mapeada e ao teclado.", + dsu_on: "Compartilhar movimento com emuladores", + dsu_note: "DSU / Cemuhook 1001 em 127.0.0.1:26760. Adicione um controle DSU no emulador e habilite movimento. Até quatro controles; mantém os sensores ligados.", + waiting: "Aguardando os sensores", + clients: "Clientes conectados", + light_modes: ["Cor estática", "Arco-íris", "Respiração", "Ligada à vibração"], + period: "Período (ms)", + idle: "Escurecer após inatividade (s; 0 = desligado)", + light_note: "Usa a cor e o brilho da página Luz. Escurece em cinco segundos. O modo nativo precisa do controle de luz ligado; apagar a luz também para os efeitos.", + left: "L2 / LT", + right: "R2 / RT", + raw: "Entrada", + output: "Saída", +}; diff --git a/crates/open-controller-core/src/i18n/zh.rs b/crates/open-controller-core/src/i18n/zh.rs index 9960220..a57de6d 100644 --- a/crates/open-controller-core/src/i18n/zh.rs +++ b/crates/open-controller-core/src/i18n/zh.rs @@ -3,6 +3,7 @@ use super::Text; pub const ZH: Text = Text { + lab: &super::lab::ZH, controllers: "手柄", controllers_one: "1 个手柄", controllers_many: "个手柄", @@ -54,7 +55,7 @@ pub const ZH: Text = Text { as_native: "原始手柄,不创建 Xbox 手柄", keep_native: "保持原生:游戏直接读取真实手柄。", native_close_game: "切换前请先关闭游戏。", - native_profiles_off: "原生模式下不应用配置方案、陀螺仪瞄准和按键映射。", + native_profiles_off: "原生模式保留游戏输入;可单独启用手柄实验室工具。", native_all_alike: "此设置适用于所有相同型号的手柄。", native_not_hidden: "关闭“隐藏原始手柄”时,此选项仅移除模拟的 Xbox 手柄。", features: "支持的功能", @@ -105,6 +106,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/ipc.rs b/crates/open-controller-core/src/ipc.rs index e6baa22..236c9b8 100644 --- a/crates/open-controller-core/src/ipc.rs +++ b/crates/open-controller-core/src/ipc.rs @@ -18,7 +18,7 @@ pub use pipe::{Pipe, Reader, is_disconnect, remove_socket}; /// The version of these messages. Raised whenever either side would misread the other's, so a /// window from one release meeting a resident process from another can tell. -pub const PROTO: u32 = 1; +pub const PROTO: u32 = 2; /// What the resident process tells the window. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -56,6 +56,7 @@ pub(crate) fn parse(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..fb85bb2 --- /dev/null +++ b/crates/open-controller-core/src/lab.rs @@ -0,0 +1,705 @@ +//! Optional controller tools. All defaults preserve direct gamepad input. +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)] +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, +} + +struct Debounce { + stable: u64, + pending: u64, + since: [Option; 64], +} +impl Default for Debounce { + fn default() -> Self { + Self { stable: 0, pending: 0, since: [None; 64] } + } +} +impl Debounce { + fn update(&mut self, raw: u64, ms: u16, now: Instant) -> u64 { + if ms == 0 { + self.stable = raw; + self.pending = raw; + self.since.fill(None); + return raw; + } + for bit in 0..64 { + let mask = 1u64 << bit; + if (raw ^ self.stable) & mask == 0 { + self.since[bit] = None; + continue; + } + if (raw ^ self.pending) & mask != 0 || self.since[bit].is_none() { + self.pending = (self.pending & !mask) | (raw & mask); + self.since[bit] = Some(now); + } + if self.since[bit].is_some_and(|t| now.duration_since(t) >= Duration::from_millis(u64::from(ms))) { + self.stable = (self.stable & !mask) | (raw & mask); + self.since[bit] = None; + } + } + self.stable + } +} + +#[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, + 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)] +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, + pub active: bool, + config: Config, + last: Option, + debounce: Debounce, + triggers: [TriggerState; 2], + touch: TouchState, + flick: FlickState, +} +impl Runtime { + 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(); + } + 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; + output.buttons = self.debounce.update(raw.buttons, if mapped { cfg.debounce_ms } else { 0 }, now); + 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; + mouse.dy += dy; + if flick.enabled { + output.axes[axis::RIGHT_X] = 0; + output.axes[axis::RIGHT_Y] = 0; + } + } else { + self.flick = FlickState::default(); + self.triggers = Default::default(); + } + self.output = output; + mouse + } +} + +pub fn dynamic_color(cfg: DynamicLight, base: [u8; 3], brightness: u8, elapsed: Duration, idle: Duration, rumble: u8) -> [u8; 3] { + let phase = elapsed.as_secs_f32() * 1000. / f32::from(cfg.period_ms.clamp(500, 30000)); + let mut out = match cfg.mode { + LightMode::Off => base, + LightMode::Rainbow => { + let h = phase.fract() * 6.; + let x = (1. - (h % 2. - 1.).abs()) * 255.; + let rgb = match h as u8 { + 0 => [255., x, 0.], + 1 => [x, 255., 0.], + 2 => [0., 255., x], + 3 => [0., x, 255.], + 4 => [x, 0., 255.], + _ => [255., 0., x], + }; + rgb.map(|v| (v * f32::from(brightness.min(100)) / 100.) as u8) + } + LightMode::Breathe => base.map(|v| (f32::from(v) * (0.15 + 0.85 * (0.5 - (phase * TAU).cos() * 0.5))) as u8), + LightMode::Rumble => { + let v = f32::from(rumble) / 255.; + [f32::from(base[0]) * (1. - v) + 255. * v, f32::from(base[1]) * (1. - v), f32::from(base[2]) * (1. - v)].map(|c| c as u8) + } + }; + if cfg.idle_seconds > 0 { + let fade = (1. - (idle.as_secs_f32() - f32::from(cfg.idle_seconds)).max(0.) / 5.).clamp(0., 1.); + out = out.map(|c| (f32::from(c) * fade) as u8); + } + out +} + +#[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 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(); + 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(); + 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 debounce_settles_without_another_hid_report() { + let mut d = Debounce::default(); + let n = Instant::now(); + assert_eq!(d.update(1, 20, n), 0); + assert_eq!(d.update(0, 20, n + Duration::from_millis(5)), 0); + assert_eq!(d.update(1, 20, n + Duration::from_millis(10)), 0); + assert_eq!(d.update(1, 20, n + Duration::from_millis(31)), 1); + assert_eq!(d.update(0, 20, n + Duration::from_millis(32)), 1); + assert_eq!(d.update(0, 20, n + Duration::from_millis(53)), 0); + } + #[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(); + 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() }; + 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)); + } + #[test] + fn config_bounds_and_idle_fade() { + let cfg = Config { debounce_ms: u16::MAX, ..Default::default() }.clean(); + assert_eq!(cfg.debounce_ms, 100); + let light = DynamicLight { idle_seconds: 10, ..Default::default() }; + assert_eq!(dynamic_color(light, [20, 40, 60], 100, Duration::ZERO, Duration::from_secs(16), 0), [0; 3]); + } +} diff --git a/crates/open-controller-core/src/lib.rs b/crates/open-controller-core/src/lib.rs index b55512b..d0a293d 100644 --- a/crates/open-controller-core/src/lib.rs +++ b/crates/open-controller-core/src/lib.rs @@ -8,18 +8,23 @@ pub mod binding; pub mod crash; +mod desktop; 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; pub mod motion; 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/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..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(), } @@ -386,6 +389,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, @@ -409,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, @@ -437,6 +445,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 +459,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 +524,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 @@ -522,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 } => { @@ -606,6 +621,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), } @@ -624,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(); @@ -757,6 +775,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..fb5fc73 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 @@ -854,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/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..e7da20f 100644 --- a/crates/open-controller-ui/src/art.rs +++ b/crates/open-controller-ui/src/art.rs @@ -834,13 +834,55 @@ 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 = pad.lab_status.led.unwrap_or_else(|| { + 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_light = if pad.profile().lab.dynamic.mode != open_controller_core::lab::LightMode::Off { + pad.lab_status.led.unwrap_or(touch_light) + } else { + touch_light + }; + 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 +915,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 +927,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 +1002,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..abda83a 100644 --- a/crates/open-controller-ui/src/demo.rs +++ b/crates/open-controller-ui/src/demo.rs @@ -18,6 +18,8 @@ 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 8ac9e9a..23c46bd 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,10 +348,13 @@ 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)); + 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() { @@ -360,7 +379,8 @@ impl MainView { } let content = match current { - Section::Buttons => self.buttons_section(pad, text, cx), + 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), Section::Sticks => self.sticks_section(pad, text, cx), @@ -672,14 +692,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 +736,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 +872,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 +943,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 +1035,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 +1055,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 +1552,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/lab.rs b/crates/open-controller-ui/src/lab.rs new file mode 100644 index 0000000..3644751 --- /dev/null +++ b/crates/open-controller-ui/src/lab.rs @@ -0,0 +1,569 @@ +//! 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, LightMode, TouchMode, TriggerMode}; +use open_controller_core::mapping::{axis, button as b}; +use open_controller_core::profile::Edit; +use open_controller_core::{PadKey, PadView, Role}; + +pub struct Number { + key: (PadKey, usize), + range: (u16, u16), + last: u16, + 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.)) +} +#[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 key = pad.key; + let cfg = pad.profiles.active().lab; + let tabs = widgets::segmented( + "lab-tabs", + l.tabs.iter().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 native = matches!(pad.role, Role::KeptNative | Role::Native { .. }); + 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() + } + 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() + } + 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() + } + 4 => { + let number = self.lab_number("lab-debounce", l.debounce, cfg.debounce_ms, (0, 100), 5, pad, |c, v| c.debounce_ms = v, cx); + panel(&t).child(caption(l.debounce_note, t.text2)).child(number).into_any_element() + } + 5 => { + let status = &pad.lab_status.dsu; + panel(&t) + .child(self.toggle("lab-dsu", (l.dsu_on, ""), cfg.dsu, Edit::Lab(Config { dsu: !cfg.dsu, ..cfg }), key, cx)) + .child(caption(l.dsu_note, t.text2)) + .when(cfg.dsu, |d| { + d.child(strong( + format!("127.0.0.1:{} · {}: {}", status.port.max(26760), l.clients, status.clients), + if status.running { t.accent } else { t.text2 }, + )) + .when(pad.gyro.is_none(), |d| d.child(caption(l.waiting, t.text2))) + .when_some(status.error.clone(), |d, error| d.child(caption(error, t.caution))) + }) + .into_any_element() + } + _ => { + let mode = choices( + "lab-light-mode", + &l.light_modes, + &[LightMode::Off, LightMode::Rainbow, LightMode::Breathe, LightMode::Rumble], + cfg.dynamic.mode, + key, + &t, + |c, v| c.dynamic.mode = v, + cx, + ); + let period = self.lab_number( + "lab-light-period", + l.period, + cfg.dynamic.period_ms, + (500, 30000), + 500, + pad, + |c, v| c.dynamic.period_ms = v, + cx, + ); + let idle = self.lab_number( + "lab-light-idle", + l.idle, + cfg.dynamic.idle_seconds, + (0, 600), + 10, + pad, + |c, v| c.dynamic.idle_seconds = v, + cx, + ); + panel(&t) + .child(caption(l.light_note, t.text2)) + .child(mode) + .when(matches!(cfg.dynamic.mode, LightMode::Rainbow | LightMode::Breathe), |d| d.child(period)) + .child(idle) + .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 { + 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() + } + + 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)) + }) + } +} diff --git a/crates/open-controller-ui/src/main.rs b/crates/open-controller-ui/src/main.rs index 55a922b..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; @@ -19,6 +20,7 @@ mod settings; mod sticks; mod text_field; mod theme; +mod touch_animation; mod tuning; mod ui; mod updates; @@ -451,7 +453,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..de7d55c 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, @@ -157,6 +158,10 @@ 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, + pub lab_tab: u8, + pub lab_numbers: HashMap<&'static str, crate::lab::Number>, _subscriptions: Vec, } @@ -174,14 +179,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 +218,10 @@ impl MainView { brand: brand(window.scale_factor()), held: 0, 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); @@ -269,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; @@ -449,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); @@ -559,7 +575,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,17 +608,32 @@ 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() } } -/// 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, 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) +} + +/// 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 @@ -671,6 +702,22 @@ 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); + } + 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)); + } + 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 } => {