diff --git a/Cargo.lock b/Cargo.lock index 4d69855..0ce5aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4308,6 +4308,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "windows 0.62.2", "windows-sys 0.61.2", "x11rb", ] @@ -4321,6 +4322,7 @@ dependencies = [ "gpui-pre", "gpui-pre-platform", "open-controller-core", + "raw-window-handle", "serde", "serde_json", "unicode-segmentation", diff --git a/README.md b/README.md index 4609f0a..ddaf7df 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,22 @@ already opened it keeps it after it goes back to Xbox, so it would read it twice without a serial number shares its settings with every controller of its model, and so does this choice. With "Hide original controllers" off, native only removes the Xbox controller. +The Light page can explicitly opt into light-bar control while input stays native. This is off +by default; a game that also sets the light can compete with it. Custom colours accept a full +`#RRGGBB` value as well as the colour swatches. + +The preview follows both touchpad contacts, including movement without clicking, with +frame-timed smoothing and a short fading trail in the focused detailed preview. Unfocused +windows and reduced-motion mode keep direct feedback instead; losing focus clears old trails. +Both contacts and trails follow the configured LED colour, with the theme accent as a fallback +when the LED colour is black or off. This only affects drawing, never controller input. On Windows, +the DualSense microphone indicator shows the actual mute state of the default recording device. +Its separate, opt-in mic-button switch toggles that device; another binding for the button takes +priority. Bluetooth may use the PC microphone, since it need not expose a DualSense recording +device. This reads the endpoint's mute flag and never records audio. + +Windows uses one application-drawn title bar with the usual window controls. + ## Supported controllers Controllers are read through [SDL 3](https://libsdl.org), which speaks each one's own protocol, diff --git a/crates/open-controller-core/Cargo.toml b/crates/open-controller-core/Cargo.toml index a9cdce2..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..327f5fd 100644 --- a/crates/open-controller-core/examples/windows/loopback.rs +++ b/crates/open-controller-core/examples/windows/loopback.rs @@ -217,7 +217,13 @@ fn main() { engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.extras.contains(&TOUCH_LEFT)) }); check(pad.features.touchpad && pad.features.motion, "a touchpad and a gyro are seen, and the touchpad's halves listed"); + engine.send(Command::Watch(true)); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, true, 0.2, 0.5, 1.0) }; + wait_for("touch coordinates in the preview", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) + && p.input.touches[0] == open_controller_core::mapping::Touch::from_position(true, 0.2, 0.5)) + }); + check(true, "touching without clicking reaches the preview with its position"); button(TOUCHPAD, true); wait_for("a click on the left half as X", Duration::from_millis(500), x(true)); button(TOUCHPAD, false); @@ -229,6 +235,21 @@ fn main() { button(TOUCHPAD, false); unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 0, false, 0.8, 0.5, 0.0) }; check(right_is_not_x, "a click on the left half of the touchpad is a button of its own, the right half another"); + wait_for("released touch clears the preview", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && !p.input.touches[0].down) + }); + unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, true, 0.7, 0.2, 1.0) }; + wait_for("the second finger independently reaches the preview", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) + && !p.input.touches[0].down + && p.input.touches[1] == open_controller_core::mapping::Touch::from_position(true, 0.7, 0.2)) + }); + unsafe { SDL_SetJoystickVirtualTouchpad(joy, 0, 1, false, 0.7, 0.2, 0.0) }; + wait_for("both fingers release", Duration::from_secs(1), || { + engine.snapshot().pads.into_iter().find(|p| p.store.as_deref() == Some(PROFILE) && p.input.touches.iter().all(|t| !t.down)) + }); + check(true, "both touchpad fingers and their independent releases reach the preview"); + engine.send(Command::Watch(false)); // A profile for a program: in front, its assignments; away, the chosen profile's again. profiles.apply(Edit::Select(0)); diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index 4c95d24..e61a7d7 100644 --- a/crates/open-controller-core/src/engine.rs +++ b/crates/open-controller-core/src/engine.rs @@ -185,6 +185,9 @@ pub enum Role { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PadView { + /// Actual mute flag of Windows' default input endpoint, not a latched button press. + #[serde(default)] + pub microphone_muted: Option, pub key: PadKey, pub name: String, pub brand: Brand, @@ -541,6 +544,8 @@ impl Target { } struct Core { + microphone: crate::microphone::Monitor, + microphone_seen: Option, bus: Option>, /// Read once on connecting: the device tree is slow to query, and this is the input thread. bus_version: Option, @@ -638,6 +643,8 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang HANDHELD_STOP.store(false, Ordering::Relaxed); let handheld_reader = handheld::start(handheld_tx, &HANDHELD_STOP); let mut core = Core { + microphone: crate::microphone::Monitor::start(), + microphone_seen: None, bus: None, bus_version: None, bus_error: None, @@ -715,6 +722,11 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang core.macro_buttons(); core.feedback(now); core.housekeeping(now); + let muted = core.microphone.muted(); + if core.microphone_seen != muted { + core.microphone_seen = muted; + core.changed = true; + } let waiting = core.roster.slots().iter().any(|s| s.lost_since.is_some()); let countdown = waiting && now.duration_since(last_countdown) >= Duration::from_millis(500); @@ -1699,7 +1711,13 @@ impl Core { for p in self.phys.values_mut() { // Where controllers stay as they are, their lights are still OpenController's to set. let player = match p.kind { - Kind::Slot(_) if native(&self.profiles, &p.model.store, p.model.form) => continue, + Kind::Slot(_) + if native(&self.profiles, &p.model.store, p.model.form) + && !self.profiles.get(&p.model.store).is_some_and(|x| x.native_light) => + { + p.led = None; + continue; + } Kind::Slot(s) => self.targets.get(&s).and_then(|t| t.player), Kind::Native if !VIRTUAL_PADS => None, _ => continue, @@ -1777,6 +1795,22 @@ impl Core { let aim_before = p.aim_out; let (rate_before, accel_before) = (p.rate, p.accel); p.pad.read(&mut p.state); + let mic_owner = match p.kind { + Kind::Slot(s) => self.roster.get(s).and_then(|s| s.source) == Some(id), + Kind::Native => true, + _ => false, + }; + if mic_owner + && matches!(p.model.family, Family::DualSense | Family::DualSenseEdge) + && self.profiles.get(&p.model.store).is_some_and(|x| x.mic_control) + && !self + .profiles + .get(&p.model.store) + .is_some_and(|x| x.for_program(&self.foreground).bindings.contains_key(&(crate::mapping::button::MISC1 as u8))) + && crate::microphone::pressed(before.buttons, p.state.buttons) + { + self.microphone.toggle(); + } if p.model.family == Family::Handheld && p.kind == Kind::Native { p.state.buttons |= self.handheld_held; } @@ -2228,6 +2262,7 @@ impl Core { // A controller that copies another's ids goes by the name of the model its owner chose. let alias = model.and_then(|m| models::alias(m.vendor, m.product, chosen)); PadView { + microphone_muted: model.filter(|m| matches!(m.family, Family::DualSense | Family::DualSenseEdge)).and(self.microphone_seen), key, name: alias.map(|a| a.4.to_string()).or_else(|| model.map(|m| m.name.clone())).unwrap_or_default(), brand: model.map(|m| m.brand).unwrap_or(Brand::Other), diff --git a/crates/open-controller-core/src/i18n.rs b/crates/open-controller-core/src/i18n.rs index 236f730..f4648b9 100644 --- a/crates/open-controller-core/src/i18n.rs +++ b/crates/open-controller-core/src/i18n.rs @@ -121,6 +121,15 @@ pub struct Text { pub touchpad_click: &'static str, pub capture: &'static str, pub mic: &'static str, + pub native_light: &'static str, + pub native_light_hint: &'static str, + pub mic_sync: &'static str, + pub mic_sync_hint: &'static str, + pub mic_muted: &'static str, + pub mic_live: &'static str, + pub mic_unavailable: &'static str, + pub rgb_hex: &'static str, + pub rgb_hex_hint: &'static str, pub share: &'static str, pub extra_n: &'static str, @@ -584,6 +593,15 @@ pub const EN: Text = Text { touchpad_click: "Touchpad click", capture: "Capture", mic: "Mic", + native_light: "Control lighting in native mode", + native_light_hint: "Keeps native input. Lighting may conflict with a game that also controls it.", + mic_sync: "Mic button controls Windows default microphone", + mic_sync_hint: "Press to toggle actual mute. Other assignments take priority. Bluetooth may use the PC microphone.", + mic_muted: "Windows microphone: muted · active", + mic_live: "Windows microphone: unmuted · inactive", + mic_unavailable: "Windows microphone: unavailable", + rgb_hex: "RGB colour · #RRGGBB", + rgb_hex_hint: "Enter six hexadecimal digits to apply.", share: "Share", extra_n: "Extra {}", @@ -1052,6 +1070,15 @@ pub const PT: Text = Text { touchpad_click: "Clique do touchpad", capture: "Captura", mic: "Microfone", + native_light: "Controlar luz no modo nativo", + native_light_hint: "Mantém a entrada nativa. A luz pode entrar em conflito com o jogo.", + mic_sync: "Botão de microfone controla a entrada padrão do Windows", + mic_sync_hint: "Aperte para alternar o mudo real. Outros mapeamentos têm prioridade. Bluetooth pode usar o microfone do PC.", + mic_muted: "Microfone do Windows: mudo · ativo", + mic_live: "Microfone do Windows: aberto · inativo", + mic_unavailable: "Microfone do Windows: indisponível", + rgb_hex: "Cor RGB · #RRGGBB", + rgb_hex_hint: "Digite seis dígitos hexadecimais para aplicar.", share: "Compartilhar", extra_n: "Extra {}", diff --git a/crates/open-controller-core/src/i18n/zh.rs b/crates/open-controller-core/src/i18n/zh.rs index 9960220..a628a7a 100644 --- a/crates/open-controller-core/src/i18n/zh.rs +++ b/crates/open-controller-core/src/i18n/zh.rs @@ -105,6 +105,15 @@ pub const ZH: Text = Text { touchpad_click: "触摸板按下", capture: "截图键", mic: "麦克风键", + native_light: "原生模式下控制灯光", + native_light_hint: "保留原始输入。若游戏也控制灯光,双方可能互相覆盖。", + mic_sync: "麦克风键控制 Windows 默认麦克风", + mic_sync_hint: "按一下切换实际静音;此键的其他映射优先。蓝牙下可能使用电脑麦克风。", + mic_muted: "Windows 麦克风:已静音 · active", + mic_live: "Windows 麦克风:未静音 · inactive", + mic_unavailable: "Windows 麦克风:不可用", + rgb_hex: "RGB 颜色 · #RRGGBB", + rgb_hex_hint: "输入完整的六位十六进制颜色即可应用。", share: "分享键", extra_n: "额外按键 {}", tab_xbox: "Xbox 按键", diff --git a/crates/open-controller-core/src/lib.rs b/crates/open-controller-core/src/lib.rs index b55512b..269cd3f 100644 --- a/crates/open-controller-core/src/lib.rs +++ b/crates/open-controller-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod ds4; pub mod extras; pub mod i18n; pub mod mapping; +mod microphone; pub mod models; pub mod motion; pub mod profile; diff --git a/crates/open-controller-core/src/mapping.rs b/crates/open-controller-core/src/mapping.rs index ad46028..26b39de 100644 --- a/crates/open-controller-core/src/mapping.rs +++ b/crates/open-controller-core/src/mapping.rs @@ -57,6 +57,23 @@ pub mod xusb { pub const Y: u16 = 0x8000; } +/// A finger on a touchpad, in normalised 0..=65535 coordinates. Released fingers are zeroed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Touch { + pub down: bool, + pub x: u16, + pub y: u16, +} + +impl Touch { + pub fn from_position(down: bool, x: f32, y: f32) -> Self { + if !down || !x.is_finite() || !y.is_finite() { + return Self::default(); + } + Self { down, x: (x.clamp(0., 1.) * 65535.).round() as u16, y: (y.clamp(0., 1.) * 65535.).round() as u16 } + } +} + /// One gamepad's input as SDL reports it: a bit per button, sticks in -32768..=32767 with Y /// growing downwards, triggers in 0..=32767. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -65,6 +82,8 @@ pub struct PadState { /// (`extras::TOUCH_LEFT` and up). pub buttons: u64, pub axes: [i16; axis::COUNT], + #[serde(default)] + pub touches: [Touch; 2], /// How hard each pressure-sensitive button is pressed, 0 to 255, in [`PRESSURE`]'s order; all /// 0 on a controller without them. #[serde(default)] @@ -178,7 +197,9 @@ const NOISE: i32 = 2048; /// A change a person made: a button, or an axis moved further than stick noise. pub fn significant(before: &PadState, after: &PadState) -> bool { - before.buttons != after.buttons || before.axes.iter().zip(after.axes).any(|(&a, b)| (a as i32 - b as i32).abs() > NOISE) + before.buttons != after.buttons + || before.touches != after.touches + || before.axes.iter().zip(after.axes).any(|(&a, b)| (a as i32 - b as i32).abs() > NOISE) } /// XInput motor speed (0..=255) to SDL's rumble intensity (0..=65535). @@ -190,6 +211,16 @@ pub fn motor(v: u8) -> u16 { mod tests { use super::*; + #[test] + fn touch_contacts_are_bounded_and_release_clears_stale_coordinates() { + assert_eq!(Touch::from_position(true, 0., 1.), Touch { down: true, x: 0, y: 65535 }); + assert_eq!(Touch::from_position(true, -1., 2.), Touch { down: true, x: 0, y: 65535 }); + assert_eq!(Touch::from_position(false, 0.8, 0.5), Touch::default()); + assert_eq!(Touch::from_position(true, f32::NAN, 0.), Touch::default()); + let old = r#"{"buttons":0,"axes":[0,0,0,0,0,0]}"#; + assert_eq!(serde_json::from_str::(old).unwrap().touches, [Touch::default(); 2]); + } + fn state(buttons: &[u32], axes: [i16; 6]) -> PadState { PadState { buttons: buttons.iter().fold(0, |b, &i| b | 1 << i), axes, ..PadState::default() } } diff --git a/crates/open-controller-core/src/microphone.rs b/crates/open-controller-core/src/microphone.rs new file mode 100644 index 0000000..1595f39 --- /dev/null +++ b/crates/open-controller-core/src/microphone.rs @@ -0,0 +1,99 @@ +//! Windows' default recording endpoint. Only its mute flag is read; audio is never captured. +//! COM and endpoint discovery stay on a worker, away from the controller's input thread. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +pub struct Monitor { + state: Arc, + #[cfg(windows)] + tx: std::sync::mpsc::SyncSender<()>, +} + +impl Monitor { + pub fn start() -> Self { + let state = Arc::new(AtomicU8::new(0)); + #[cfg(windows)] + { + let (tx, rx) = std::sync::mpsc::sync_channel(8); + let shared = state.clone(); + let _ = std::thread::Builder::new().name("open-controller-microphone".into()).spawn(move || windows_worker(rx, shared)); + Self { state, tx } + } + #[cfg(not(windows))] + Self { state } + } + + pub fn muted(&self) -> Option { + match self.state.load(Ordering::Acquire) { + 1 => Some(false), + 2 => Some(true), + _ => None, + } + } + + pub fn toggle(&self) { + #[cfg(windows)] + let _ = self.tx.try_send(()); + } +} + +#[cfg(windows)] +fn windows_worker(rx: std::sync::mpsc::Receiver<()>, state: Arc) { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Duration; + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{IMMDeviceEnumerator, MMDeviceEnumerator, eCapture, eConsole}; + use windows::Win32::System::Com::{CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize}; + + unsafe { + if CoInitializeEx(None, COINIT_MULTITHREADED).is_err() { + return; + } + let query = |toggle: bool| -> windows::core::Result { + let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let endpoint = enumerator.GetDefaultAudioEndpoint(eCapture, eConsole)?; + let volume: IAudioEndpointVolume = endpoint.Activate(CLSCTX_ALL, None)?; + let muted = volume.GetMute()?.as_bool(); + if toggle { + volume.SetMute(!muted, std::ptr::null())?; + } + Ok(volume.GetMute()?.as_bool()) + }; + let mut toggle = false; + loop { + let value = match query(toggle) { + Ok(false) => 1, + Ok(true) => 2, + Err(_) => 0, + }; + state.store(value, Ordering::Release); + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(()) => toggle = true, + Err(RecvTimeoutError::Timeout) => toggle = false, + Err(RecvTimeoutError::Disconnected) => break, + } + } + CoUninitialize(); + } +} + +pub fn pressed(before: u64, after: u64) -> bool { + let mask = 1 << crate::mapping::button::MISC1; + before & mask == 0 && after & mask != 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mic_press_toggles_once_until_released() { + let mic = 1 << crate::mapping::button::MISC1; + assert!(pressed(0, mic)); + assert!(!pressed(mic, mic)); + assert!(!pressed(mic, 0)); + assert!(!pressed(0, 1)); + assert!(pressed(0, mic)); + } +} diff --git a/crates/open-controller-core/src/profile.rs b/crates/open-controller-core/src/profile.rs index 3f0d0a2..382d104 100644 --- a/crates/open-controller-core/src/profile.rs +++ b/crates/open-controller-core/src/profile.rs @@ -386,6 +386,10 @@ pub struct Profiles { /// Left as it is for games: no Xbox controller is made for it and it is not hidden, so /// none of the profiles apply. A version that does not know this field reads it as off. pub native: bool, + /// Opt-in lighting while game input stays native. Off preserves the original no-output mode. + pub native_light: bool, + /// The DualSense mic button toggles the Windows default capture endpoint, even in native mode. + pub mic_control: bool, /// Made an Xbox controller although it is not a gamepad (a flight stick, a throttle, a /// wheel), which is otherwise left as it is (see [`Form::left_as_is`]). pub xbox: bool, @@ -437,6 +441,8 @@ pub enum Edit { Select(usize), /// Keeps the controller native, or makes it an Xbox controller again. Native(bool), + NativeLight(bool), + MicControl(bool), /// Makes a controller that is not a gamepad an Xbox controller, or leaves it as it is again. Xbox(bool), /// Says which model the controller is (see [`Profiles::model`]). @@ -449,6 +455,8 @@ impl Profiles { list: vec![Profile { name: first, ..Profile::default() }], active: 0, native: false, + native_light: false, + mic_control: false, xbox: false, model: None, output: Output::Xbox360, @@ -512,6 +520,8 @@ impl Profiles { /// Fields a newer version wrote count as a change, so they are not thrown away with it. pub fn is_default(&self) -> bool { !self.native + && !self.native_light + && !self.mic_control && !self.xbox && self.model.is_none() && self.output == Output::Xbox360 @@ -606,6 +616,8 @@ impl Profiles { Edit::Select(i) if i < self.list.len() => self.active = i, Edit::Select(_) => {} Edit::Native(on) => self.native = on, + Edit::NativeLight(on) => self.native_light = on, + Edit::MicControl(on) => self.mic_control = on, Edit::Xbox(on) => (self.xbox, self.native) = (on, false), Edit::Model(key) => self.model = key.filter(|k| !k.is_empty() && k.len() <= 40), } @@ -757,6 +769,20 @@ pub fn light_color(light: Light, brightness: u8, player: Option, battery: Op #[cfg(test)] mod tests { use super::*; + + #[test] + fn native_lighting_and_mic_control_are_opt_in_and_survive_saving() { + let mut p = Profiles::default(); + assert!(!p.native_light && !p.mic_control); + p.apply(Edit::NativeLight(true)); + assert!(!p.is_default()); + p.apply(Edit::MicControl(true)); + p.apply(Edit::Native(true)); + let loaded: Profiles = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + assert!(loaded.native && loaded.native_light && loaded.mic_control); + p.apply(Edit::MicControl(false)); + assert!(p.native && p.native_light); + } use crate::binding::XboxButton; #[test] diff --git a/crates/open-controller-core/src/sdl.rs b/crates/open-controller-core/src/sdl.rs index 339c8fe..2256d55 100644 --- a/crates/open-controller-core/src/sdl.rs +++ b/crates/open-controller-core/src/sdl.rs @@ -159,6 +159,9 @@ impl Sdl { SDL_EVENT_GAMEPAD_ADDED => Event::GamepadAdded(ev.gdevice.which.0), SDL_EVENT_GAMEPAD_REMOVED => Event::GamepadRemoved(ev.gdevice.which.0), SDL_EVENT_GAMEPAD_UPDATE_COMPLETE => Event::GamepadUpdated(ev.gdevice.which.0), + SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN | SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION | SDL_EVENT_GAMEPAD_TOUCHPAD_UP => { + Event::GamepadUpdated(ev.gtouchpad.which.0) + } SDL_EVENT_JOYSTICK_BATTERY_UPDATED => Event::Battery(ev.jbattery.which.0), SDL_EVENT_JOYSTICK_ADDED => Event::JoystickAdded(ev.jdevice.which.0), SDL_EVENT_JOYSTICK_REMOVED => Event::JoystickRemoved(ev.jdevice.which.0), @@ -751,7 +754,7 @@ impl Gamepad { b |= 1 << i; } } - b |= self.touch_buttons(b & 1 << button::TOUCHPAD != 0); + b |= self.touch_buttons(b & 1 << button::TOUCHPAD != 0, out); if self.wii_dpad { let js = unsafe { SDL_GetGamepadJoystick(self.gp) }; for i in button::DPAD_UP..=button::DPAD_RIGHT { @@ -775,8 +778,9 @@ impl Gamepad { } /// The touchpad's halves and two-finger touch, as the bits OpenController gives them. - fn touch_buttons(&self, clicked: bool) -> u64 { + fn touch_buttons(&self, clicked: bool, out: &mut PadState) -> u64 { unsafe { + out.touches = Default::default(); if SDL_GetNumGamepadTouchpads(self.gp) == 0 { return 0; } @@ -785,6 +789,9 @@ impl Gamepad { for (i, f) in fingers.iter_mut().enumerate().take(n as usize) { let mut p = 0f32; SDL_GetGamepadTouchpadFinger(self.gp, 0, i as i32, &mut f.down, &mut f.x, &mut f.y, &mut p); + if let Some(touch) = out.touches.get_mut(i) { + *touch = crate::mapping::Touch::from_position(f.down, f.x, f.y); + } } let (left, right, two) = touch_buttons(clicked, &fingers[..n as usize]); u64::from(left) << TOUCH_LEFT | u64::from(right) << TOUCH_RIGHT | u64::from(two) << TOUCH_TWO diff --git a/crates/open-controller-ui/Cargo.toml b/crates/open-controller-ui/Cargo.toml index 314f860..360bf1d 100644 --- a/crates/open-controller-ui/Cargo.toml +++ b/crates/open-controller-ui/Cargo.toml @@ -23,6 +23,7 @@ serde_json.workspace = true unicode-segmentation = "1" [target.'cfg(windows)'.dependencies] +raw-window-handle = "0.6" windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } [build-dependencies] diff --git a/crates/open-controller-ui/src/art.rs b/crates/open-controller-ui/src/art.rs index c9c21db..824aa43 100644 --- a/crates/open-controller-ui/src/art.rs +++ b/crates/open-controller-ui/src/art.rs @@ -834,13 +834,49 @@ fn icon(name: &str, x: f32, y: f32, r: f32) -> Option<(Ops, Option)> { }) } +fn pad_light_supported(look: Look) -> bool { + matches!(look, Look::DualSense | Look::DualSenseEdge | Look::DualShock4) +} + +fn touch_position((left, top, right, bottom): (f32, f32, f32, f32), (x, y): (f32, f32)) -> (f32, f32) { + (left + (right - left) * x, top + (bottom - top) * y) +} + /// The drawing of a controller in its current state, `scale` times the size of a 320-wide one. pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { + controller_with_touches(pad, scale, t, crate::touch_animation::Frame::immediate(pad.input.touches)) +} + +pub fn controller_with_touches(pad: &PadView, scale: f32, t: &Theme, touches: crate::touch_animation::Frame) -> AnyElement { let look = look(pad); let glyphs = glyphs(pad, look); let state: PadState = pad.input; + let mic_muted = pad.microphone_muted == Some(true); + let light = match pad.power { + open_controller_core::device::Power::Battery(v) | open_controller_core::device::Power::Charging(v) => v, + _ => None, + }; + let light = + open_controller_core::profile::light_color(pad.profile().light, pad.profile().brightness, crate::home::player(&pad.role), light); + let light_owned = pad.role != open_controller_core::Role::KeptNative || pad.profiles.native_light; + let light_ink: Hsla = rgb(u32::from(light[0]) << 16 | u32::from(light[1]) << 8 | u32::from(light[2])).into(); let k = scale * UNIT; let ink = ink(t); + // Match the configured LED hue, while keeping feedback visible at low brightness or off. + let touch_light = open_controller_core::profile::light_color( + pad.profile().light, + 100, + crate::home::player(&pad.role), + match pad.power { + open_controller_core::device::Power::Battery(v) | open_controller_core::device::Power::Charging(v) => v, + _ => None, + }, + ); + let touch_ink: Hsla = if touch_light == [0; 3] { + ink.lit + } else { + rgb(u32::from(touch_light[0]) << 16 | u32::from(touch_light[1]) << 8 | u32::from(touch_light[2])).into() + }; let g = pad_for(look); let (turn, gripped) = grip_turn(g, &state); @@ -873,7 +909,11 @@ pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { pen.fill(&g.body, linear_gradient(180., linear_color_stop(ink.body_top, 0.), linear_color_stop(ink.body_bottom, 1.))); pen.stroke(&g.body, 1., ink.edge); for (style, ops) in &g.panels { - panel(&mut pen, style, ops, k, &ink); + if style == "light" && light_owned && pad_light_supported(look) { + pen.shape(ops, light_ink.opacity(0.75), light_ink); + } else { + panel(&mut pen, style, ops, k, &ink); + } } for &[x, y, r] in &g.dots { pen.fill(&circle(x, y, r), ink.soft); @@ -881,6 +921,27 @@ pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { if let Some(tp) = &g.touchpad { let b = Some(button::TOUCHPAD); pen.shape(tp, lit_or(b, ink.part), lit_or(b, ink.edge)); + let (left, top, right, bottom) = extent(tp); + // Keep both contacts inside the inset rectangle of the curved touch surface. + let inset = 11.; + let surface = (left + inset, top + inset, right - inset, bottom - inset); + for (i, touch) in touches.fingers.iter().enumerate() { + let color = touch_ink; + for segment in touch.trail.windows(2) { + let (x0, y0) = touch_position(surface, segment[0].0); + let (x1, y1) = touch_position(surface, segment[1].0); + let fade = segment[0].1; + let path = line(x0, y0, x1, y1); + pen.stroke(&path, (4.5 * k).max(1.), color.opacity(fade * 0.18)); + pen.stroke(&path, ((0.7 + 1.6 * fade) * k).max(0.6), color.opacity(fade * 0.8)); + pen.fill(&circle(x1, y1, 0.35 + fade * 0.8), color.opacity(fade * 0.65)); + } + let Some(head) = touch.head else { continue }; + let (x, y) = touch_position(surface, head); + pen.fill(&circle(x, y, 8.), color.opacity(0.18)); + pen.stroke(&circle(x, y, if i == 0 { 5. } else { 6.5 }), (1.3 * k).max(1.), color); + pen.fill(&circle(x, y, 2.2), color); + } } // Levers set into the body, filled from the back, with their handle where it is. @@ -935,8 +996,9 @@ pub fn controller(pad: &PadView, scale: f32, t: &Theme) -> AnyElement { } } for b in g.buttons.iter().filter(|b| b.grip == on_grip) { - let fill = lit_or(b.input, ink.part); - let edge = lit_or(b.input, ink.edge); + let muted = b.input == Some(button::MISC1) && mic_muted; + let fill = if muted { rgb(0xffa928).into() } else { lit_or(b.input, ink.part) }; + let edge = if muted { rgb(0xffc76a).into() } else { lit_or(b.input, ink.edge) }; let (ops, centre) = match &b.shape { &Shape::Circle(x, y, r) => { let (x, y) = turned.at(x, y); diff --git a/crates/open-controller-ui/src/demo.rs b/crates/open-controller-ui/src/demo.rs index 7c0f37b..f31ed38 100644 --- a/crates/open-controller-ui/src/demo.rs +++ b/crates/open-controller-ui/src/demo.rs @@ -18,6 +18,7 @@ fn input(buttons: &[u32], axes: [i16; axis::COUNT]) -> PadState { pub(crate) fn pad(key: PadKey, name: &str, brand: Brand, family: Family, (vendor, product): (u16, u16)) -> PadView { PadView { + microphone_muted: None, key, name: name.into(), brand, diff --git a/crates/open-controller-ui/src/detail.rs b/crates/open-controller-ui/src/detail.rs index 8ac9e9a..0947ee9 100644 --- a/crates/open-controller-ui/src/detail.rs +++ b/crates/open-controller-ui/src/detail.rs @@ -9,7 +9,7 @@ use crate::keys; use crate::report; use crate::text_field::{TextField, TextFieldEvent}; use crate::theme::{Theme, icon as glyph, radius}; -use crate::ui::{MainView, Section, Tab}; +use crate::ui::{MainView, Screen, Section, Tab}; use crate::widgets::{ Kind, body, button, caption, card, chip, display, group, icon, icon_button, keycap, row, segmented, stage, strong, switch, title, }; @@ -173,6 +173,22 @@ pub struct Rename { _events: Subscription, } +pub struct ColorInput { + key: PadKey, + color: [u8; 3], + field: Entity, + _events: Subscription, +} + +fn parse_rgb(value: &str) -> Option<[u8; 3]> { + let s = value.trim().strip_prefix('#').unwrap_or(value.trim()); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let n = u32::from_str_radix(s, 16).ok()?; + Some([(n >> 16) as u8, (n >> 8) as u8, n as u8]) +} + fn profile_name(text: &Text, name: &str, i: usize) -> String { match (name.is_empty(), i) { (false, _) => name.to_string(), @@ -270,7 +286,7 @@ impl MainView { cx.notify(); } - pub fn render_device(&mut self, pad: &PadView, _: &mut Window, cx: &mut Context) -> AnyElement { + pub fn render_device(&mut self, pad: &PadView, window: &mut Window, cx: &mut Context) -> AnyElement { let t = self.theme; let text = self.model.read(cx).text; let key = pad.key; @@ -332,7 +348,7 @@ impl MainView { if assignable && !native(pad) { sections.push((Section::Sticks, glyph::STICKS, text.nav_sticks)); } - if pad.features.light_bar && assignable { + if pad.features.light_bar && pad.store.is_some() { sections.push((Section::Light, glyph::LIGHT, text.nav_light)); } sections.push((Section::Info, glyph::INFO, text.nav_info)); @@ -360,7 +376,7 @@ impl MainView { } let content = match current { - Section::Buttons => self.buttons_section(pad, text, cx), + Section::Buttons => self.buttons_section(pad, text, window, cx), Section::Light => self.light_section(pad, text, cx), Section::Motion => self.motion_section(pad, text, cx), Section::Sticks => self.sticks_section(pad, text, cx), @@ -672,14 +688,28 @@ impl MainView { .into_any_element() } - fn buttons_section(&self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + fn buttons_section(&mut self, pad: &PadView, text: &'static Text, window: &mut Window, cx: &mut Context) -> AnyElement { let t = self.theme; - let drawing = card(&t).flex().flex_col().flex_none().overflow_hidden().child( - stage(&t).flex().justify_center().px(px(20.)).pt(px(28.)).pb(px(20.)).child(match self.learning_pose(pad) { - Some(input) => art::controller(&PadView { input, ..pad.clone() }, 1.1, &t), - None => art::controller(pad, 1.1, &t), - }), - ); + let controller = match self.learning_pose(pad) { + Some(input) => art::controller(&PadView { input, ..pad.clone() }, 1.1, &t), + None if !window.is_window_active() || cx.reduce_motion() => { + self.touch_animation = crate::touch_animation::Animation::default(); + art::controller(pad, 1.1, &t) + } + None => { + let frame = self.touch_animation.frame(pad.key, pad.input.touches, std::time::Instant::now()); + if frame.animating { + window.request_animation_frame(); + } + art::controller_with_touches(pad, 1.1, &t, frame) + } + }; + let drawing = card(&t) + .flex() + .flex_col() + .flex_none() + .overflow_hidden() + .child(stage(&t).flex().justify_center().px(px(20.)).pt(px(28.)).pb(px(20.)).child(controller)); let hint = pad.hint.map(|h| hint_text(text, h)).or_else(|| read_directly(pad).then_some(text.linux_direct_read)).map(|h| { div() .flex() @@ -702,6 +732,9 @@ impl MainView { .flex_1() .min_w(px(320.)) .child(self.extras_column(pad, text, &t, cx)) + .when(cfg!(windows) && matches!(pad.family, Family::DualSense | Family::DualSenseEdge), |d| { + d.child(self.microphone_row(pad, text, cx)) + }) .when(remappable(pad), |d| d.child(self.all_buttons_column(pad, text, &t, cx))) .when(pad.features.pressure && remappable(pad), |d| d.child(self.pressure_card(pad, text, cx))) .when(pad.store.is_some() && !kept_native(pad) && !pad.profiles.active().bindings.is_empty(), |d| { @@ -835,7 +868,31 @@ impl MainView { )) } - fn light_section(&self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + fn microphone_row(&self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { + let t = self.theme; + let status = match pad.microphone_muted { + Some(true) => text.mic_muted, + Some(false) => text.mic_live, + None => text.mic_unavailable, + }; + div() + .flex() + .flex_col() + .mt(px(16.)) + .gap(px(8.)) + .child(strong(status, if pad.microphone_muted == Some(true) { t.caution } else { t.text2 })) + .child(self.toggle( + "mic-sync", + (text.mic_sync, text.mic_sync_hint), + pad.profiles.mic_control, + Edit::MicControl(!pad.profiles.mic_control), + pad.key, + cx, + )) + .into_any_element() + } + + fn light_section(&mut self, pad: &PadView, text: &'static Text, cx: &mut Context) -> AnyElement { let t = self.theme; let key = pad.key; let p = pad.profiles.active(); @@ -882,6 +939,24 @@ impl MainView { Light::Color(c) => c, _ => SWATCHES[7], }; + if self.color_input.as_ref().is_none_or(|c| c.key != key) { + let field = cx.new(|cx| TextField::new(cx, false, 7).placeholder("#RRGGBB")); + let events = cx.subscribe(&field, move |this, field, _: &TextFieldEvent, cx| { + if matches!(this.screen, Screen::Device(k, _) if k == key) + && let Some(color) = parse_rgb(&field.read(cx).text()) + { + this.edit_now(key, Edit::Light(Light::Color(color)), cx); + } + }); + self.color_input = Some(ColorInput { key, color: [0; 3], field, _events: events }); + } + let input = self.color_input.as_mut().unwrap(); + if input.color != current_color || input.field.read(cx).text().is_empty() { + input.color = current_color; + let value = format!("#{:02X}{:02X}{:02X}", current_color[0], current_color[1], current_color[2]); + input.field.update(cx, |f, cx| f.set_text(&value, cx)); + } + let color_field = input.field.clone(); let mut swatches = div().flex().flex_wrap().gap(px(8.)); for (i, c) in SWATCHES.into_iter().enumerate() { let on = color_on && current_color == c; @@ -956,6 +1031,17 @@ impl MainView { .flex_1() .min_w(px(320.)) .gap(px(4.)) + .when(kept_native(pad), |d| { + d.child(self.toggle( + "native-light", + (text.native_light, text.native_light_hint), + pad.profiles.native_light, + Edit::NativeLight(!pad.profiles.native_light), + key, + cx, + )) + .mb(px(12.)) + }) .child(option( "light-player", text.light_player, @@ -965,7 +1051,20 @@ impl MainView { cx, )) .child(option("light-color", text.light_color, None, color_on, Light::Color(current_color), cx)) - .when(color_on, |d| d.child(div().px(px(16.)).py(px(12.)).child(swatches))) + .when(color_on, |d| { + d.child( + div() + .flex() + .flex_col() + .gap(px(10.)) + .px(px(16.)) + .py(px(12.)) + .child(swatches) + .child(caption(text.rgb_hex, t.text2)) + .child(color_field) + .child(caption(text.rgb_hex_hint, t.text3)), + ) + }) .child(option( "light-battery", text.light_battery, @@ -1449,3 +1548,18 @@ impl MainView { .into_any_element() } } + +#[cfg(test)] +mod color_tests { + use super::parse_rgb; + + #[test] + fn rgb_accepts_full_colors_and_rejects_partial_or_non_hex_input() { + assert_eq!(parse_rgb("#00aAFF"), Some([0, 170, 255])); + assert_eq!(parse_rgb("000000"), Some([0, 0, 0])); + assert_eq!(parse_rgb(" FFFFFF "), Some([255; 3])); + for s in ["", "#", "#12345", "#1234567", "#GG0000", "汉字"] { + assert_eq!(parse_rgb(s), None, "{s}"); + } + } +} diff --git a/crates/open-controller-ui/src/frame.rs b/crates/open-controller-ui/src/frame.rs index ae220e7..33aa0a3 100644 --- a/crates/open-controller-ui/src/frame.rs +++ b/crates/open-controller-ui/src/frame.rs @@ -1,6 +1,6 @@ //! The window's own frame, where the desktop leaves it to the program, as GNOME does on Wayland: //! a shadow, edges to resize the window by, and minimize, maximize and close in the bar on top. -//! Where the system draws the frame (Windows, macOS, X11, KDE) none of this shows. +//! Windows uses the same title controls while keeping its system border and shadow. use crate::theme::{Theme, icon as glyph, layout, radius}; use crate::ui::MainView; @@ -19,6 +19,29 @@ const GAP: f32 = 4.; /// From the window's buttons to the side of the window. const EDGE: f32 = 10.; +pub fn custom_title_bar(window: &Window) -> bool { + cfg!(windows) || tiling(window).is_some() +} + +/// GPUI 0.3.7's Windows `zoom` only maximizes; restore explicitly through the same HWND. +fn toggle_maximized(window: &mut Window) { + #[cfg(windows)] + { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows_sys::Win32::UI::WindowsAndMessaging::{IsZoomed, SW_MAXIMIZE, SW_RESTORE, ShowWindowAsync}; + if let Ok(handle) = window.window_handle() + && let RawWindowHandle::Win32(handle) = handle.as_raw() + { + let hwnd = handle.hwnd.get() as windows_sys::Win32::Foundation::HWND; + unsafe { + ShowWindowAsync(hwnd, if IsZoomed(hwnd) != 0 { SW_RESTORE } else { SW_MAXIMIZE }); + } + return; + } + } + window.zoom_window(); +} + /// Which sides of the window touch the screen's edges or another window, when the program /// draws the frame; `None` when the system does. pub fn tiling(window: &Window) -> Option { @@ -124,15 +147,19 @@ impl MainView { /// moves the window, a double click maximizes, a right click opens the window menu, and the /// window's buttons sit at its ends. `row` is the bar's content, kept clear of them. pub fn title_bar(&self, bar: Div, row: Div, window: &Window, cx: &mut Context) -> Div { - if tiling(window).is_none() { + if !custom_title_bar(window) { return bar.child(row); } let t = self.theme; let (left, right) = buttons(window, cx); let can = window.window_controls(); let maximized = window.is_maximized(); - let width = inner_width(window); - let margin = (width - width.min(layout::MAX_W)) / 2.; + let margin = if cfg!(windows) { + 0. + } else { + let width = inner_width(window); + (width - width.min(layout::MAX_W)) / 2. + }; let room = |n: usize| { let own = layout::GUTTER - 6.; if n == 0 { own } else { own.max(EDGE + n as f32 * (BUTTON + GAP) + 4. - margin) } @@ -155,7 +182,7 @@ impl MainView { cx.listener(move |this, ev: &MouseDownEvent, window, _| { this.moving = ev.click_count < 2; if ev.click_count == 2 && can.maximize { - window.zoom_window(); + toggle_maximized(window); } }), ) @@ -205,7 +232,7 @@ fn window_button(b: WindowButton, maximized: bool, t: &Theme, cx: &mut Context window.minimize_window(), - WindowButton::Maximize => window.zoom_window(), + WindowButton::Maximize => toggle_maximized(window), WindowButton::Close => window.remove_window(), })) } diff --git a/crates/open-controller-ui/src/main.rs b/crates/open-controller-ui/src/main.rs index 55a922b..8cb8852 100644 --- a/crates/open-controller-ui/src/main.rs +++ b/crates/open-controller-ui/src/main.rs @@ -19,6 +19,7 @@ mod settings; mod sticks; mod text_field; mod theme; +mod touch_animation; mod tuning; mod ui; mod updates; @@ -451,7 +452,7 @@ fn forced_lang() -> Option { fn open_window(model: Entity, cx: &mut App) -> Option> { let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, size(px(1160.), px(800.)), cx))), - titlebar: Some(TitlebarOptions { title: Some("OpenController".into()), ..Default::default() }), + titlebar: Some(TitlebarOptions { title: Some("OpenController".into()), appears_transparent: cfg!(windows), ..Default::default() }), // The Wayland app id and the X11 class: the .desktop file's name, so the dock shows its icon. app_id: Some("io.github.brunovncs.open-controller".into()), window_min_size: Some(size(px(720.), px(520.))), diff --git a/crates/open-controller-ui/src/touch_animation.rs b/crates/open-controller-ui/src/touch_animation.rs new file mode 100644 index 0000000..5868e8b --- /dev/null +++ b/crates/open-controller-ui/src/touch_animation.rs @@ -0,0 +1,154 @@ +//! Presentation-only touch smoothing. Raw controller input is never changed. + +use open_controller_core::PadKey; +use open_controller_core::mapping::Touch; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +type Position = (f32, f32); +const TRAIL_LIFE: Duration = Duration::from_millis(240); +const RESPONSE: f32 = 0.014; +const MIN_STEP: f32 = 0.0007; +const MAX_SAMPLES: usize = 48; + +#[derive(Default)] +pub struct Animation { + key: Option, + last: Option, + fingers: [Finger; 2], +} + +#[derive(Default)] +struct Finger { + down: bool, + position: Position, + sampled: Position, + trail: VecDeque<(Instant, Position)>, +} + +#[derive(Default)] +pub struct Frame { + pub fingers: [FingerFrame; 2], + pub animating: bool, +} + +#[derive(Default)] +pub struct FingerFrame { + pub head: Option, + /// Oldest to newest; each point carries its remaining lifetime, from zero to one. + pub trail: Vec<(Position, f32)>, +} + +fn position(touch: Touch) -> Position { + (f32::from(touch.x) / 65535., f32::from(touch.y) / 65535.) +} + +fn distance(a: Position, b: Position) -> f32 { + (a.0 - b.0).hypot(a.1 - b.1) +} + +impl Frame { + pub fn immediate(touches: [Touch; 2]) -> Self { + Self { + fingers: touches.map(|touch| FingerFrame { head: touch.down.then(|| position(touch)), trail: Vec::new() }), + animating: false, + } + } +} + +impl Animation { + pub fn frame(&mut self, key: PadKey, touches: [Touch; 2], now: Instant) -> Frame { + // A different page/controller or a long-hidden preview starts with fresh contacts. + if self.key != Some(key) || self.last.is_some_and(|last| now.duration_since(last) > TRAIL_LIFE) { + *self = Self { key: Some(key), ..Self::default() }; + } + let dt = self.last.map_or(0., |last| now.duration_since(last).as_secs_f32()); + self.last = Some(now); + let blend = 1. - (-dt / RESPONSE).exp(); + let mut frame = Frame::default(); + for ((finger, touch), painted) in self.fingers.iter_mut().zip(touches).zip(frame.fingers.iter_mut()) { + while finger.trail.front().is_some_and(|(time, _)| now.duration_since(*time) >= TRAIL_LIFE) { + finger.trail.pop_front(); + } + if touch.down { + let target = position(touch); + if !finger.down { + // Never connect the end of one gesture to the start of the next. + finger.trail.clear(); + finger.position = target; + finger.sampled = target; + finger.trail.push_back((now, target)); + } else { + finger.position.0 += (target.0 - finger.position.0) * blend; + finger.position.1 += (target.1 - finger.position.1) * blend; + if distance(finger.position, target) < MIN_STEP { + finger.position = target; + } else { + frame.animating = true; + } + if distance(finger.position, finger.sampled) >= MIN_STEP { + finger.trail.push_back((now, finger.position)); + finger.sampled = finger.position; + while finger.trail.len() > MAX_SAMPLES { + finger.trail.pop_front(); + } + } + } + painted.head = Some(finger.position); + } + finger.down = touch.down; + painted.trail = finger + .trail + .iter() + .map(|(time, point)| (*point, 1. - now.duration_since(*time).as_secs_f32() / TRAIL_LIFE.as_secs_f32())) + .collect(); + frame.animating |= !painted.trail.is_empty(); + } + frame + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contacts_and_gestures_do_not_join_and_released_trails_stop_animating() { + let mut animation = Animation::default(); + let key = PadKey::Slot(0); + let now = Instant::now(); + let touch = |x| Touch { down: true, x, y: 32768 }; + animation.frame(key, [touch(1000), touch(60000)], now); + let moved = animation.frame(key, [touch(30000), touch(40000)], now + Duration::from_millis(16)); + assert!(moved.fingers[0].head.unwrap().0 < moved.fingers[1].head.unwrap().0); + let released = animation.frame(key, [Touch::default(); 2], now + Duration::from_millis(32)); + assert!(released.fingers.iter().all(|f| f.head.is_none())); + assert!(released.animating); + let new = animation.frame(key, [touch(65000), Touch::default()], now + Duration::from_millis(48)); + assert_eq!(new.fingers[0].trail.len(), 1); + animation.frame(key, [Touch::default(); 2], now + Duration::from_millis(64)); + let faded = animation.frame(key, [Touch::default(); 2], now + Duration::from_millis(320)); + assert!(!faded.animating); + assert!(faded.fingers.iter().all(|f| f.trail.is_empty())); + } + + #[test] + fn smoothing_uses_elapsed_time_instead_of_frame_count() { + let finish = |steps: u32| { + let mut animation = Animation::default(); + let key = PadKey::Slot(0); + let now = Instant::now(); + let start = Touch { down: true, x: 0, y: 0 }; + let end = Touch { x: 65535, ..start }; + animation.frame(key, [start, Touch::default()], now); + let mut frame = Frame::default(); + for step in 1..=steps { + frame = + animation.frame(key, [end, Touch::default()], now + Duration::from_secs_f64(0.04 * f64::from(step) / f64::from(steps))); + } + frame.fingers[0].head.unwrap().0 + }; + assert!((finish(2) - finish(8)).abs() < 0.0001); + assert!(finish(8) > 0.9 && finish(8) < 1.); + } +} diff --git a/crates/open-controller-ui/src/ui.rs b/crates/open-controller-ui/src/ui.rs index b1e3f1b..80e7021 100644 --- a/crates/open-controller-ui/src/ui.rs +++ b/crates/open-controller-ui/src/ui.rs @@ -157,6 +157,8 @@ pub struct MainView { /// The bar on top was pressed where the window draws its own frame: moving the mouse now /// moves the window. pub moving: bool, + pub color_input: Option, + pub touch_animation: crate::touch_animation::Animation, _subscriptions: Vec, } @@ -174,14 +176,16 @@ impl MainView { let activation = cx.observe_window_activation(window, |this, window, cx| { // Behind a game, the drawings need not follow every frame of input. this.send(ToTray::Focused(window.is_window_active()), cx); + // Drop the old gesture immediately on blur; never replay it on reactivation. + this.touch_animation = crate::touch_animation::Animation::default(); // GPUI tells of light and dark changing but not of the accent colour, which is // picked in Windows' settings: coming back from them makes the window active. let now = theme(window); if now.accent != this.theme.accent { crate::theme::accent_changed(); this.theme = now; - cx.notify(); } + cx.notify(); }); let focus = cx.focus_handle(); focus.focus(window, cx); @@ -211,6 +215,8 @@ impl MainView { brand: brand(window.scale_factor()), held: 0, moving: false, + color_input: None, + touch_animation: crate::touch_animation::Animation::default(), _subscriptions: vec![observe, theme_change, activation], }; view.read_installed(cx); @@ -559,7 +565,7 @@ impl MainView { /// The bar across the top: where you are, and the way to the settings. fn top_bar(&self, window: &Window, cx: &mut Context) -> AnyElement { let t = self.theme; - let framed = crate::frame::tiling(window).is_some(); + let framed = crate::frame::custom_title_bar(window); let attention = self.needs_attention(cx); let on_settings = self.screen == Screen::Settings; let mark = img(self.brand.1.clone()).size(px(BRAND)).flex_none(); @@ -592,10 +598,11 @@ impl MainView { .items_center() .justify_between() .w_full() - .max_w(px(layout::MAX_W)) + .when(!cfg!(windows), |d| d.max_w(px(layout::MAX_W))) // The brand's own padding and the gear's hit area sit in the gutter. .px(px(layout::GUTTER - 6.)) .child(home) + .when(cfg!(windows), |d| d.child(div().flex_1().h_full().window_control_area(gpui::WindowControlArea::Drag))) .child(gear); self.title_bar(bar, row, window, cx).into_any_element() }