diff --git a/crates/open-controller-core/examples/linux/loopback.rs b/crates/open-controller-core/examples/linux/loopback.rs index b2f65f7..7ec60c2 100644 --- a/crates/open-controller-core/examples/linux/loopback.rs +++ b/crates/open-controller-core/examples/linux/loopback.rs @@ -100,7 +100,7 @@ fn check(ok: bool, what: &str) { fn main() { let mut profiles = Profiles::default(); profiles.apply(Edit::Bind { button: RIGHT_PADDLE1, action: Some(Action::Xbox(XboxButton::Y)) }); - profiles.apply(Edit::Bind { button: LEFT_PADDLE1, action: Some(Action::Keys(Chord { mods: 0, key: VK_F24 })) }); + profiles.apply(Edit::Bind { button: LEFT_PADDLE1, action: Some(Action::Keys(Chord::new(0, VK_F24))) }); let dir = std::env::temp_dir().join("open-controller-loopback"); let config = Config { data_dir: dir, hide: false, profiles: [(PROFILE.to_string(), profiles.clone())].into(), driver: Default::default(), split_joycons: false, mappings: Default::default() }; diff --git a/crates/open-controller-core/examples/windows/loopback.rs b/crates/open-controller-core/examples/windows/loopback.rs index 485c116..e7261b3 100644 --- a/crates/open-controller-core/examples/windows/loopback.rs +++ b/crates/open-controller-core/examples/windows/loopback.rs @@ -94,7 +94,7 @@ 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 })) }); + profiles.apply(Edit::Bind { button: LEFT_PADDLE1, action: Some(Action::Keys(Chord::new(0, 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 engine = Engine::start(config, || {}); diff --git a/crates/open-controller-core/src/binding.rs b/crates/open-controller-core/src/binding.rs index 588f28d..11a6066 100644 --- a/crates/open-controller-core/src/binding.rs +++ b/crates/open-controller-core/src/binding.rs @@ -194,17 +194,91 @@ pub mod modifier { pub const WIN: u8 = 8; } -/// A key with the modifiers held with it. `key` is a Windows virtual-key code; 0 is a chord of -/// modifiers only. +/// A simultaneous set of keys, with modifiers pressed first. `key` remains the first +/// Windows virtual-key code for compatibility with older configurations. Mouse buttons +/// use their Windows VKeys (1, 2, 4, 5, 6); additional keys are stored only when needed. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Chord { pub mods: u8, pub key: u16, + #[serde(default, skip_serializing_if = "ExtraKeys::is_empty")] + pub extra: ExtraKeys, +} + +/// A bounded, allocation-free set covering every Windows virtual key. Serialized as a +/// readable list of key codes rather than internal bit masks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "Vec", into = "Vec")] +pub struct ExtraKeys([u64; 4]); + +impl ExtraKeys { + fn is_empty(&self) -> bool { + self.0 == [0; 4] + } + + fn insert(&mut self, key: u16) { + if (1..=255).contains(&key) { + self.0[key as usize / 64] |= 1 << (key % 64); + } + } + + fn iter(&self) -> impl DoubleEndedIterator + use<> { + let bits = self.0; + (1..=255).filter(move |&key| bits[key as usize / 64] & (1 << (key % 64)) != 0) + } +} + +impl TryFrom> for ExtraKeys { + type Error = &'static str; + fn try_from(keys: Vec) -> Result { + let mut out = Self::default(); + for key in keys { + if !(1..=255).contains(&key) { + return Err("Invalid additional virtual-key code"); + } + out.insert(key); + } + Ok(out) + } +} + +impl From for Vec { + fn from(keys: ExtraKeys) -> Self { + keys.iter().collect() + } } impl Chord { + pub const fn new(mods: u8, key: u16) -> Self { + Self { mods, key, extra: ExtraKeys([0; 4]) } + } + + pub fn from_keys(mods: u8, keys: impl IntoIterator) -> Self { + let mut chord = Self::new(mods, 0); + for key in keys { + chord.add_key(key); + } + chord + } + + pub fn add_key(&mut self, key: u16) { + if key == 0 || key == self.key { + return; + } + if self.key == 0 { + self.key = key; + } else { + self.extra.insert(key); + } + } + + pub fn keys(&self) -> impl DoubleEndedIterator + use<> { + let first = self.key; + std::iter::once(first).filter(|&key| key != 0).chain(self.extra.iter().filter(move |&key| key != first)) + } + pub fn is_empty(&self) -> bool { - self.mods == 0 && self.key == 0 + self.mods == 0 && self.key == 0 && self.extra.is_empty() } } @@ -376,12 +450,24 @@ mod tests { r } + #[test] + fn multi_key_chords_round_trip_without_changing_legacy_files() { + let old = r#"{"mods":1,"key":86}"#; + let legacy: Chord = serde_json::from_str(old).unwrap(); + assert_eq!(serde_json::to_string(&legacy).unwrap(), old); + let chord = Chord::from_keys(modifier::CTRL, [0x56, 0x26, 0x26, 0x56]); + assert_eq!(chord.keys().collect::>(), [0x56, 0x26]); + let json = serde_json::to_string(&chord).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), chord); + assert!(serde_json::from_str::(r#"{"mods":1,"key":86,"extra":[256]}"#).is_err()); + } + #[test] fn overlay_adds_only_held_buttons() { let mut b = Bindings::new(); b.insert(16, Action::Xbox(XboxButton::A)); b.insert(17, Action::Xbox(XboxButton::LeftTrigger)); - b.insert(18, Action::Keys(Chord { mods: modifier::CTRL, key: 0x41 })); + b.insert(18, Action::Keys(Chord::new(modifier::CTRL, 0x41))); assert_eq!(report(&b, &[]), XusbReport::default()); assert_eq!(report(&b, &[16]).buttons, xusb::A); let r = report(&b, &[16, 17, 18]); @@ -420,7 +506,7 @@ mod tests { fn what_games_see_pressed() { let mut b = Bindings::new(); b.insert(button::SOUTH as u8, Action::Xbox(XboxButton::B)); - b.insert(button::BACK as u8, Action::Keys(Chord { mods: 0, key: 0x4D })); + b.insert(button::BACK as u8, Action::Keys(Chord::new(0, 0x4D))); b.insert(extras::LEFT_PADDLE1, Action::Combo(Buttons::of(&[XboxButton::X, XboxButton::Y]))); let held = |list: &[u32], lt: i16| { let mut s = PadState { buttons: list.iter().fold(0, |m, &x| m | 1 << x), ..PadState::default() }; @@ -454,9 +540,9 @@ mod tests { #[test] fn a_button_given_a_key_leaves_the_game() { let mut b = Bindings::new(); - b.insert(button::BACK as u8, Action::Keys(Chord { mods: 0, key: 0x4D })); + b.insert(button::BACK as u8, Action::Keys(Chord::new(0, 0x4D))); assert_eq!(report(&b, &[button::BACK]), XusbReport::default()); - b.insert(button::START as u8, Action::Macro(vec![Step::Keys(Chord { mods: 0, key: 0x41 })])); + b.insert(button::START as u8, Action::Macro(vec![Step::Keys(Chord::new(0, 0x41))])); assert_eq!(report(&b, &[button::START, button::SOUTH]).buttons, xusb::A); } @@ -526,10 +612,10 @@ mod tests { #[test] fn macros_are_clamped() { - let long = Action::Macro((0..100).map(|_| Step::Wait(60_000)).chain([Step::Keys(Chord { mods: 0, key: 0x41 })]).collect()); + let long = Action::Macro((0..100).map(|_| Step::Wait(60_000)).chain([Step::Keys(Chord::new(0, 0x41))]).collect()); assert_eq!(long.sanitised(), None, "the key fell beyond the step limit"); - let ok = Action::Macro(vec![Step::Keys(Chord { mods: 0, key: 0x41 }), Step::Wait(60_000)]).sanitised(); - assert_eq!(ok, Some(Action::Macro(vec![Step::Keys(Chord { mods: 0, key: 0x41 }), Step::Wait(MAX_WAIT_MS)]))); + let ok = Action::Macro(vec![Step::Keys(Chord::new(0, 0x41)), Step::Wait(60_000)]).sanitised(); + assert_eq!(ok, Some(Action::Macro(vec![Step::Keys(Chord::new(0, 0x41)), Step::Wait(MAX_WAIT_MS)]))); assert_eq!(Action::Keys(Chord::default()).sanitised(), None); let a = Step::Xbox(Buttons::of(&[XboxButton::A])); let pad = Action::Macro(vec![a, Step::Wait(20), Step::Xbox(Buttons::default())]); @@ -546,7 +632,7 @@ mod tests { #[test] fn bindings_round_trip_as_json() { let mut b = Bindings::new(); - b.insert(17, Action::Macro(vec![Step::Keys(Chord { mods: modifier::SHIFT, key: 0x31 }), Step::Wait(50)])); + b.insert(17, Action::Macro(vec![Step::Keys(Chord::new(modifier::SHIFT, 0x31)), Step::Wait(50)])); b.insert(18, Action::Macro(vec![Step::Xbox(Buttons::of(&[XboxButton::LeftBumper, XboxButton::Up]))])); b.insert(19, Action::Combo(Buttons::of(&[XboxButton::A, XboxButton::Guide]))); let text = serde_json::to_string(&b).unwrap(); diff --git a/crates/open-controller-core/src/button_mode.rs b/crates/open-controller-core/src/button_mode.rs new file mode 100644 index 0000000..d871e58 --- /dev/null +++ b/crates/open-controller-core/src/button_mode.rs @@ -0,0 +1,244 @@ +//! Each editor tab is an exclusive button mode. Inactive assignments are remembered so a +//! user can switch Capture from F10 to controller combinations and back without resetting it. +use crate::binding::{self, Action, Buttons, Chord, Step}; +use crate::profile::Profile; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Mode { + Xbox, + Key, + Macro, + Combinations, + Nothing, +} + +impl Mode { + pub fn of(action: &Action) -> Self { + match action { + Action::Xbox(_) | Action::Combo(_) => Self::Xbox, + Action::Keys(_) => Self::Key, + Action::Macro(_) => Self::Macro, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Presets { + pub xbox: Option, + pub key: Option, + pub macro_steps: Vec, +} + +impl Presets { + fn remember(&mut self, action: &Action) { + match action { + Action::Xbox(_) | Action::Combo(_) => self.xbox = action.xbox(), + Action::Keys(c) => self.key = Some(*c), + Action::Macro(steps) => self.macro_steps = steps.clone(), + } + } + + fn clear(&mut self, mode: Mode) { + match mode { + Mode::Xbox => self.xbox = None, + Mode::Key => self.key = None, + Mode::Macro => self.macro_steps.clear(), + _ => {} + } + } + + fn action(&self, mode: Mode) -> Option { + match mode { + Mode::Xbox => self.xbox.map(Action::Combo), + Mode::Key => self.key.map(Action::Keys), + Mode::Macro => Some(Action::Macro(self.macro_steps.clone())), + _ => None, + } + .and_then(Action::sanitised) + } + + fn sanitise(&mut self) { + self.xbox = self.xbox.filter(|b| !b.is_empty()); + self.key = self.key.filter(|c| !c.is_empty() && c.mods & !15 == 0 && c.key <= 255); + self.macro_steps = match Action::Macro(std::mem::take(&mut self.macro_steps)).sanitised() { + Some(Action::Macro(steps)) => steps, + _ => Vec::new(), + }; + } +} + +impl Profile { + pub fn button_mode(&self, button: u8) -> Mode { + self.button_modes.get(&button).copied().unwrap_or_else(|| { + self.bindings.get(&button).map(Mode::of).unwrap_or(if binding::is_normal(button) { Mode::Nothing } else { Mode::Xbox }) + }) + } + + pub fn assign_button(&mut self, button: u8, action: Option) { + if button >= 62 { + return; + } + match action.and_then(Action::sanitised) { + Some(action) => { + self.button_modes.insert(button, Mode::of(&action)); + self.button_presets.entry(button).or_default().remember(&action); + self.bindings.insert(button, action); + } + None => { + let mode = self.button_mode(button); + if let Some(p) = self.button_presets.get_mut(&button) { + p.clear(mode); + } + self.bindings.remove(&button); + } + } + } + + pub fn select_button_mode(&mut self, button: u8, mode: Mode) { + if button >= 62 { + return; + } + if let Some(action) = self.bindings.remove(&button) { + self.button_presets.entry(button).or_default().remember(&action); + } + self.button_modes.insert(button, mode); + if let Some(action) = self.button_presets.get(&button).and_then(|p| p.action(mode)) { + self.bindings.insert(button, action); + } + } + + pub fn shortcut_anchor(&self, input: u64) -> u8 { + self.shortcut_anchors.get(&input).copied().unwrap_or_else(|| { + // Migrate the earlier flat editor: prefer Capture, then an extra, then a normal + // button. Legacy trigger-only shortcuts remain accessible under Capture. + if input & (1 << 15) != 0 { + return 15; + } + let extras = input & ((1u64 << 62) - 1) & !binding::NORMAL.iter().fold(0, |m, &b| m | 1 << b); + let real = input & ((1u64 << 62) - 1); + if extras != 0 { + extras.trailing_zeros() as u8 + } else if real != 0 { + real.trailing_zeros() as u8 + } else { + 15 + } + }) + } + + pub fn enabled_shortcuts(&self) -> impl Iterator + Clone { + self.shortcuts.iter().filter(|(mask, _)| self.button_mode(self.shortcut_anchor(**mask)) == Mode::Combinations) + } + + pub fn sanitise_button_modes(&mut self) { + self.button_modes.retain(|&b, _| b < 62); + let explicit_modes: std::collections::BTreeSet = self.button_modes.keys().copied().collect(); + self.button_presets.retain(|&b, _| b < 62); + self.shortcut_anchors.retain(|mask, anchor| self.shortcuts.contains_key(mask) && *anchor < 62); + for preset in self.button_presets.values_mut() { + preset.sanitise(); + } + for (&button, action) in &self.bindings { + if explicit_modes.contains(&button) { + self.button_presets.entry(button).or_default().remember(action); + } + } + for input in self.shortcuts.keys().copied().collect::>() { + let anchor = self.shortcut_anchor(input); + self.shortcut_anchors.insert(input, anchor); + if !explicit_modes.contains(&anchor) { + // Earlier files had a flat combination list and no explicit active modes. + self.select_button_mode(anchor, Mode::Combinations); + } + } + for (&button, &mode) in &self.button_modes { + self.bindings.remove(&button); + if let Some(action) = self.button_presets.get(&button).and_then(|p| p.action(mode)) { + self.bindings.insert(button, action); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{Edit, Profiles}; + use crate::shortcuts::{self, State}; + const CAPTURE: u8 = 15; + const INPUT: u64 = (1 << CAPTURE) | 1; + const F10: Chord = Chord::new(0, 121); + const HOTKEY: Chord = Chord::new(5, 122); + + #[test] + fn capture_f10_switches_to_native_combinations_and_back_without_reset() { + let mut p = Profiles::default(); + p.apply(Edit::Bind { button: CAPTURE, action: Some(Action::Keys(F10)) }); + p.apply(Edit::ButtonMode { button: CAPTURE, mode: Mode::Combinations }); + p.apply(Edit::Shortcut { name: None, anchor: CAPTURE, input: INPUT, keys: Some(HOTKEY) }); + let mut state = State::default(); + let out = state.update(p.active().enabled_shortcuts(), &p.active().bindings, 0, INPUT); + assert_eq!(out.fire, Some(HOTKEY)); + assert_eq!(out.after, 0); + assert!(out.taps.is_empty()); + assert!(!p.active().bindings.contains_key(&CAPTURE), "F10 is remembered, not active"); + assert!(state.update(p.active().enabled_shortcuts(), &p.active().bindings, INPUT, 0).taps.is_empty()); + p.apply(Edit::ButtonMode { button: CAPTURE, mode: Mode::Key }); + assert_eq!(p.active().bindings.get(&CAPTURE), Some(&Action::Keys(F10))); + assert_eq!(p.active().enabled_shortcuts().count(), 0); + assert_eq!(p.active().shortcuts.get(&INPUT), Some(&HOTKEY)); + p.apply(Edit::ButtonMode { button: CAPTURE, mode: Mode::Combinations }); + let restored: Profiles = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + let restored = restored.sanitised(); + assert_eq!(restored.active().enabled_shortcuts().count(), 1); + assert!(!restored.active().bindings.contains_key(&CAPTURE)); + } + + #[test] + fn selecting_key_survives_reload_and_leaves_combinations_disabled() { + let mut p = Profiles::default(); + p.apply(Edit::Bind { button: CAPTURE, action: Some(Action::Keys(F10)) }); + p.apply(Edit::Shortcut { name: None, anchor: CAPTURE, input: INPUT, keys: Some(HOTKEY) }); + p.apply(Edit::ButtonMode { button: CAPTURE, mode: Mode::Key }); + let p: Profiles = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + let p = p.sanitised(); + assert_eq!(p.active().button_mode(CAPTURE), Mode::Key); + assert_eq!(p.active().enabled_shortcuts().count(), 0); + assert_eq!(p.active().bindings[&CAPTURE], Action::Keys(F10)); + } + + #[test] + fn legacy_conflicting_f10_is_kept_in_the_key_preset_when_migrated() { + let p: Profiles = serde_json::from_str( + r#"{"list":[{"bindings":{"15":{"Keys":{"mods":0,"key":121}}},"shortcuts":{"32769":{"mods":5,"key":122}}}]}"#, + ) + .unwrap(); + let mut p = p.sanitised(); + assert_eq!(p.active().button_mode(CAPTURE), Mode::Combinations); + assert!(!p.active().bindings.contains_key(&CAPTURE)); + p.apply(Edit::ButtonMode { button: CAPTURE, mode: Mode::Key }); + assert_eq!(p.active().bindings[&CAPTURE], Action::Keys(F10)); + } + + #[test] + fn macro_xbox_and_nothing_keep_independent_presets() { + let mut p = Profile::default(); + p.assign_button(CAPTURE, Some(Action::Keys(F10))); + let macro_action = Action::Macro(vec![Step::Keys(HOTKEY)]); + p.assign_button(CAPTURE, Some(macro_action.clone())); + p.assign_button(CAPTURE, Some(Action::Xbox(crate::binding::XboxButton::A))); + for mode in [Mode::Nothing, Mode::Combinations] { + p.select_button_mode(CAPTURE, mode); + assert!(!p.bindings.contains_key(&CAPTURE)); + } + p.select_button_mode(CAPTURE, Mode::Macro); + assert_eq!(p.bindings[&CAPTURE], macro_action); + p.select_button_mode(CAPTURE, Mode::Key); + assert_eq!(p.bindings[&CAPTURE], Action::Keys(F10)); + p.select_button_mode(CAPTURE, Mode::Xbox); + assert_eq!(p.bindings[&CAPTURE], Action::Xbox(crate::binding::XboxButton::A)); + assert_eq!(shortcuts::input(&crate::mapping::PadState { buttons: 1 << CAPTURE, ..Default::default() }), 1 << CAPTURE); + } +} diff --git a/crates/open-controller-core/src/engine.rs b/crates/open-controller-core/src/engine.rs index b7ddba4..9490359 100644 --- a/crates/open-controller-core/src/engine.rs +++ b/crates/open-controller-core/src/engine.rs @@ -22,6 +22,7 @@ use crate::report::{Diagnosis, GyroFacts}; use crate::roster::{Attached, DeviceId, Roster, SlotId}; use crate::rt; use crate::sdl::{Event, Gamepad, Joystick, Sdl}; +use crate::shortcuts; #[cfg(windows)] use crate::vigem::gone; #[cfg(windows)] @@ -140,6 +141,7 @@ pub enum Command { PowerOff(PadKey), /// Whether someone is looking: live input in snapshots is only kept up to date then. Watch(bool), + RecordShortcuts(Option), /// Whether the window looking is the one in front; behind, live input comes less often. Focused(bool), /// Replaces the profiles of the controllers whose settings are kept under this key. @@ -257,6 +259,8 @@ pub enum Driver { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Snapshot { pub pads: Vec, + #[serde(default)] + pub press_measurement: Option>, pub vigem: Driver, pub hidhide: Driver, pub hiding: bool, @@ -283,6 +287,7 @@ impl Default for Snapshot { fn default() -> Self { Snapshot { pads: Vec::new(), + press_measurement: None, vigem: Driver::Missing, hidhide: Driver::Missing, hiding: false, @@ -485,12 +490,19 @@ struct Unmapped { /// Who holds a key down: a slot (its virtual controller's controller), or an XInput controller /// of its own (a handheld's built-in one). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] enum Owner { Slot(SlotId), Device(DeviceId), } +fn output_owner(owner: Owner) -> PadKey { + match owner { + Owner::Slot(s) => PadKey::Slot(s.0), + Owner::Device(d) => PadKey::Device(d), + } +} + /// A virtual controller. struct Target { serial: u32, @@ -588,6 +600,9 @@ struct Core { keyboard: Keyboard, /// Keys held down for extra buttons that are held down, to be let go with them. held: Vec<(Owner, u8, Chord)>, + shortcut_states: HashMap, + shortcut_recording: Option, + press_meter: Option<(PadKey, crate::press_meter::Meter)>, /// The Xbox buttons a macro holds down right now, by the controller that played it. macro_held: HashMap, started: Instant, @@ -668,6 +683,9 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang profiles: config.profiles, keyboard: Keyboard::start(), held: Vec::new(), + shortcut_states: HashMap::new(), + shortcut_recording: None, + press_meter: None, macro_held: HashMap::new(), started: Instant::now(), foreground: String::new(), @@ -698,6 +716,9 @@ fn run(config: Config, rx: Receiver, shared: Arc>, on_chang Msg::Quit => break 'run, Msg::Command(Command::Watch(on)) => { watching = on; + if !on { + core.command(Command::RecordShortcuts(None), now); + } focused = true; core.changed = true; } @@ -844,6 +865,20 @@ impl Core { } } Command::Watch(_) | Command::Focused(_) => {} + Command::RecordShortcuts(key) => { + let was = self.shortcut_recording.take(); + self.shortcut_recording = key.and_then(|key| self.device_for(key)); + self.press_meter = key.zip(self.shortcut_recording).map(|(key, dev)| { + let held = self.phys.get(&dev).map_or(0, |p| shortcuts::input(&p.state)); + (key, crate::press_meter::Meter::new(held, Instant::now())) + }); + for d in [was, self.shortcut_recording].into_iter().flatten() { + self.release_keys(self.owner_of(d)); + if let Some(Kind::Slot(s)) = self.phys.get(&d).map(|p| p.kind) { + self.resend(s); + } + } + } Command::SetProfiles(store, profiles) => { let devices: Vec = self.phys.iter().filter(|(_, p)| p.model.store == store).map(|(&id, _)| id).collect(); for &d in &devices { @@ -1023,6 +1058,11 @@ impl Core { for d in &devices { if let Some(p) = self.phys.get_mut(d) { p.led = None; + if p.link == Link::Bluetooth + && let Err(error) = p.pad.restore_switch_input_mode() + { + self.note(format!("slot {} could not restore Switch input reports: {error}", s.0)); + } } } continue; @@ -1536,7 +1576,7 @@ impl Core { Kind::Slot(s) if !self.is_native(s) => self.roster.get(s).and_then(|x| x.source), _ => None, }; - match other.and_then(|d| self.phys.get(&d).map(|q| (d, q.state.buttons))) { + match other.and_then(|d| self.phys.get(&d).map(|q| (d, shortcuts::input(&q.state)))) { Some((d, held)) => self.type_keys(owner, d, held, held), None => self.release_keys(owner), } @@ -1598,10 +1638,15 @@ impl Core { /// that does something else is taken out first, so the d-pad a stick makes stays. fn report_of(&self, dev: DeviceId) -> XusbReport { let Some(p) = self.phys.get(&dev) else { return XusbReport::default() }; + if self.shortcut_recording == Some(dev) { + return XusbReport::default(); + } let profile = self.profile_of(&p.model.store); let mut state = p.state; if let Some(pr) = profile { state.buttons &= !binding::taken(&pr.bindings); + let used = self.shortcut_states.get(&self.owner_of(dev)).map_or(0, |s| s.used); + shortcuts::suppress(&mut state, used | shortcuts::deferred(pr.enabled_shortcuts())); if p.model.features.pressure { motion::apply_pressure(&mut state, pr.pressure); } @@ -1618,7 +1663,8 @@ impl Core { r.thumb_ry = (i32::from(r.thumb_ry) + i32::from(p.aim_out.1)).clamp(-32768, 32767) as i16; } if let Some(pr) = profile { - binding::overlay(&pr.bindings, p.state.buttons, &mut r); + let used = self.shortcut_states.get(&self.owner_of(dev)).map_or(0, |s| s.used); + binding::overlay(&pr.bindings, p.state.buttons & !used & !shortcuts::deferred(pr.enabled_shortcuts()), &mut r); } if let Some(b) = self.macro_held.get(&dev) { b.press(&mut r); @@ -1656,8 +1702,44 @@ impl Core { /// the macros of the buttons just pressed. Held keys follow the buttons' state rather than /// their release, which can come from another connection of the controller than the press. fn type_keys(&mut self, s: Owner, dev: DeviceId, before: u64, after: u64) { + if self.shortcut_recording == Some(dev) { + return; + } let program = self.foreground.as_str(); - let bindings = self.phys.get(&dev).and_then(|p| self.profiles.get(&p.model.store)).map(|p| &p.for_program(program).bindings); + let profile = self.phys.get(&dev).and_then(|p| self.profiles.get(&p.model.store)).map(|p| p.for_program(program)); + let bindings = profile.map(|p| &p.bindings); + let (before, after) = if let Some(profile) = profile { + let transition = self.shortcut_states.entry(s).or_default().update_modes( + profile.enabled_shortcuts(), + &profile.bindings, + &profile.shortcut_held, + before, + after, + ); + for chord in transition.releases { + self.keyboard.release(chord); + } + for chord in transition.presses { + self.keyboard.press(chord); + } + if let Some(keys) = transition.fire { + self.keyboard.shortcut(keys, output_owner(s), profile.shortcut_hold_ms(transition.fire_input.unwrap_or(0))); + } + for (input, chord) in transition.shortcut_taps { + self.keyboard.shortcut(chord, output_owner(s), profile.shortcut_hold_ms(input)); + } + for action in transition.taps { + let steps = match action { + Action::Keys(c) => vec![binding::Step::Keys(c)], + Action::Macro(steps) => steps, + a => vec![binding::Step::Xbox(a.xbox().unwrap_or_default())], + }; + self.keyboard.play(steps, dev); + } + (transition.before, transition.after) + } else { + (before, after) + }; let mut i = 0; while i < self.held.len() { let (owner, button, chord) = self.held[i]; @@ -1724,6 +1806,12 @@ impl Core { /// Lets go of every key an owner holds down: its controller left, or its assignments changed. fn release_keys(&mut self, s: Owner) { + self.keyboard.cancel_shortcuts(output_owner(s)); + if let Some(mut state) = self.shortcut_states.remove(&s) { + for chord in state.release_all() { + self.keyboard.release(chord); + } + } let (mine, rest): (Vec<_>, Vec<_>) = self.held.drain(..).partition(|h| h.0 == s); self.held = rest; for (_, _, c) in mine { @@ -1808,8 +1896,14 @@ 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); + let (before_input, after_input) = (shortcuts::input(&before), shortcuts::input(&state)); + if self.shortcut_recording == Some(id) + && let Some((_, meter)) = self.press_meter.as_mut() + { + meter.feed(after_input, Instant::now()); + } + if kind == Kind::Native && before_input != after_input { + self.type_keys(Owner::Device(id), id, before_input, after_input); } if let Kind::Slot(s) = kind { // Stick noise on the idle connection of a controller connected twice must not @@ -1819,10 +1913,10 @@ impl Core { self.move_rumble(s); } 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); + if before_input != after_input || !self.held.is_empty() { + self.type_keys(Owner::Slot(s), id, before_input, after_input); } + self.resend(s); } } } @@ -2205,6 +2299,10 @@ impl Core { }; Snapshot { pads, + press_measurement: self + .press_meter + .as_ref() + .and_then(|(key, meter)| meter.latest.clone().map(|reading| Box::new((*key, reading)))), vigem, hidhide: self.hider.state().clone(), hiding: self.hiding, @@ -2261,6 +2359,11 @@ impl Core { 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); } + for (_, mut state) in self.shortcut_states.drain() { + for chord in state.release_all() { + self.keyboard.release(chord); + } + } for (_, _, c) in std::mem::take(&mut self.held) { self.keyboard.release(c); } diff --git a/crates/open-controller-core/src/i18n.rs b/crates/open-controller-core/src/i18n.rs index a9b00b3..b9264cf 100644 --- a/crates/open-controller-core/src/i18n.rs +++ b/crates/open-controller-core/src/i18n.rs @@ -31,7 +31,81 @@ impl Lang { } } +pub struct ConfigurationText { + pub save_file: &'static str, + pub load_file: &'static str, + pub file_hint: &'static str, + pub busy: &'static str, + pub saved: &'static str, + pub failed: &'static str, + pub match_title: &'static str, + pub match_hint: &'static str, + pub mismatch_title: &'static str, + pub mismatch_hint: &'static str, + pub full: &'static str, + pub global: &'static str, + pub loaded: &'static str, + pub global_loaded: &'static str, + pub source: &'static str, + pub current: &'static str, + pub cancel: &'static str, + pub mouse: &'static str, + pub mouse_hint: &'static str, +} + +pub struct CombinationsText { + pub fixed_on: &'static str, + pub fixed_off: &'static str, + pub held_label: &'static str, + pub duration_modes_help: &'static str, + pub meter_title: &'static str, + pub meter_hint: &'static str, + pub meter_start: &'static str, + pub meter_stop: &'static str, + pub meter_recording: &'static str, + pub meter_result: &'static str, + pub meter_minimum: &'static str, + pub meter_output: &'static str, + pub meter_limit: &'static str, + + pub duration_title: &'static str, + pub duration_hint: &'static str, + pub duration_error: &'static str, + pub help_title: &'static str, + pub help_close: &'static str, + pub help_sections: &'static [(&'static str, &'static str)], + pub tab: &'static str, + pub description: &'static str, + pub native_hint: &'static str, + pub saved_key: &'static str, + pub add: &'static str, + pub edit: &'static str, + pub empty: &'static str, + pub input: &'static str, + pub output: &'static str, + pub record: &'static str, + pub record_hint: &'static str, + pub choose_key: &'static str, + pub name: &'static str, + pub name_placeholder: &'static str, + pub key_capture_hint: &'static str, + pub key_capture_error: &'static str, + pub save: &'static str, + pub cancel: &'static str, + pub need_buttons: &'static str, + pub need_pair: &'static str, + pub duplicate: &'static str, + pub conflict: &'static str, + pub replace: &'static str, + pub unassign: &'static str, + pub unassigned: &'static str, + pub capacity: &'static str, + pub limit: &'static str, +} + pub struct Text { + pub configuration: ConfigurationText, + pub combinations: CombinationsText, pub controllers: &'static str, pub controllers_one: &'static str, pub controllers_many: &'static str, @@ -468,6 +542,118 @@ pub struct Text { } pub const EN: Text = Text { + configuration: ConfigurationText { + save_file: "Save configuration…", + load_file: "Load configuration…", + file_hint: "One file includes this controller’s profiles and global named keyboard/mouse actions.", + busy: "Working…", + saved: "Configuration saved: {}", + failed: "Could not complete the operation: {}", + match_title: "Controller matches", + match_hint: "Loading replaces this controller’s profiles. The file includes all profiles; global-only import adds unassigned actions to the selected profile. Other controllers keep their settings.", + mismatch_title: "Controller does not match", + mismatch_hint: "The file belongs to a different controller model. Use its global names and keyboard/mouse shortcuts, then assign controller inputs manually. Existing assignments will be kept; imported actions are added unassigned to the selected profile.", + full: "Load controller setup + actions", + global: "Use global actions only", + loaded: "Controller setup and global actions loaded.", + global_loaded: "Global actions added to the selected profile without controller inputs. Edit each unassigned row and record its buttons.", + source: "File controller: {}", + current: "Current controller: {}", + cancel: "Cancel", + mouse: "Mouse buttons…", + mouse_hint: "Select modifiers, then a mouse button. This assigns the shortcut without clicking that button or activating other apps.", + }, + combinations: CombinationsText { + fixed_on: "On", + fixed_off: "Off", + held_label: "Until released", + duration_modes_help: "On: send one timed pulse (10–60000 ms), even if you keep holding the controller. Off: press the shortcut when the controller input completes and keep it down until any component is released. For Alt+Tab, Off keeps Alt pressed so the selector stays open. A held single-button action starts immediately; if it is also part of a larger combination, its initial action can occur first. Save to apply.", + + meter_title: "Controller press measurement", + meter_hint: "Measure a button or a combination. Release every button between samples. This does not change any assignment.", + meter_start: "Start measurement", + meter_stop: "Stop measurement", + meter_recording: "Measurement active: OpenController actions are paused. Press buttons and release all. Stop or leave this window to resume normal input.", + meter_result: "Last observed simultaneous hold: {}", + meter_minimum: "Shortest observed hold for this input in this session: {}", + meter_output: "Configured simulated output for the matching action: {}", + meter_limit: "Input-report timing limits precision. This is observed hold duration, not device latency, a hardware minimum, or a recommended output duration. Starting again resets the session.", + + duration_title: "Key press duration for this action", + duration_hint: "How long the simulated shortcut stays pressed, not a delay before sending. Start at 100 ms; if an app misses it, try 150 or 250 ms.", + duration_error: "Enter a whole number from 10 to 60000 ms.", + help_title: "How to use Combinations", + help_close: "Close guide", + help_sections: &[ + ( + "1. Prepare the app you want to control", + "Set a hotkey in the target app first and keep that app running. For example, configure Ctrl + Shift + Mouse 5 to toggle recording in OBS. OpenController sends that input; naming an action “OBS recording” only labels it and does not open OBS or configure its hotkeys.", + ), + ( + "2. Create a named action", + "Open your controller, choose Buttons, expand Capture (or another button), then select Combinations. Click Add combination and enter a useful name such as “OBS: toggle recording”. Each tab is a separate mode: Combinations pauses this button’s individual Key, Xbox and Macro assignments. Returning to Key restores its saved key, such as F10.", + ), + ( + "3. Record the controller input", + "Click Record buttons. Press Capture + B together, then release all buttons to confirm. The input must include the button whose panel you opened. Capture alone can also be recorded: timed mode fires on release if no larger combination consumed it; held mode starts on press and ends on release. A matched combination sends its action once and consumes its buttons. Normal game buttons pressed before the combination completes can reach the game; press Capture first or use a hardware button programmed to send both together.", + ), + ( + "4. Choose the output and save", + "Click Choose key or shortcut, hold all keys of the keyboard shortcut together, then release them. Shortcuts can contain several regular keys, for example Ctrl + V + Up Arrow. On Windows, this picker temporarily captures the keyboard so those keys do not activate other apps while you assign them. Esc alone cancels; Ctrl + Shift + Esc can be assigned. For a mouse shortcut, use Mouse buttons, select Ctrl/Shift/Alt/Win as needed, then select the mouse button. This manual choice does not click that button. Choose the duration in milliseconds (100 ms initially), then click Save. Test the controller combination with the target app running.", + ), + ( + "5. Extra buttons and mouse limits", + "Some controllers do not expose L4/R4 as independent PC inputs. If your controller supports it, program an extra button in its own firmware/software to send Capture + B; OpenController can then recognize that input. The standard Windows mouse interface provides five buttons: left, right, middle, Back and Forward. For additional physical buttons, configure a keyboard output in the mouse manufacturer’s software, then assign that same key/shortcut here. Proprietary mouse inputs are not universally supported.", + ), + ( + "6. Edit, replace, or leave an action unassigned", + "Edit opens directly under that row. Change its name, controller input or output, then Save. If another action already uses the input, Save offers Replace or Cancel. Replace preserves the displaced action as a row without controller buttons. Clear controller input followed by Save keeps the name and output for later assignment. You may also create an action without recording buttons. Edit an unassigned row to record its input. Delete removes the action; Cancel discards the current draft.", + ), + ( + "7. Profiles and portable files", + "Each profile has its own actions; each combination saves its own duration. New profiles start empty, and switching profiles closes unfinished shortcut edits. The app can also activate a profile for a program configured in its profile menu. At the bottom of Combinations, Save configuration opens the normal Save As dialog and writes one file containing all controller profiles and global named outputs. Load configuration previews the controller match. Loading the complete setup replaces this controller’s profiles. Global-only import keeps existing assignments and adds unassigned actions only to the selected profile; record their controller buttons manually. A different controller model can use the global-only option. Hardware L4/R4 programming remains in the controller itself.", + ), + ( + "8. Key press duration for this action: a hold, not a delay", + "The shortcut is sent as soon as the combination is recognized. Duration controls how long its simulated keyboard/mouse output remains pressed before release; it is not a wait before sending. Default: 100 ms. This setting belongs to the action you are editing, including a single button assigned in this mode. Other combinations keep their own values. Normal gamepad input, direct Key assignments and ordinary macro timing are unchanged. In the action editor, enter 10–60000 ms and Save. If an app misses a shortcut, try 150 or 250 ms. Very large values hold the keys longer and can make repeated shortcut actions slower. With fixed duration On, the simulated output is released after this duration even if you keep holding the controller. Switch it Off to keep the shortcut pressed until you release any button in its controller input. The ms value is remembered when switching back On. A held single-button action starts immediately and may emit its initial action before a larger combination completes. For a window selector that stays open, Ctrl + Alt + Tab is more suitable than a brief Alt + Tab. Only timed single-button actions wait for release. Held single buttons start immediately. Long fixed pulses do not queue other shortcuts behind them. Changing profile, disconnecting the controller, entering an input recorder or quitting the resident app releases held outputs. Closing only the window keeps OpenController running, so a held action continues until release.", + ), + ( + "9. If an action does not work", + "Check the selected profile and that Combinations is the active mode. Confirm the recorded controller buttons, choose the exact output used by the target app, and save the action. Test the same hotkey with the physical keyboard/mouse and check whether the target app allows it in the background. If physical input works but the simulated tap is missed, increase the duration gradually. Increasing it cannot fix an incorrect hotkey, an unsupported proprietary button, an app that rejects simulated input, or access restrictions. Esc alone, changing window focus or the picker timeout cancels keyboard assignment; normal keyboard input then resumes.", + ), + ( + "10. Measure your controller press", + "Use Start measurement at the bottom of the panel. Existing OpenController actions are paused only while measuring. Press one button, or a combination, and release all buttons. The meter shows the observed simultaneous hold and the shortest observed hold for that same input in the current session. If that input has an assigned action, it also shows its configured simulated output duration. These are separate measurements: a physical 40 ms hold can send a configured 100 ms output. The minimum shown is only the shortest press observed in this session; it is not a controller hardware minimum, latency measurement or automatic compatibility recommendation. Stop measurement or leave the window to resume normal control. Measurements are not saved to the configuration.", + ), + ], + tab: "Combinations", + description: "Assign this button alone or together with others to a keyboard or mouse shortcut. Unassigned actions stay in this profile.", + native_hint: "In this mode, this button is used for combinations. Its Key, Xbox and Macro assignments are paused.", + saved_key: "Saved in Key: {}. Switch to Key to use it again.", + add: "+ Add combination", + edit: "Edit", + empty: "No combination recorded", + input: "1. Controller input", + output: "2. Keyboard or mouse output", + record: "Record buttons", + record_hint: "Press {} alone or with other buttons, then release all to confirm. Esc cancels.", + choose_key: "Choose key or shortcut", + name: "Name (optional)", + name_placeholder: "e.g. HDR capture or Show FPS", + key_capture_hint: "Press the shortcut and release all keys. Keyboard shortcuts are paused during capture. Esc alone cancels.", + key_capture_error: "Could not start exclusive keyboard capture", + save: "Save", + cancel: "Cancel", + need_buttons: "Press this button alone or with other buttons, then release all.", + need_pair: "Choose the keyboard or mouse output. Record buttons, or save without an input to assign it later.", + duplicate: "That combination already exists. Edit its row or record a different one.", + conflict: "This input is assigned to ‘{}’. Replace it? The previous action will remain in this profile without assigned buttons.", + replace: "Replace", + unassign: "Clear controller input", + unassigned: "No controller buttons assigned", + capacity: "This profile already has 256 unassigned actions. Remove an unused action first.", + limit: "A profile can have at most 32 combinations.", + }, controllers: "Controllers", controllers_one: "1 controller", controllers_many: "controllers", @@ -617,7 +803,7 @@ pub const EN: Text = Text { profile_n: "Profile {}", rename: "Rename", delete: "Delete", - profiles_hint: "Each profile keeps its own buttons and light. A new one starts as a copy of the one in use.", + profiles_hint: "Each profile keeps its own settings. A new profile starts empty.", light_desc: "What the light bar shows while OpenController runs.", light_player: "Player colour", light_player_desc: "Blue, red, green or pink, as on the console.", @@ -926,6 +1112,118 @@ pub const EN: Text = Text { }; pub const PT: Text = Text { + configuration: ConfigurationText { + save_file: "Salvar configuração…", + load_file: "Carregar configuração…", + file_hint: "Um arquivo inclui os perfis deste controle e ações globais nomeadas de teclado/mouse.", + busy: "Aguarde…", + saved: "Configuração salva: {}", + failed: "Não foi possível concluir a operação: {}", + match_title: "Controle compatível", + match_hint: "O carregamento substitui os perfis deste controle. A importação global adiciona ações sem botões ao perfil selecionado. Os outros controles mantêm suas configurações.", + mismatch_title: "O controle não corresponde", + mismatch_hint: "O arquivo pertence a outro modelo de controle. Use os nomes e atalhos globais de teclado/mouse e atribua os botões do controle manualmente. As atribuições existentes são mantidas; as ações são adicionadas sem botões ao perfil selecionado.", + full: "Carregar perfis + ações", + global: "Usar apenas ações globais", + loaded: "Perfis do controle e ações globais carregados.", + global_loaded: "Ações globais adicionadas ao perfil selecionado sem botões. Edite cada ação para gravar seus botões.", + source: "Controle do arquivo: {}", + current: "Controle atual: {}", + cancel: "Cancelar", + mouse: "Botões do mouse…", + mouse_hint: "Selecione os modificadores e depois um botão do mouse. Isso atribui o atalho sem clicar nesse botão nem ativar outros aplicativos.", + }, + combinations: CombinationsText { + fixed_on: "Ligado", + fixed_off: "Desligado", + held_label: "Até soltar", + duration_modes_help: "Ligado: envia um toque temporizado (10–60000 ms), mesmo se continuar segurando o controle. Desligado: mantém o atalho até soltar qualquer botão da combinação. Para Alt+Tab, mantém Alt pressionado e o seletor aberto. Um botão sozinho mantido começa imediatamente; se também fizer parte de uma combinação maior, sua ação inicial pode ocorrer primeiro. Salve para aplicar.", + + meter_title: "Medição de pulsação do controle", + meter_hint: "Meça um botão ou uma combinação. Solte todos entre amostras. Isso não altera atribuições.", + meter_start: "Iniciar medição", + meter_stop: "Parar medição", + meter_recording: "Medição ativa: ações do OpenController pausadas. Pressione e solte todos. Pare ou saia desta janela para retomar.", + meter_result: "Último tempo observado com os botões juntos: {}", + meter_minimum: "Menor tempo observado desta entrada nesta sessão: {}", + meter_output: "Saída simulada configurada para a ação correspondente: {}", + meter_limit: "A precisão depende dos relatórios de entrada. Mede duração observada, não latência, mínimo do hardware ou duração de saída recomendada. Reiniciar zera a sessão.", + + duration_title: "Duração da tecla", + duration_hint: "Tempo que o atalho simulado fica pressionado, não atraso antes de enviar. Comece em 100 ms; se um aplicativo perder a entrada, tente 150 ou 250 ms.", + duration_error: "Digite um número inteiro de 10 a 60000 ms.", + help_title: "Como usar Combinations", + help_close: "Fechar guia", + help_sections: &[ + ( + "1. Prepare o aplicativo de destino", + "Configure primeiro um atalho no aplicativo e mantenha-o aberto. Por exemplo, Ctrl + Shift + Mouse 5 para alternar gravação no OBS. O OpenController envia essa entrada; o nome da ação é apenas um rótulo e não abre nem configura o aplicativo.", + ), + ( + "2. Crie uma ação com nome", + "Abra o controle, escolha Buttons, expanda Capture (ou outro botão) e selecione Combinations. Clique em Add combination e informe um nome útil. Cada aba é um modo separado: Combinations pausa as atribuições individuais de Key, Xbox e Macro deste botão. Voltar a Key restaura sua tecla salva, como F10.", + ), + ( + "3. Grave os botões do controle", + "Clique em Record buttons, pressione Capture + B juntos e solte todos para confirmar. A entrada deve incluir o botão cujo painel você abriu. Capture sozinho também pode ser gravado: no modo temporizado dispara ao soltar se nenhuma combinação maior o consumiu; no modo mantido começa ao pressionar e termina ao soltar. Uma combinação completa dispara uma vez e consome seus botões. Botões normais pressionados antes de completar a combinação podem chegar ao jogo; pressione Capture primeiro ou use um botão físico programado para enviar ambos juntos.", + ), + ( + "4. Escolha a saída e salve", + "Clique em Choose key or shortcut, mantenha todas as teclas do atalho pressionadas juntas e depois solte-as. O atalho pode ter várias teclas comuns, como Ctrl + V + Seta para cima. No Windows, o seletor captura temporariamente o teclado para não ativar outros aplicativos durante a atribuição. Esc sozinho cancela; Ctrl + Shift + Esc pode ser atribuído. Para mouse, abra Mouse buttons, escolha os modificadores e depois o botão. A escolha manual não clica nesse botão. Clique em Save e teste com o aplicativo de destino aberto.", + ), + ( + "5. Botões extras e limites do mouse", + "Alguns controles não expõem L4/R4 como entradas independentes no PC. Quando o modelo permitir, programe o botão extra no próprio controle/software para enviar Capture + B. A interface padrão de mouse do Windows oferece cinco botões: esquerdo, direito, central, voltar e avançar. Para outros botões físicos, configure uma saída de teclado no software do fabricante e atribua essa mesma tecla/atalho aqui. Entradas proprietárias não têm suporte universal.", + ), + ( + "6. Edite, substitua ou deixe sem botões", + "Edit abre o formulário abaixo da própria ação. Altere nome, entrada ou saída e salve. Se a entrada estiver ocupada, Save oferece Replace ou Cancel. Replace conserva a ação anterior como uma linha sem botões. Clear controller input e depois Save mantêm nome e saída para atribuir depois. Também é possível criar uma ação sem gravar botões. Edite uma linha sem botões para gravar sua entrada. Delete remove a ação; Cancel descarta o rascunho.", + ), + ( + "7. Perfis e arquivos portáteis", + "Cada perfil guarda suas ações; cada combinação salva sua própria duração. Novos perfis começam vazios; trocar de perfil fecha edições incompletas. O menu de perfis também permite ativá-los por programa. No final de Combinations, Save configuration abre Salvar como e grava um arquivo com os perfis do controle e saídas globais nomeadas. Load configuration mostra a compatibilidade do controle. A carga completa substitui os perfis deste controle. A importação global mantém as atribuições existentes e adiciona ações sem botões somente ao perfil selecionado; grave suas entradas manualmente. Outro modelo pode usar a opção global. A programação interna de L4/R4 permanece no controle.", + ), + ( + "8. Duração da tecla: tempo pressionado, não atraso", + "O atalho é enviado assim que a combinação é reconhecida. A duração determina quanto tempo a saída simulada de teclado/mouse permanece pressionada antes de soltar; não é uma espera antes de enviar. Padrão: 100 ms. Aplica-se à ação que você está editando, incluindo um botão sozinho neste modo. As outras combinações mantêm seus próprios valores. Entradas normais do jogo, atribuições Key diretas e tempos de macros comuns permanecem iguais. Digite 10–60000 ms e salve. Se um aplicativo perder o atalho, tente 150 ou 250 ms. Valores muito altos mantêm as teclas pressionadas por mais tempo e podem tornar repetições mais lentas. Com duração fixa ligada, a saída é solta após esse tempo mesmo se continuar segurando o controle. Desligue para manter o atalho até soltar um botão da entrada. O valor em ms é lembrado ao ligar novamente. Um botão sozinho mantido começa imediatamente e pode iniciar antes de completar uma combinação maior. Para deixar o seletor de janelas aberto, use Ctrl + Alt + Tab em vez de um toque de Alt + Tab. Apenas Capture sozinho temporizado aguarda a soltura. Ações mantidas começam imediatamente. Temporizadores longos são independentes; mudar de perfil, desconectar ou encerrar o processo residente libera as saídas. Fechar só a janela mantém o OpenController em execução.", + ), + ( + "9. Se uma ação não funcionar", + "Confira o perfil selecionado e o modo Combinations. Verifique entrada, saída exata do aplicativo e se a ação foi salva. Teste o mesmo atalho no teclado/mouse físico e confira se o aplicativo o aceita em segundo plano. Se a entrada física funcionar mas o toque simulado for perdido, aumente a duração aos poucos. Isso não corrige atalho errado, botão proprietário sem suporte, rejeição de entrada simulada ou restrições de acesso. Esc sozinho, perda de foco ou tempo limite cancelam a captura de teclado; depois o teclado volta ao normal.", + ), + ( + "10. Meça a pulsação do controle", + "Use Iniciar medição no final do painel. As ações do OpenController ficam pausadas somente durante a medição. Pressione um botão ou uma combinação e solte todos. O medidor mostra o tempo observado com os botões juntos e o menor tempo desta entrada nesta sessão. Se houver ação atribuída, mostra também sua duração de saída simulada. São tempos diferentes: uma pulsação física de 40 ms pode enviar uma saída configurada de 100 ms. O mínimo é apenas o menor tempo observado na sessão; não é mínimo do hardware, latência ou recomendação automática. Pare ou saia da janela para retomar o controle. As medidas não são salvas na configuração.", + ), + ], + tab: "Combinações", + description: "Atribua este botão sozinho ou com outros a um atalho de teclado ou mouse. Ações sem botões ficam neste perfil.", + native_hint: "Neste modo, este botão serve para combinações. Suas atribuições de Tecla, Xbox e Macro ficam pausadas.", + saved_key: "Salvo em Tecla: {}. Volte para Tecla para usar novamente.", + add: "+ Adicionar combinação", + edit: "Editar", + empty: "Nenhuma combinação gravada", + input: "1. Entrada do controle", + output: "2. Saída de teclado ou mouse", + record: "Gravar botões", + record_hint: "Pressione {} sozinho ou com outros botões e solte todos para confirmar. Esc cancela.", + choose_key: "Escolher tecla ou atalho", + name: "Nome (opcional)", + name_placeholder: "Ex.: captura HDR ou mostrar FPS", + key_capture_hint: "Pressione o atalho e solte todas as teclas. Atalhos do teclado ficam pausados durante a captura. Esc sozinho cancela.", + key_capture_error: "Não foi possível iniciar a captura exclusiva do teclado", + save: "Salvar", + cancel: "Cancelar", + need_buttons: "Pressione este botão sozinho ou com outros botões e solte todos.", + need_pair: "Escolha a saída de teclado ou mouse. Grave os botões ou salve sem entrada para atribuí-la depois.", + duplicate: "Essa combinação já existe. Edite a linha dela ou grave outra.", + conflict: "Esta entrada já pertence a ‘{}’. Substituir? A ação anterior ficará neste perfil sem botões atribuídos.", + replace: "Substituir", + unassign: "Limpar entrada do controle", + unassigned: "Nenhum botão do controle atribuído", + capacity: "Este perfil já tem 256 ações sem botões. Remova uma ação não utilizada primeiro.", + limit: "Um perfil pode ter no máximo 32 combinações.", + }, controllers: "Controles", controllers_one: "1 controle", controllers_many: "controles", @@ -1075,7 +1373,7 @@ pub const PT: Text = Text { profile_n: "Perfil {}", rename: "Renomear", delete: "Excluir", - profiles_hint: "Cada perfil guarda seus botões e sua luz. Um novo começa como cópia do que está em uso.", + profiles_hint: "Cada perfil guarda suas próprias configurações. Um novo perfil começa vazio.", light_desc: "O que a barra de luz mostra enquanto o OpenController roda.", light_player: "Cor do jogador", light_player_desc: "Azul, vermelho, verde ou rosa, como no console.", diff --git a/crates/open-controller-core/src/i18n/zh.rs b/crates/open-controller-core/src/i18n/zh.rs index 89a0d18..2f6e687 100644 --- a/crates/open-controller-core/src/i18n/zh.rs +++ b/crates/open-controller-core/src/i18n/zh.rs @@ -1,8 +1,10 @@ //! Simplified Chinese interface text. Keep placeholders in the same order as English. -use super::Text; +use super::{EN, Text}; pub const ZH: Text = Text { + configuration: EN.configuration, + combinations: EN.combinations, controllers: "手柄", controllers_one: "1 个手柄", controllers_many: "个手柄", diff --git a/crates/open-controller-core/src/ipc.rs b/crates/open-controller-core/src/ipc.rs index e6baa22..d424267 100644 --- a/crates/open-controller-core/src/ipc.rs +++ b/crates/open-controller-core/src/ipc.rs @@ -6,7 +6,8 @@ use crate::engine::{PadKey, Snapshot}; use crate::i18n::Lang; use crate::platform::VirtualDriver; -use crate::profile::Edit; +use crate::portable::ActionLibrary; +use crate::profile::{Edit, Profiles}; use crate::report::Diagnosis; use serde::{Deserialize, Serialize}; @@ -18,13 +19,14 @@ 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)] pub enum ToWindow { Snapshot(Snapshot), Prefs(Prefs), + ActionLibrary(ActionLibrary), /// The answer to [`ToTray::Diagnose`]; `None` when the controller is gone. Diagnosis(PadKey, Option>), /// The first message on a connection. Resident processes before it send none. @@ -77,6 +79,13 @@ pub enum ToTray { SplitJoyCons(bool), /// Whether the window is the one in front: behind a game it is sent live input less often. Focused(bool), + RecordShortcuts(Option), + /// Apply a reviewed import, atomically preserving all unrelated controllers. + ApplyConfiguration { + store: Option, + profiles: Option>, + actions: ActionLibrary, + }, /// A joystick SDL has no layout for gets one its owner made: its GUID, a name, and the /// parts as a mapping line has them (`a:b2,b:b1,`). Map { diff --git a/crates/open-controller-core/src/keyboard.rs b/crates/open-controller-core/src/keyboard.rs index 040c4c2..ab98fa7 100644 --- a/crates/open-controller-core/src/keyboard.rs +++ b/crates/open-controller-core/src/keyboard.rs @@ -19,9 +19,11 @@ use std::time::Duration; mod sys; pub use sys::{key_name, vk_for_char}; +mod timed; #[derive(Clone, Copy, Debug, PartialEq)] enum Out { + Finish, Down(Chord), Up(Chord), } @@ -38,17 +40,20 @@ pub enum Tap { /// the threads end. pub struct Keyboard { keys: Sender, - macros: Sender<(Vec, DeviceId)>, + macros: Sender, + shortcuts: Sender, taps: Receiver, plays: Arc>, /// Never sent on: closed when the keyboard is dropped, which cuts a macro's wait short. - _stop: Sender<()>, + _stop: Option>, + workers: Vec>, + key_worker: Option>, } impl Keyboard { pub fn start() -> Keyboard { let (keys, rx) = unbounded::(); - std::thread::Builder::new() + let key_worker = std::thread::Builder::new() .name("open-controller-keys".into()) .spawn(move || { // The only owner of the sink and of the counts of held keys: macros send their @@ -59,6 +64,9 @@ impl Keyboard { let mut sink = sys::Sink::open(); let mut held = Held::default(); while let Ok(out) = rx.recv() { + if out == Out::Finish { + break; + } if sink.is_none() { sink = sys::Sink::open(); } @@ -66,25 +74,25 @@ impl Keyboard { match out { Out::Down(c) => sink.down(held.down(c)), Out::Up(c) => sink.up(held.up(c)), + Out::Finish => break, } } }) .expect("could not start the keyboard thread"); - let (macros, queued) = unbounded::<(Vec, DeviceId)>(); let (to_pads, taps) = unbounded::(); let (stop_tx, stop) = crossbeam_channel::bounded::<()>(0); - let plays = Arc::new(Mutex::new(Plays::default())); - let (to_keys, done) = (keys.clone(), plays.clone()); - std::thread::Builder::new() - .name("open-controller-macros".into()) - .spawn(move || { - while let Ok((steps, by)) = queued.recv() { - play(&to_keys, &to_pads, by, &steps, &stop); - done.lock().unwrap_or_else(PoisonError::into_inner).done(&steps); - } - }) - .expect("could not start the macro thread"); - Keyboard { keys, macros, taps, plays, _stop: stop_tx } + let (macros, plays, macro_worker) = start_player("open-controller-macros", keys.clone(), to_pads.clone(), stop.clone()); + let (shortcuts, shortcut_worker) = timed::start(keys.clone(), stop); + Keyboard { + keys, + macros, + shortcuts, + taps, + plays, + _stop: Some(stop_tx), + workers: vec![macro_worker, shortcut_worker], + key_worker: Some(key_worker), + } } /// The Xbox buttons macros press and let go of, for the input thread to read. @@ -100,16 +108,64 @@ impl Keyboard { let _ = self.keys.send(Out::Up(c)); } + /// A controller shortcut holds its output long enough for background hotkey polling. + /// Its worker and queue are separate from macros and direct key/button input. + pub fn shortcut(&self, chord: Chord, owner: crate::PadKey, hold_ms: u16) { + let _ = self.shortcuts.send(timed::Command::Press(chord, owner, Duration::from_millis(crate::shortcuts::hold_ms(hold_ms).into()))); + } + + pub fn cancel_shortcuts(&self, owner: crate::PadKey) { + let _ = self.shortcuts.send(timed::Command::Cancel(owner)); + } + /// Plays a macro a button of controller `by` started. pub fn play(&self, steps: Vec, by: DeviceId) { if self.plays.lock().unwrap_or_else(PoisonError::into_inner).admit(&steps) { - let _ = self.macros.send((steps, by)); + let _ = self.macros.send((steps, by, TAP)); } } } -/// Gap between the keys of a macro step, so programs that poll the keyboard see each one. +impl Drop for Keyboard { + fn drop(&mut self) { + drop(self._stop.take()); + for worker in self.workers.drain(..) { + let _ = worker.join(); + } + let _ = self.keys.send(Out::Finish); + if let Some(worker) = self.key_worker.take() { + let _ = worker.join(); + } + } +} + +/// Preserve the original macro timing. Only controller shortcuts get the longer hold. const TAP: Duration = Duration::from_millis(15); +#[cfg(test)] +const SHORTCUT_TAP: Duration = Duration::from_millis(100); +type Job = (Vec, DeviceId, Duration); +type Player = (Sender, Arc>, std::thread::JoinHandle<()>); + +fn start_player(name: &str, keys: Sender, pads: Sender, stop: Receiver<()>) -> Player { + let (send, queued) = unbounded::(); + let plays = Arc::new(Mutex::new(Plays::default())); + let done = plays.clone(); + let worker = std::thread::Builder::new() + .name(name.into()) + .spawn(move || { + loop { + crossbeam_channel::select! { + recv(stop) -> _ => break, + recv(queued) -> job => match job { + Ok((steps, by, tap)) => { play(&keys, &pads, by, &steps, &stop, tap); done.lock().unwrap_or_else(PoisonError::into_inner).done(&steps); } + Err(_) => break, + } + } + } + }) + .expect("could not start the playback thread"); + (send, plays, worker) +} /// Macros playing or waiting their turn, at most. const MAX_PLAYS: usize = 4; @@ -139,16 +195,16 @@ impl Plays { /// Plays `steps` through the key thread, and its Xbox buttons on `by`'s virtual controller. Once /// `stop` closes it ends early, with every key and button it pressed let go of. -fn play(keys: &Sender, pads: &Sender, by: DeviceId, steps: &[Step], stop: &Receiver<()>) { +fn play(keys: &Sender, pads: &Sender, by: DeviceId, steps: &[Step], stop: &Receiver<()>, tap: Duration) { let wait = |d: Duration| !matches!(stop.recv_timeout(d), Err(RecvTimeoutError::Disconnected)); let xbox_tap = Duration::from_millis(XBOX_TAP_MS.into()); for s in steps { match *s { Step::Keys(c) => { let _ = keys.send(Out::Down(c)); - let on = wait(TAP); + let on = wait(tap); let _ = keys.send(Out::Up(c)); - if !on || !wait(TAP) { + if !on || !wait(tap) { return; } } @@ -188,15 +244,15 @@ impl Held { *n += 1; } } - if c.key != 0 { - *self.keys.entry(c.key).or_default() += 1; + for key in c.keys() { + *self.keys.entry(key).or_default() += 1; } c } /// The part of the chord no other one holds. A key nothing pressed is let go of anyway. fn up(&mut self, c: Chord) -> Chord { - let mut out = Chord { mods: 0, key: 0 }; + let mut out = Chord::new(0, 0); for (n, &m) in self.mods.iter_mut().zip(&MODIFIER_BITS) { if c.mods & m != 0 { *n = n.saturating_sub(1); @@ -205,12 +261,12 @@ impl Held { } } } - if c.key != 0 { - let n = self.keys.remove(&c.key).unwrap_or(0).saturating_sub(1); + for key in c.keys() { + let n = self.keys.remove(&key).unwrap_or(0).saturating_sub(1); if n == 0 { - out.key = c.key; + out.add_key(key); } else { - self.keys.insert(c.key, n); + self.keys.insert(key, n); } } out @@ -220,6 +276,11 @@ impl Held { /// Keys whose system name is missing or unhelpful. fn fixed_name(vk: u16) -> Option<&'static str> { Some(match vk { + 0x01 => "Mouse 1 (Left)", + 0x02 => "Mouse 2 (Right)", + 0x04 => "Mouse 3 (Middle)", + 0x05 => "Mouse 4 (Back)", + 0x06 => "Mouse 5 (Forward)", 0x5B | 0x5C => sys::META, 0xAD => "Mute", 0xAE => "Volume −", @@ -316,9 +377,7 @@ pub fn chord_names(c: Chord) -> Vec { v.push(name.into()); } } - if c.key != 0 { - v.push(key_name(c.key)); - } + v.extend(c.keys().map(key_name)); v } @@ -326,6 +385,19 @@ pub fn chord_names(c: Chord) -> Vec { mod tests { use super::*; + #[test] + fn shared_keys_in_multi_key_outputs_stay_down_until_the_last_owner_releases() { + let chord = Chord::from_keys(modifier::CTRL, [0x56, 0x26]); + let other = Chord::new(modifier::CTRL, 0x56); + let mut held = Held::default(); + assert_eq!(held.down(chord), chord); + held.down(other); + assert_eq!(held.up(chord), Chord::new(0, 0x26)); + assert_eq!(held.up(other), other); + assert!(held.keys.is_empty()); + assert_eq!(held.mods, [0; 4]); + } + #[test] fn plain_names() { assert_eq!(plain_name(0x41), "A"); @@ -343,10 +415,10 @@ mod tests { fn a_key_two_chords_hold_stays_down_until_both_let_go() { use modifier::{CTRL, SHIFT}; let mut held = Held::default(); - let (copy, paste) = (Chord { mods: CTRL, key: 0x43 }, Chord { mods: CTRL | SHIFT, key: 0x56 }); + let (copy, paste) = (Chord::new(CTRL, 0x43), Chord::new(CTRL | SHIFT, 0x56)); assert_eq!(held.down(copy), copy); assert_eq!(held.down(paste), paste); - assert_eq!(held.up(copy), Chord { mods: 0, key: 0x43 }, "Ctrl stays down for the other"); + assert_eq!(held.up(copy), Chord::new(0, 0x43), "Ctrl stays down for the other"); assert_eq!(held.up(paste), paste); // Let go of without having been pressed here: let go of all the same. assert_eq!(held.up(copy), copy); @@ -354,7 +426,7 @@ mod tests { #[test] fn a_macro_pressed_again_while_it_plays_is_not_queued() { - let (a, b) = (vec![Step::Keys(Chord { mods: 0, key: 0x41 })], vec![Step::Wait(10)]); + let (a, b) = (vec![Step::Keys(Chord::new(0, 0x41))], vec![Step::Wait(10)]); let mut plays = Plays::default(); assert!(plays.admit(&a)); assert!(!plays.admit(&a), "already playing"); @@ -369,13 +441,13 @@ mod tests { #[test] fn a_stopped_macro_lets_go_of_its_keys_at_once() { - let c = Chord { mods: modifier::CTRL, key: 0x43 }; + let c = Chord::new(modifier::CTRL, 0x43); let (keys, typed) = unbounded(); let (stop_tx, stop) = crossbeam_channel::bounded(0); let steps = [Step::Keys(c), Step::Wait(5000), Step::Keys(c)]; let started = std::time::Instant::now(); let (pads, _) = unbounded(); - let player = std::thread::spawn(move || play(&keys, &pads, 0, &steps, &stop)); + let player = std::thread::spawn(move || play(&keys, &pads, 0, &steps, &stop, TAP)); assert_eq!(typed.recv().unwrap(), Out::Down(c)); drop(stop_tx); player.join().unwrap(); @@ -388,12 +460,12 @@ mod tests { fn a_macro_taps_xbox_buttons_on_the_controller_that_played_it() { use crate::binding::XboxButton; let (a, lb) = (Buttons::of(&[XboxButton::A]), Buttons::of(&[XboxButton::LeftBumper, XboxButton::Up])); - let c = Chord { mods: 0, key: 0x41 }; + let c = Chord::new(0, 0x41); let (keys, typed) = unbounded(); let (pads, tapped) = unbounded(); let (_stop_tx, stop) = crossbeam_channel::bounded(0); let started = std::time::Instant::now(); - play(&keys, &pads, 7, &[Step::Xbox(a), Step::Keys(c), Step::Xbox(lb)], &stop); + play(&keys, &pads, 7, &[Step::Xbox(a), Step::Keys(c), Step::Xbox(lb)], &stop, TAP); let taps: Vec = tapped.try_iter().collect(); assert_eq!(taps, [Tap::Down(7, a), Tap::Up(7, a), Tap::Down(7, lb), Tap::Up(7, lb)]); assert_eq!(typed.try_iter().collect::>(), [Out::Down(c), Out::Up(c)]); @@ -407,10 +479,57 @@ mod tests { let (keys, _) = unbounded(); let (pads, tapped) = unbounded(); let (stop_tx, stop) = crossbeam_channel::bounded(0); - let player = std::thread::spawn(move || play(&keys, &pads, 3, &[Step::Xbox(a), Step::Wait(5000)], &stop)); + let player = std::thread::spawn(move || play(&keys, &pads, 3, &[Step::Xbox(a), Step::Wait(5000)], &stop, TAP)); assert_eq!(tapped.recv().unwrap(), Tap::Down(3, a)); drop(stop_tx); player.join().unwrap(); assert_eq!(tapped.try_iter().collect::>(), [Tap::Up(3, a)]); } + #[test] + fn shortcut_taps_span_several_background_hotkey_poll_intervals() { + let chord = Chord::new(modifier::CTRL | modifier::SHIFT, 5); + let (keys, output) = unbounded(); + let (pads, _) = unbounded(); + let (_stop, stopped) = crossbeam_channel::bounded(0); + let player = std::thread::spawn(move || play(&keys, &pads, 0, &[Step::Keys(chord)], &stopped, SHORTCUT_TAP)); + assert_eq!(output.recv().unwrap(), Out::Down(chord)); + // OBS polls background hotkeys every 25 ms. It must see a held chord, + // including at an unfavourable polling phase, rather than just a queued release. + assert!(matches!(output.recv_timeout(Duration::from_millis(60)), Err(RecvTimeoutError::Timeout))); + assert_eq!(output.recv_timeout(Duration::from_secs(1)).unwrap(), Out::Up(chord)); + player.join().unwrap(); + } + #[test] + fn a_long_shortcut_does_not_queue_or_slow_a_regular_macro() { + let shortcut = Chord::new(3, 5); + let macro_key = Chord::new(0, 0x41); + let (keys, output) = unbounded(); + let (pads, _) = unbounded(); + let (stop_tx, stop) = crossbeam_channel::bounded(0); + // A generous test hold makes worker isolation deterministic even on a busy machine. + let (shortcuts, _, _shortcut_worker) = start_player("test-shortcuts", keys.clone(), pads.clone(), stop.clone()); + let (macros, _, _macro_worker) = start_player("test-macros", keys, pads, stop); + shortcuts.send((vec![Step::Keys(shortcut)], 0, Duration::from_secs(2))).unwrap(); + assert_eq!(output.recv().unwrap(), Out::Down(shortcut)); + macros.send((vec![Step::Keys(macro_key)], 0, TAP)).unwrap(); + assert_eq!(output.recv_timeout(Duration::from_secs(1)).unwrap(), Out::Down(macro_key)); + assert_eq!(output.recv_timeout(Duration::from_secs(1)).unwrap(), Out::Up(macro_key)); + drop(stop_tx); + assert_eq!(output.recv_timeout(Duration::from_secs(1)).unwrap(), Out::Up(shortcut)); + } + #[test] + fn chosen_shortcut_duration_is_forwarded_per_job_and_macros_keep_their_timing() { + let chord = Chord::new(3, 5); + let (keys, _output) = unbounded(); + let (macros, macro_jobs) = unbounded(); + let (shortcuts, shortcut_jobs) = unbounded(); + let (_pads, taps) = unbounded(); + let (stop, _) = crossbeam_channel::bounded(0); + let keyboard = + Keyboard { keys, macros, shortcuts, taps, plays: Default::default(), _stop: Some(stop), workers: Vec::new(), key_worker: None }; + keyboard.shortcut(chord, crate::PadKey::Slot(7), 60_000); + assert_eq!(shortcut_jobs.recv().unwrap(), timed::Command::Press(chord, crate::PadKey::Slot(7), Duration::from_secs(60))); + keyboard.play(vec![Step::Keys(chord)], 7); + assert_eq!(macro_jobs.recv().unwrap(), (vec![Step::Keys(chord)], 7, TAP)); + } } diff --git a/crates/open-controller-core/src/keyboard/linux.rs b/crates/open-controller-core/src/keyboard/linux.rs index 73d1e15..05b9a1a 100644 --- a/crates/open-controller-core/src/keyboard/linux.rs +++ b/crates/open-controller-core/src/keyboard/linux.rs @@ -51,12 +51,12 @@ impl Sink { pub fn down(&self, c: Chord) { let mods = MODIFIERS.iter().filter(|(m, _)| c.mods & m != 0).map(|&(_, k)| k); - self.keys(mods.chain(code(c.key)), 1); + self.keys(mods.chain(c.keys().filter_map(code)), 1); } pub fn up(&self, c: Chord) { let mods = MODIFIERS.iter().rev().filter(|(m, _)| c.mods & m != 0).map(|&(_, k)| k); - self.keys(code(c.key).into_iter().chain(mods), 0); + self.keys(c.keys().rev().filter_map(code).chain(mods), 0); } } diff --git a/crates/open-controller-core/src/keyboard/macos.rs b/crates/open-controller-core/src/keyboard/macos.rs index 4e09b73..f97acea 100644 --- a/crates/open-controller-core/src/keyboard/macos.rs +++ b/crates/open-controller-core/src/keyboard/macos.rs @@ -62,14 +62,14 @@ impl Sink { self.post(key, true, flags); } } - if let Some(k) = code(c.key) { + for k in c.keys().filter_map(code) { self.post(k, true, flags); } } pub fn up(&self, c: Chord) { let mut flags: u64 = MODIFIERS.iter().filter(|(m, ..)| c.mods & m != 0).map(|&(.., f)| f).sum(); - if let Some(k) = code(c.key) { + for k in c.keys().rev().filter_map(code) { self.post(k, false, flags); } for &(m, key, flag) in MODIFIERS.iter().rev() { diff --git a/crates/open-controller-core/src/keyboard/timed.rs b/crates/open-controller-core/src/keyboard/timed.rs new file mode 100644 index 0000000..c28fe4d --- /dev/null +++ b/crates/open-controller-core/src/keyboard/timed.rs @@ -0,0 +1,102 @@ +//! Independent timed shortcut deadlines: a long hold never queues another shortcut behind it. +use super::Out; +use crate::{PadKey, binding::Chord}; +use crossbeam_channel::{Receiver, Sender, unbounded}; +use std::time::{Duration, Instant}; + +#[derive(Debug, PartialEq)] +pub(super) enum Command { + Press(Chord, PadKey, Duration), + Cancel(PadKey), +} + +struct Lease { + chord: Chord, + owner: PadKey, + until: Instant, +} +#[derive(Default)] +struct Leases(Vec); +impl Leases { + fn press(&mut self, chord: Chord, owner: PadKey, until: Instant) -> Option { + if self.0.len() >= 128 || self.0.iter().any(|p| p.owner == owner && p.chord == chord) { + return None; + } + self.0.push(Lease { chord, owner, until }); + Some(Out::Down(chord)) + } + fn release(&mut self, predicate: impl Fn(&Lease) -> bool) -> Vec { + let mut out = Vec::new(); + self.0.retain(|p| { + if predicate(p) { + out.push(Out::Up(p.chord)); + false + } else { + true + } + }); + out + } + fn wait(&self, now: Instant) -> Duration { + self.0.iter().map(|p| p.until.saturating_duration_since(now)).min().unwrap_or(Duration::from_secs(60)) + } +} + +pub(super) fn start(keys: Sender, stop: Receiver<()>) -> (Sender, std::thread::JoinHandle<()>) { + let (send, jobs) = unbounded(); + let worker = std::thread::Builder::new() + .name("open-controller-shortcuts".into()) + .spawn(move || { + let mut leases = Leases::default(); + loop { + crossbeam_channel::select! { + recv(stop) -> _ => break, + recv(jobs) -> job => match job { + Ok(Command::Press(chord, owner, duration)) => { + if let Some(out) = leases.press(chord, owner, Instant::now() + duration) { let _ = keys.send(out); } + } + Ok(Command::Cancel(owner)) => for out in leases.release(|p| p.owner == owner) { let _ = keys.send(out); }, + Err(_) => break, + }, + default(leases.wait(Instant::now())) => {} + } + for out in leases.release(|p| p.until <= Instant::now()) { + let _ = keys.send(out); + } + } + for out in leases.release(|_| true) { + let _ = keys.send(out); + } + }) + .expect("could not start the shortcut timer"); + (send, worker) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn long_shortcut_does_not_delay_another_and_cancel_is_owner_scoped() { + let a = Chord::new(1, 9); + let b = Chord::new(3, 5); + let now = Instant::now(); + let mut leases = Leases::default(); + assert_eq!(leases.press(a, PadKey::Slot(0), now + Duration::from_secs(60)), Some(Out::Down(a))); + assert_eq!(leases.press(b, PadKey::Slot(1), now + Duration::from_millis(100)), Some(Out::Down(b))); + assert_eq!(leases.release(|p| p.until <= now + Duration::from_millis(100)), [Out::Up(b)]); + assert!(leases.release(|p| p.owner == PadKey::Slot(1)).is_empty()); + assert_eq!(leases.release(|p| p.owner == PadKey::Slot(0)), [Out::Up(a)]); + } + #[test] + fn stopping_the_worker_releases_long_outputs_immediately() { + let chord = Chord::new(4, 9); + let (keys, output) = unbounded(); + let (stop, stopped) = crossbeam_channel::bounded(0); + let (jobs, worker) = start(keys, stopped); + jobs.send(Command::Press(chord, PadKey::Slot(0), Duration::from_secs(60))).unwrap(); + assert_eq!(output.recv().unwrap(), Out::Down(chord)); + drop(stop); + assert_eq!(output.recv_timeout(Duration::from_secs(1)).unwrap(), Out::Up(chord)); + worker.join().unwrap(); + } +} diff --git a/crates/open-controller-core/src/keyboard/windows.rs b/crates/open-controller-core/src/keyboard/windows.rs index f94f82d..08023f7 100644 --- a/crates/open-controller-core/src/keyboard/windows.rs +++ b/crates/open-controller-core/src/keyboard/windows.rs @@ -3,8 +3,9 @@ use crate::binding::{Chord, modifier}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - GetKeyNameTextW, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, MAPVK_VK_TO_VSC_EX, - MapVirtualKeyW, SendInput, VK_CONTROL, VK_LWIN, VK_MENU, VK_SHIFT, VkKeyScanW, + GetKeyNameTextW, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, MAPVK_VK_TO_VSC_EX, + MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, MapVirtualKeyW, SendInput, VK_CONTROL, VK_LWIN, VK_MENU, VK_SHIFT, VkKeyScanW, }; /// `SendInput` needs nothing opened first. @@ -32,22 +33,33 @@ const MODIFIERS: [(u8, u16); 4] = fn down(c: Chord) -> Vec { let mut v: Vec = MODIFIERS.iter().filter(|(m, _)| c.mods & m != 0).map(|&(_, vk)| key(vk, false)).collect(); - if c.key != 0 { - v.push(key(c.key, false)); - } + v.extend(c.keys().map(|vk| key(vk, false))); v } fn up(c: Chord) -> Vec { let mut v = Vec::new(); - if c.key != 0 { - v.push(key(c.key, true)); - } + v.extend(c.keys().rev().map(|vk| key(vk, true))); v.extend(MODIFIERS.iter().rev().filter(|(m, _)| c.mods & m != 0).map(|&(_, vk)| key(vk, true))); v } fn key(vk: u16, release: bool) -> INPUT { + if let Some((down, up, data)) = match vk { + 1 => Some((MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0)), + 2 => Some((MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 0)), + 4 => Some((MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, 0)), + 5 => Some((MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, 1)), + 6 => Some((MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, 2)), + _ => None, + } { + return INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { dx: 0, dy: 0, mouseData: data, dwFlags: if release { up } else { down }, time: 0, dwExtraInfo: 0 }, + }, + }; + } let scan = unsafe { MapVirtualKeyW(vk as u32, MAPVK_VK_TO_VSC_EX) }; let mut flags = if release { KEYEVENTF_KEYUP } else { 0 }; if is_extended(vk, scan) { @@ -112,9 +124,23 @@ pub fn key_name(vk: u16) -> String { mod tests { use super::*; + #[test] + fn multi_key_output_presses_every_key_and_releases_in_reverse_order() { + let c = Chord::from_keys(modifier::CTRL, [0x56, 0x26]); + let vks = |v: Vec| v.iter().map(|i| unsafe { (i.Anonymous.ki.wVk, i.Anonymous.ki.dwFlags) }).collect::>(); + let pressed = vks(down(c)); + assert_eq!(pressed.iter().map(|i| i.0).collect::>(), [VK_CONTROL, 0x56, 0x26]); + assert!(pressed[2].1 & KEYEVENTF_EXTENDEDKEY != 0, "Up must arrive as the arrow, not the numpad key"); + assert!(pressed.iter().all(|i| i.1 & KEYEVENTF_KEYUP == 0)); + let released = vks(up(c)); + assert_eq!(released.iter().map(|i| i.0).collect::>(), [0x26, 0x56, VK_CONTROL]); + assert!(released.iter().all(|i| i.1 & KEYEVENTF_KEYUP != 0)); + assert_eq!(super::super::chord_names(c), ["Ctrl".to_string(), "V".to_string(), key_name(0x26)]); + } + #[test] fn chords_press_modifiers_first_and_release_them_last() { - let c = Chord { mods: modifier::CTRL | modifier::SHIFT, key: 0x41 }; + let c = Chord::new(modifier::CTRL | modifier::SHIFT, 0x41); let vks = |v: Vec| { v.iter().map(|i| unsafe { (i.Anonymous.ki.wVk, i.Anonymous.ki.dwFlags & KEYEVENTF_KEYUP != 0) }).collect::>() }; @@ -122,6 +148,19 @@ mod tests { assert_eq!(vks(up(c)), [(0x41, true), (VK_SHIFT, true), (VK_CONTROL, true)]); } + #[test] + fn obs_mouse4_chord_sends_modifiers_before_the_side_button_and_releases_them_last() { + let c = Chord::new(modifier::CTRL | modifier::SHIFT, 5); + let down = down(c); + let up = up(c); + assert_eq!(down.iter().map(|i| i.r#type).collect::>(), [INPUT_KEYBOARD, INPUT_KEYBOARD, INPUT_MOUSE]); + assert_eq!(unsafe { down[2].Anonymous.mi.dwFlags }, MOUSEEVENTF_XDOWN); + assert_eq!(unsafe { down[2].Anonymous.mi.mouseData }, 1); + assert_eq!(up.iter().map(|i| i.r#type).collect::>(), [INPUT_MOUSE, INPUT_KEYBOARD, INPUT_KEYBOARD]); + assert_eq!(unsafe { up[0].Anonymous.mi.dwFlags }, MOUSEEVENTF_XUP); + assert_eq!(unsafe { up[0].Anonymous.mi.mouseData }, 1); + } + #[test] fn arrows_are_extended_keys() { let k = key(0x25, false); @@ -133,7 +172,7 @@ mod tests { #[test] fn names() { assert_eq!(key_name(0x7C), "F13"); - assert_eq!(super::super::chord_names(Chord { mods: modifier::CTRL, key: 0x70 }), ["Ctrl", "F1"]); + assert_eq!(super::super::chord_names(Chord::new(modifier::CTRL, 0x70)), ["Ctrl", "F1"]); assert_eq!(vk_for_char('a'), Some(0x41)); } } diff --git a/crates/open-controller-core/src/lib.rs b/crates/open-controller-core/src/lib.rs index b55512b..6c824ee 100644 --- a/crates/open-controller-core/src/lib.rs +++ b/crates/open-controller-core/src/lib.rs @@ -7,6 +7,7 @@ //! tested on its own. pub mod binding; +pub mod button_mode; pub mod crash; pub mod device; pub mod ds3; @@ -16,10 +17,14 @@ pub mod i18n; pub mod mapping; pub mod models; pub mod motion; +pub mod portable; +pub mod press_meter; pub mod profile; pub mod rating; pub mod report; pub mod roster; +pub mod shortcut_edit; +pub mod shortcuts; mod engine; pub mod handheld; diff --git a/crates/open-controller-core/src/portable.rs b/crates/open-controller-core/src/portable.rs new file mode 100644 index 0000000..d9baf13 --- /dev/null +++ b/crates/open-controller-core/src/portable.rs @@ -0,0 +1,378 @@ +//! Portable controller profiles and a controller-independent library of named output actions. +use crate::device::Form; +use crate::extras::Family; +use crate::{ + PadView, + binding::{Action, Chord}, + keyboard, + profile::Profiles, + shortcuts, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::{ + io::{Read, Write}, + path::Path, +}; + +pub const MAX_BYTES: u64 = 4 * 1024 * 1024; +pub const MAX_ACTIONS: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControllerIdentity { + pub name: String, + pub vendor: u16, + pub product: u16, + pub family: Family, + pub form: Form, + pub guid: String, + pub store: Option, +} + +impl From<&PadView> for ControllerIdentity { + fn from(p: &PadView) -> Self { + Self { + name: p.name.clone(), + vendor: p.vendor, + product: p.product, + family: p.family, + form: p.form, + guid: p.guid.clone(), + store: p.store.clone(), + } + } +} + +impl ControllerIdentity { + pub fn compatible(&self, target: &Self) -> bool { + let same_model = self.vendor == target.vendor + && self.product == target.product + && self.family == target.family + && (self.vendor != 0 || (!self.guid.is_empty() && self.guid == target.guid)); + self.form == target.form && same_model + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ControllerFile { + pub format: String, + pub version: u32, + pub controller: ControllerIdentity, + pub profiles: Profiles, + pub actions: ActionLibrary, +} + +impl ControllerFile { + pub fn new(pad: &PadView, library: ActionLibrary) -> Result { + Ok(Self { + format: "opencontroller.configuration".into(), + version: 1, + controller: pad.into(), + profiles: pad.profiles.clone().sanitised(), + actions: library.with_profiles(&pad.profiles)?, + }) + } + + /// A mismatch can always import global actions, but must never apply controller inputs. + pub fn profiles_for(&self, target: &ControllerIdentity, full: bool) -> Result, String> { + if !full { + return Ok(None); + } + if !self.controller.compatible(target) { + return Err("The controller does not match this configuration.".into()); + } + Ok(Some(self.profiles.clone().sanitised())) + } + + /// Global import creates unassigned rows only in the selected profile. + pub fn global_profiles(&self, target: &Profiles, anchor: u8) -> Result { + let mut profiles = target.clone(); + let p = profiles.list.get_mut(target.active).ok_or("No selected profile.")?; + for a in &self.actions.actions { + let bound = p.shortcuts.iter().any(|(input, keys)| { + *keys == a.keys + && p.shortcut_hold_ms(*input) == a.hold_ms + && p.shortcut_held.contains(input) == a.hold_until_release + && p.shortcut_names.get(input).cloned().unwrap_or_else(|| keyboard::chord_names(*keys).join(" + ")) == a.name + }); + let unassigned = p + .unassigned_shortcuts + .values() + .any(|s| s.keys == a.keys && s.name == a.name && s.hold_ms == a.hold_ms && s.hold_until_release == a.hold_until_release); + if !bound + && !unassigned + && !p.stash_shortcut(shortcuts::ShortcutAction { + name: a.name.clone(), + keys: a.keys, + anchor, + hold_ms: a.hold_ms, + hold_until_release: a.hold_until_release, + }) + { + return Err("The selected profile has too many unassigned actions (maximum 256).".into()); + } + } + p.select_button_mode(anchor, crate::button_mode::Mode::Combinations); + Ok(profiles) + } + + pub fn validate(mut self) -> Result { + if self.format != "opencontroller.configuration" || self.version != 1 { + return Err("Unsupported configuration format or version.".into()); + } + if self.profiles.list.is_empty() { + return Err("The configuration contains no profiles.".into()); + } + self.profiles = self.profiles.sanitised(); + self.actions = self.actions.validate()?; + Ok(self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NamedAction { + #[serde(default)] + pub hold_until_release: bool, + #[serde(default = "shortcuts::default_hold_ms")] + pub hold_ms: u16, + pub name: String, + pub keys: Chord, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActionLibrary { + pub format: String, + pub version: u32, + pub actions: Vec, +} + +impl Default for ActionLibrary { + fn default() -> Self { + Self { format: "opencontroller.actions".into(), version: 1, actions: Vec::new() } + } +} + +impl ActionLibrary { + pub fn validate(mut self) -> Result { + if self.format != "opencontroller.actions" || self.version != 1 { + return Err("Unsupported actions library format or version.".into()); + } + if self.actions.len() > MAX_ACTIONS { + return Err("The library contains more than 256 actions.".into()); + } + let mut unique = Vec::new(); + for mut a in self.actions { + if a.keys.is_empty() || a.keys.mods & !15 != 0 || a.keys.key > 255 { + return Err("An action contains an invalid keyboard or mouse shortcut.".into()); + } + a.name = shortcuts::clean_name(&a.name); + a.hold_ms = shortcuts::hold_ms(a.hold_ms); + if a.name.is_empty() { + a.name = keyboard::chord_names(a.keys).join(" + "); + } + if !unique.contains(&a) { + unique.push(a); + } + } + unique.sort_by_key(|a| (a.name.to_lowercase(), a.keys.mods, a.keys.keys().collect::>(), a.hold_ms, a.hold_until_release)); + self.actions = unique; + Ok(self) + } + + pub fn with_profiles(mut self, profiles: &Profiles) -> Result { + for p in &profiles.list { + for a in p.unassigned_shortcuts.values() { + let a = NamedAction { name: a.name.clone(), keys: a.keys, hold_ms: a.hold_ms, hold_until_release: a.hold_until_release }; + if !self.actions.contains(&a) { + self.actions.push(a); + } + } + for (&input, &keys) in &p.shortcuts { + let a = NamedAction { + hold_ms: p.shortcut_hold_ms(input), + hold_until_release: p.shortcut_held.contains(&input), + name: p.shortcut_names.get(&input).cloned().unwrap_or_else(|| keyboard::chord_names(keys).join(" + ")), + keys, + }; + if !self.actions.contains(&a) { + self.actions.push(a); + } + } + for action in p.bindings.values() { + if let Action::Keys(keys) = action { + let a = NamedAction { + name: keyboard::chord_names(*keys).join(" + "), + keys: *keys, + hold_ms: shortcuts::DEFAULT_HOLD_MS, + hold_until_release: false, + }; + if !self.actions.iter().any(|a| a.keys == *keys) { + self.actions.push(a); + } + } + } + } + self.validate() + } + + pub fn merge(mut self, other: Self) -> Result { + for a in other.validate()?.actions { + if !self.actions.contains(&a) { + self.actions.push(a); + } + } + self.validate() + } +} + +pub fn read(path: &Path) -> Result { + let mut bytes = Vec::new(); + std::fs::File::open(path).and_then(|f| f.take(MAX_BYTES + 1).read_to_end(&mut bytes)).map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Configuration file is too large (maximum 4 MB).".into()); + } + serde_json::from_slice(&bytes).map_err(|e| format!("Invalid configuration: {e}")) +} + +pub fn write(path: &Path, value: &T) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + let tmp = path.with_extension(format!("oc-tmp-{}-{stamp}", std::process::id())); + let result = (|| { + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?; + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&tmp, path) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result.map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{binding::modifier, profile::Edit}; + fn identity() -> ControllerIdentity { + ControllerIdentity { + name: "Switch Pro".into(), + vendor: 0x57e, + product: 0x2009, + family: Family::SwitchPro, + form: Form::Gamepad, + guid: "switch".into(), + store: Some("serial:original".into()), + } + } + #[test] + fn global_actions_preserve_every_key_in_a_multi_key_output() { + let keys = Chord::from_keys(modifier::CTRL, [0x56, 0x26]); + let library = ActionLibrary { + actions: vec![NamedAction { name: "AudioSync volume".into(), keys, hold_ms: 100, hold_until_release: true }], + ..ActionLibrary::default() + }; + let json = serde_json::to_string(&library).unwrap(); + let loaded: ActionLibrary = serde_json::from_str(&json).unwrap(); + let loaded = loaded.validate().unwrap(); + assert_eq!(loaded.actions[0].keys, keys); + assert!(loaded.actions[0].hold_until_release); + } + + #[test] + fn controller_file_is_model_bound_but_works_with_a_new_store_on_another_pc() { + let source = identity(); + let mut target = source.clone(); + target.store = Some("serial:other-pc".into()); + let file = ControllerFile { + format: "opencontroller.configuration".into(), + version: 1, + controller: source, + profiles: Profiles::default(), + actions: ActionLibrary::default(), + }; + assert!(file.controller.compatible(&target)); + assert!(file.clone().validate().is_ok()); + assert!(file.profiles_for(&target, true).unwrap().is_some()); + target.vendor = 0x54c; + target.family = Family::DualSense; + assert!(!file.controller.compatible(&target)); + assert!(file.profiles_for(&target, true).is_err()); + assert!(file.profiles_for(&target, false).unwrap().is_none()); + assert!(file.validate().is_ok(), "global actions remain usable for another controller"); + } + #[test] + fn global_actions_roundtrip_without_controller_inputs_and_preserve_mouse_modifiers() { + let mut p = Profiles::default(); + p.apply(Edit::Shortcut { + anchor: 15, + input: (1 << 15) | 1, + name: Some("OBS recording".into()), + keys: Some(Chord::new(modifier::CTRL | modifier::SHIFT, 5)), + }); + let library = ActionLibrary::default().with_profiles(&p).unwrap(); + let text = serde_json::to_string(&library).unwrap(); + assert!(!text.contains("anchor") && !text.contains("serial") && !text.contains("32769")); + let loaded: ActionLibrary = serde_json::from_str(&text).unwrap(); + assert_eq!(loaded.validate().unwrap(), library); + assert_eq!(library.actions[0].keys, Chord::new(3, 5)); + assert_eq!(library.clone().merge(library.clone()).unwrap(), library); + } + #[test] + fn wrong_file_types_and_invalid_actions_are_rejected() { + let library = ActionLibrary { version: 99, ..Default::default() }; + assert!(library.validate().is_err()); + let library = ActionLibrary { + actions: vec![NamedAction { name: "bad".into(), keys: Chord::new(32, 5), hold_ms: 100, hold_until_release: false }], + ..Default::default() + }; + assert!(library.validate().is_err()); + } + #[test] + fn a_single_file_roundtrips_and_overwriting_leaves_no_side_files() { + let dir = std::env::temp_dir().join(format!("oc-portable-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("configuration.json"); + let mut profiles = Profiles::default(); + profiles.apply(Edit::Shortcut { anchor: 15, input: (1 << 15) | 1, name: Some("OBS".into()), keys: Some(Chord::new(3, 5)) }); + let file = ControllerFile { + format: "opencontroller.configuration".into(), + version: 1, + controller: identity(), + actions: ActionLibrary::default().with_profiles(&profiles).unwrap(), + profiles, + }; + write(&path, &file).unwrap(); + write(&path, &file).unwrap(); + assert_eq!(read::(&path).unwrap().validate().unwrap(), file); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::write(&path, vec![b' '; (MAX_BYTES + 1) as usize]).unwrap(); + assert!(read::(&path).unwrap_err().contains("too large")); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] + fn global_import_creates_unassigned_rows_only_in_the_selected_profile_without_duplicates() { + let mut source = Profiles::default(); + source.apply(Edit::Shortcut { anchor: 15, input: 32769, name: Some("OBS".into()), keys: Some(Chord::new(3, 5)) }); + source.list[0].shortcut_durations.insert(32769, 250); + source.list[0].shortcut_held.insert(32769); + let file = ControllerFile { + format: "opencontroller.configuration".into(), + version: 1, + controller: identity(), + actions: ActionLibrary::default().with_profiles(&source).unwrap(), + profiles: source.clone(), + }; + let mut target = source; + target.apply(Edit::Add("Other".into())); + let before = target.list[0].clone(); + let imported = file.global_profiles(&target, 15).unwrap(); + assert_eq!(imported.list[0], before); + assert!(imported.active().shortcuts.is_empty()); + assert_eq!(imported.active().unassigned_shortcuts.len(), 1); + assert_eq!(imported.active().unassigned_shortcuts[&0].hold_ms, 250); + assert!(imported.active().unassigned_shortcuts[&0].hold_until_release); + assert_eq!(file.global_profiles(&imported, 15).unwrap(), imported); + let exported = ActionLibrary::default().with_profiles(&imported).unwrap(); + assert_eq!(exported.actions.len(), 1); + } +} diff --git a/crates/open-controller-core/src/press_meter.rs b/crates/open-controller-core/src/press_meter.rs new file mode 100644 index 0000000..8815b29 --- /dev/null +++ b/crates/open-controller-core/src/press_meter.rs @@ -0,0 +1,103 @@ +//! Observed input hold times, collected only during an explicit controller recording session. +//! These are report-level observations, not hardware latency or a device minimum specification. +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Reading { + pub input: u64, + pub held_us: u64, + pub minimum_us: u64, + pub samples: u32, +} + +pub struct Meter { + ready: bool, + held: u64, + since: Instant, + peak: u64, + longest: Duration, + stats: BTreeMap, + pub latest: Option, +} + +impl Meter { + pub fn new(held: u64, now: Instant) -> Self { + Self { ready: held == 0, held, since: now, peak: 0, longest: Duration::ZERO, stats: BTreeMap::new(), latest: None } + } + + pub fn feed(&mut self, held: u64, now: Instant) { + if !self.ready { + self.ready = held == 0; + self.held = held; + self.since = now; + return; + } + if held == self.held { + return; + } + if self.held != 0 { + let span = now.saturating_duration_since(self.since); + if self.held.count_ones() > self.peak.count_ones() || self.held.count_ones() == self.peak.count_ones() && span > self.longest { + self.peak = self.held; + self.longest = span; + } + } + self.held = held; + self.since = now; + if held == 0 && self.peak != 0 { + let us = self.longest.as_micros().min(u128::from(u64::MAX)) as u64; + if self.stats.len() >= 128 && !self.stats.contains_key(&self.peak) { + self.stats.clear(); + } + let stats = self.stats.entry(self.peak).or_insert((us, 0)); + stats.0 = stats.0.min(us); + stats.1 = stats.1.saturating_add(1); + self.latest = Some(Reading { input: self.peak, held_us: us, minimum_us: stats.0, samples: stats.1 }); + self.peak = 0; + self.longest = Duration::ZERO; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn single_button_observed_minimum_is_per_input_not_a_hardware_claim() { + let start = Instant::now(); + let mut meter = Meter::new(0, start); + meter.feed(1, start); + meter.feed(0, start + Duration::from_millis(180)); + meter.feed(1, start + Duration::from_millis(200)); + meter.feed(0, start + Duration::from_millis(250)); + assert_eq!(meter.latest, Some(Reading { input: 1, held_us: 50_000, minimum_us: 50_000, samples: 2 })); + meter.feed(2, start + Duration::from_millis(300)); + meter.feed(0, start + Duration::from_millis(390)); + assert_eq!(meter.latest.as_ref().unwrap().minimum_us, 90_000); + } + #[test] + fn combination_reports_the_simultaneous_hold_not_the_total_gesture() { + let start = Instant::now(); + let mut meter = Meter::new(0, start); + meter.feed(1, start); + meter.feed(3, start + Duration::from_millis(120)); + meter.feed(1, start + Duration::from_millis(150)); + meter.feed(0, start + Duration::from_millis(400)); + assert_eq!(meter.latest, Some(Reading { input: 3, held_us: 30_000, minimum_us: 30_000, samples: 1 })); + } + #[test] + fn preheld_buttons_and_incomplete_gestures_do_not_create_measurements() { + let start = Instant::now(); + let mut meter = Meter::new(1, start); + meter.feed(0, start + Duration::from_secs(1)); + assert!(meter.latest.is_none()); + meter.feed(1, start + Duration::from_secs(2)); + assert!(meter.latest.is_none()); + meter.feed(0, start + Duration::from_secs(3)); + assert_eq!(meter.latest.unwrap().held_us, 1_000_000); + } +} diff --git a/crates/open-controller-core/src/profile.rs b/crates/open-controller-core/src/profile.rs index 286a861..713893b 100644 --- a/crates/open-controller-core/src/profile.rs +++ b/crates/open-controller-core/src/profile.rs @@ -2,11 +2,15 @@ //! its sticks and gyro behave, and which programs switch to them. One is in use; a profile that //! names the program in front takes over while that program is in front. +use crate::binding::Chord; use crate::binding::{self, Action, Bindings}; +use crate::button_mode::{Mode, Presets}; use crate::device::Form; +use crate::shortcuts::{self, Shortcuts}; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; pub const MAX_PROFILES: usize = 8; pub const MAX_NAME: usize = 32; @@ -253,6 +257,22 @@ pub struct Profile { #[serde(deserialize_with = "lenient_bindings")] pub bindings: Bindings, #[serde(deserialize_with = "lenient")] + pub shortcuts: Shortcuts, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeMap::is_empty")] + pub shortcut_durations: BTreeMap, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeSet::is_empty")] + pub shortcut_held: BTreeSet, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeMap::is_empty")] + pub unassigned_shortcuts: BTreeMap, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeMap::is_empty")] + pub button_modes: BTreeMap, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeMap::is_empty")] + pub button_presets: BTreeMap, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeMap::is_empty")] + pub shortcut_anchors: BTreeMap, + #[serde(deserialize_with = "lenient", skip_serializing_if = "BTreeMap::is_empty")] + pub shortcut_names: BTreeMap, + #[serde(deserialize_with = "lenient")] pub light: Light, /// How bright a chosen or battery colour is, in percent. pub brightness: u8, @@ -274,6 +294,14 @@ impl Default for Profile { Profile { name: String::new(), bindings: Bindings::new(), + shortcuts: Shortcuts::new(), + shortcut_durations: BTreeMap::new(), + shortcut_held: BTreeSet::new(), + unassigned_shortcuts: BTreeMap::new(), + button_modes: BTreeMap::new(), + button_presets: BTreeMap::new(), + shortcut_anchors: BTreeMap::new(), + shortcut_names: BTreeMap::new(), light: Light::Player, brightness: 100, low_battery_flash: true, @@ -344,6 +372,28 @@ pub enum Edit { }, /// Clears every assignment of the profile in use. ClearBindings, + ChangeShortcut(crate::shortcut_edit::Change), + RemoveShortcut { + profile: usize, + input: u64, + }, + RemoveUnassigned { + profile: usize, + id: u64, + }, + /// An incoming controller combination that taps a keyboard shortcut. + ButtonMode { + button: u8, + mode: Mode, + }, + Shortcut { + /// Missing in earlier requests: keep the name when only the binding is edited. + #[serde(default)] + name: Option, + anchor: u8, + input: u64, + keys: Option, + }, /// Makes a Nintendo controller's face buttons follow their letters rather than their places /// (see [`binding::letters`]), or puts them back. Letters(bool), @@ -358,7 +408,7 @@ pub enum Edit { /// Makes a profile the one in use while this program is in front. AddProgram(usize, String), RemoveProgram(usize, String), - /// A new profile, a copy of the one in use, which it replaces as the one in use. + /// A new independent profile with default settings. Add(String), Rename(usize, String), Delete(usize), @@ -452,21 +502,79 @@ impl Profiles { match edit { // The same limit `sanitised` keeps: buttons are bits of a u64. Edit::Bind { button: 64.., .. } => {} - Edit::Bind { button, action } => { - let b = &mut self.active_mut().bindings; - match action.and_then(|a| a.sanitised()) { - Some(a) => b.insert(button, a), - None => b.remove(&button), - }; + Edit::Bind { button, action } => self.active_mut().assign_button(button, action), + Edit::ButtonMode { button, mode } => self.active_mut().select_button_mode(button, mode), + Edit::ClearBindings => { + let p = self.active_mut(); + p.bindings.clear(); + p.shortcuts.clear(); + p.shortcut_durations.clear(); + p.shortcut_held.clear(); + p.unassigned_shortcuts.clear(); + p.button_modes.clear(); + p.button_presets.clear(); + p.shortcut_anchors.clear(); + p.shortcut_names.clear(); + } + Edit::RemoveShortcut { profile, input } => { + if profile == self.active { + let p = self.active_mut(); + p.shortcuts.remove(&input); + p.shortcut_durations.remove(&input); + p.shortcut_held.remove(&input); + p.shortcut_names.remove(&input); + p.shortcut_anchors.remove(&input); + } + } + Edit::ChangeShortcut(change) => { + if change.profile == self.active { + self.active_mut().change_shortcut(change); + } + } + Edit::RemoveUnassigned { profile, id } => { + if profile == self.active { + self.active_mut().unassigned_shortcuts.remove(&id); + } + } + Edit::Shortcut { name, anchor, input, keys } => { + let p = self.active_mut(); + match keys { + Some(c) + if anchor < 62 + && shortcuts::valid(input, c) + && (p.shortcuts.contains_key(&input) || p.shortcuts.len() < shortcuts::MAX_SHORTCUTS) => + { + p.shortcuts.insert(input, c); + p.shortcut_anchors.insert(input, anchor); + if let Some(name) = name { + let name = shortcuts::clean_name(&name); + if name.is_empty() { + p.shortcut_names.remove(&input); + } else { + p.shortcut_names.insert(input, name); + } + } + p.select_button_mode(anchor, Mode::Combinations); + } + None => { + p.shortcuts.remove(&input); + p.shortcut_durations.remove(&input); + p.shortcut_held.remove(&input); + p.shortcut_anchors.remove(&input); + p.shortcut_names.remove(&input); + } + _ => {} + } } - Edit::ClearBindings => self.active_mut().bindings.clear(), Edit::Letters(on) => { - let b = &mut self.active_mut().bindings; + let p = self.active_mut(); for (button, action) in binding::letters() { if on { - b.insert(button, action); - } else if b.get(&button) == Some(&action) { - b.remove(&button); + p.assign_button(button, Some(action)); + } else if p.bindings.get(&button) == Some(&action) { + p.bindings.remove(&button); + p.button_modes.remove(&button); + p.button_presets.remove(&button); } } } @@ -510,7 +618,7 @@ impl Profiles { } Edit::Add(name) if self.list.len() < MAX_PROFILES => { // Programs stay with the profile that named them. - let copy = Profile { name: clean_name(&name), programs: Vec::new(), ..self.active().clone() }; + let copy = Profile { name: clean_name(&name), ..Profile::default() }; self.list.push(copy); self.active = self.list.len() - 1; } @@ -544,6 +652,23 @@ impl Profiles { } for p in &mut self.list { p.name = clean_name(&p.name); + p.shortcut_held.retain(|input| p.shortcuts.contains_key(input)); + p.shortcut_durations.retain(|input, ms| { + *ms = shortcuts::hold_ms(*ms); + p.shortcuts.contains_key(input) + }); + p.shortcuts.retain(|&mask, keys| shortcuts::valid(mask, *keys)); + p.shortcuts = std::mem::take(&mut p.shortcuts).into_iter().take(shortcuts::MAX_SHORTCUTS).collect(); + p.shortcut_names.retain(|input, name| { + *name = shortcuts::clean_name(name); + p.shortcuts.contains_key(input) && !name.is_empty() + }); + p.unassigned_shortcuts.retain(|_, a| { + a.name = shortcuts::clean_name(&a.name); + a.hold_ms = shortcuts::hold_ms(a.hold_ms); + a.anchor < 62 && !a.keys.is_empty() && a.keys.mods & !15 == 0 && a.keys.key <= 255 + }); + p.unassigned_shortcuts = std::mem::take(&mut p.unassigned_shortcuts).into_iter().take(shortcuts::MAX_UNASSIGNED).collect(); p.brightness = p.brightness.clamp(10, 100); p.gyro = clean_gyro(p.gyro); p.sticks = clean_sticks(p.sticks); @@ -559,6 +684,7 @@ impl Profiles { .filter(|&(b, _)| b < 64) .filter_map(|(b, a)| a.sanitised().map(|a| (b, a))) .collect(); + p.sanitise_button_modes(); } self.active = self.active.min(self.list.len() - 1); self @@ -649,13 +775,53 @@ mod tests { use super::*; use crate::binding::XboxButton; + #[test] + fn incoming_shortcuts_roundtrip_and_new_profiles_do_not_inherit_assignments() { + let mut p = Profiles::default(); + let mask = (1 << 15) | crate::shortcuts::LT; + let keys = crate::binding::Chord::new(5, 121); + p.apply(Edit::Bind { button: 15, action: Some(Action::Keys(keys)) }); + p.apply(Edit::Shortcut { name: Some(" Captura HDR: región ".into()), anchor: 15, input: mask, keys: Some(keys) }); + assert_eq!(p.active().shortcut_names[&mask], "Captura HDR: región"); + p.apply(Edit::Shortcut { name: None, anchor: 15, input: mask, keys: Some(keys) }); + assert_eq!(p.active().shortcut_names[&mask], "Captura HDR: región", "an older edit preserves the name"); + let json = serde_json::to_string(&p).unwrap(); + let restored: Profiles = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, p); + p.apply(Edit::Add("Copy".into())); + assert!(p.active().shortcuts.is_empty()); + p.apply(Edit::ClearBindings); + assert!(p.active().shortcuts.is_empty()); + assert!(p.active().shortcut_names.is_empty()); + assert!(p.active().bindings.is_empty()); + assert_eq!(p.list[0].shortcuts.get(&mask), Some(&keys)); + assert_eq!(p.list[0].shortcut_names[&mask], "Captura HDR: región"); + let old: Profiles = serde_json::from_str(r#"{"list":[{"bindings":{"15":{"Keys":{"mods":0,"key":121}}}}]}"#).unwrap(); + assert!(old.active().shortcuts.is_empty()); + assert_eq!(old.active().bindings.len(), 1); + } + + #[test] + fn incoming_shortcuts_reject_empty_inputs_and_invalid_keys() { + let mut p = Profiles::default(); + let valid = crate::binding::Chord::new(5, 121); + for (input, keys) in [(0, valid), (3, Chord::default()), (3, Chord::new(32, 121)), (3, Chord::new(0, 65535))] { + p.apply(Edit::Shortcut { name: None, anchor: 15, input, keys: Some(keys) }); + } + assert!(p.active().shortcuts.is_empty()); + p.apply(Edit::Shortcut { name: None, anchor: 15, input: 3, keys: Some(valid) }); + assert_eq!(p.active().shortcuts.len(), 1); + p.apply(Edit::Shortcut { name: None, anchor: 15, input: 3, keys: None }); + assert!(p.active().shortcuts.is_empty()); + } + #[test] fn edits_apply_to_the_profile_in_use() { let mut p = Profiles::new("Default".into()); p.apply(Edit::Bind { button: 17, action: Some(Action::Xbox(XboxButton::A)) }); p.apply(Edit::Add("Racing".into())); assert_eq!(p.active, 1); - assert_eq!(p.active().bindings.len(), 1, "a new profile starts as a copy"); + assert!(p.active().bindings.is_empty(), "a new profile starts independently"); p.apply(Edit::Bind { button: 17, action: None }); assert!(p.active().bindings.is_empty()); p.apply(Edit::Bind { button: 64, action: Some(Action::Xbox(XboxButton::A)) }); @@ -683,7 +849,7 @@ mod tests { fn remapped_buttons_round_trip() { let mut p = Profiles::new("Zelda".into()); p.apply(Edit::Letters(true)); - p.apply(Edit::Bind { button: 4, action: Some(Action::Keys(crate::binding::Chord { mods: 0, key: 0x4D })) }); + p.apply(Edit::Bind { button: 4, action: Some(Action::Keys(crate::binding::Chord::new(0, 0x4D))) }); let text = serde_json::to_string(&p).unwrap(); let back = serde_json::from_str::(&text).unwrap().sanitised(); assert_eq!(back, p); @@ -1044,4 +1210,20 @@ mod tests { let back: Gyro = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap(); assert_eq!(back, g); } + #[test] + fn shortcut_durations_default_roundtrip_and_do_not_leak_to_other_profiles() { + let mut p: Profiles = serde_json::from_str(r#"{"list":[{}]}"#).unwrap(); + assert_eq!(p.active().shortcut_hold_ms(32769), 100); + p.apply(Edit::Shortcut { name: None, anchor: 15, input: 32769, keys: Some(Chord::new(0, 121)) }); + p.list[0].shortcut_durations.insert(32769, 250); + assert_eq!(p.active().shortcut_hold_ms(32769), 250); + p.apply(Edit::Add("Other".into())); + assert_eq!(p.active().shortcut_hold_ms(32769), 100); + assert_eq!(serde_json::from_str::(&serde_json::to_string(&p).unwrap()).unwrap(), p); + p.list[0].shortcut_durations.insert(32769, 0); + p.list[0].shortcut_durations.insert(123, 250); + let cleaned = p.sanitised(); + assert_eq!(cleaned.list[0].shortcut_hold_ms(32769), shortcuts::MIN_HOLD_MS); + assert!(!cleaned.list[0].shortcut_durations.contains_key(&123)); + } } diff --git a/crates/open-controller-core/src/sdl.rs b/crates/open-controller-core/src/sdl.rs index 9a18fe3..2a13263 100644 --- a/crates/open-controller-core/src/sdl.rs +++ b/crates/open-controller-core/src/sdl.rs @@ -798,6 +798,17 @@ impl Gamepad { if ok { Ok(()) } else { Err(error()) } } + /// A native game can change the report mode while SDL's handle stays open. Ask the + /// Switch Pro HIDAPI driver for full reports again when emulation takes over. + pub fn restore_switch_input_mode(&self) -> Result<(), String> { + if unsafe { SDL_GetRealGamepadType(self.gp) } != SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO { + return Ok(()); + } + // Switch subcommand 0x03 (Set Input Report Mode), 0x30 (Full Controller State). + let request = [0x03u8, 0x30]; + unsafe { SDL_SendGamepadEffect(self.gp, request.as_ptr().cast(), request.len() as i32) }.then_some(()).ok_or_else(error) + } + /// The accelerometer's last reading in m/s², if it is on. pub fn accel(&self) -> Option<[f32; 3]> { let mut v = [0f32; 3]; diff --git a/crates/open-controller-core/src/shortcut_edit.rs b/crates/open-controller-core/src/shortcut_edit.rs new file mode 100644 index 0000000..69fc919 --- /dev/null +++ b/crates/open-controller-core/src/shortcut_edit.rs @@ -0,0 +1,209 @@ +//! Atomic shortcut edits. Unassigned actions belong to a profile, never to another editor. +use crate::{ + binding::Chord, + button_mode::Mode, + profile::Profile, + shortcuts::{self, ShortcutAction}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Change { + #[serde(default)] + pub hold_until_release: bool, + #[serde(default = "shortcuts::default_hold_ms")] + pub hold_ms: u16, + pub profile: usize, + pub original: Option, + pub unassigned: Option, + pub anchor: u8, + pub input: Option, + pub keys: Chord, + pub name: String, + pub replace: bool, +} + +impl Profile { + pub fn shortcut_hold_ms(&self, input: u64) -> u16 { + shortcuts::hold_ms(self.shortcut_durations.get(&input).copied().unwrap_or(shortcuts::DEFAULT_HOLD_MS)) + } + + pub fn stash_shortcut(&mut self, action: ShortcutAction) -> bool { + if self.unassigned_shortcuts.values().any(|a| a == &action) { + return true; + } + let Some(id) = (0..shortcuts::MAX_UNASSIGNED as u64).find(|id| !self.unassigned_shortcuts.contains_key(id)) else { + return false; + }; + self.unassigned_shortcuts.insert(id, action); + true + } + + pub fn change_shortcut(&mut self, change: Change) -> bool { + let mut edited = self.clone(); + if !edited.try_change_shortcut(change) { + return false; + } + *self = edited; + true + } + + fn try_change_shortcut(&mut self, c: Change) -> bool { + if c.anchor >= 62 + || c.keys.is_empty() + || c.keys.mods & !15 != 0 + || c.keys.key > 255 + || c.input.is_some_and(|input| !shortcuts::valid(input, c.keys) || input & (1 << c.anchor) == 0) + || c.original.is_some_and(|mask| !self.shortcuts.contains_key(&mask)) + || c.unassigned.is_some_and(|id| !self.unassigned_shortcuts.contains_key(&id)) + || (c.original.is_some() && c.unassigned.is_some()) + { + return false; + } + if let Some(input) = c.input { + if Some(input) != c.original && self.shortcuts.contains_key(&input) { + if !c.replace { + return false; + } + let displaced = ShortcutAction { + hold_ms: self.shortcut_hold_ms(input), + hold_until_release: self.shortcut_held.contains(&input), + name: self.shortcut_names.get(&input).cloned().unwrap_or_default(), + keys: self.shortcuts[&input], + anchor: self.shortcut_anchor(input), + }; + if !self.stash_shortcut(displaced) { + return false; + } + } + if !self.shortcuts.contains_key(&input) && c.original.is_none() && self.shortcuts.len() >= shortcuts::MAX_SHORTCUTS { + return false; + } + } + if let Some(old) = c.original { + self.shortcuts.remove(&old); + self.shortcut_durations.remove(&old); + self.shortcut_held.remove(&old); + self.shortcut_names.remove(&old); + self.shortcut_anchors.remove(&old); + } + if let Some(id) = c.unassigned { + self.unassigned_shortcuts.remove(&id); + } + let name = shortcuts::clean_name(&c.name); + match c.input { + Some(input) => { + self.shortcuts.insert(input, c.keys); + self.shortcut_held.remove(&input); + if c.hold_until_release { + self.shortcut_held.insert(input); + } + let ms = shortcuts::hold_ms(c.hold_ms); + self.shortcut_durations.remove(&input); + if ms != shortcuts::DEFAULT_HOLD_MS { + self.shortcut_durations.insert(input, ms); + } + self.shortcut_anchors.insert(input, c.anchor); + self.shortcut_names.remove(&input); + if !name.is_empty() { + self.shortcut_names.insert(input, name); + } + } + None => { + if !self.stash_shortcut(ShortcutAction { + name, + keys: c.keys, + anchor: c.anchor, + hold_ms: shortcuts::hold_ms(c.hold_ms), + hold_until_release: c.hold_until_release, + }) { + return false; + } + } + } + self.select_button_mode(c.anchor, Mode::Combinations); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{Edit, Profiles}; + fn change() -> Change { + Change { + hold_until_release: false, + hold_ms: 250, + profile: 0, + original: None, + unassigned: None, + anchor: 15, + input: Some(32769), + keys: Chord::new(3, 5), + name: "OBS pause".into(), + replace: false, + } + } + #[test] + fn replacing_moves_the_displaced_action_to_unassigned_and_is_atomic() { + let mut p = Profile::default(); + assert!(p.change_shortcut(change())); + let old = p.clone(); + let replacement = Change { name: "Recording".into(), keys: Chord::new(3, 6), ..change() }; + assert!(!p.change_shortcut(replacement.clone())); + assert_eq!(p, old); + assert!(p.change_shortcut(Change { replace: true, ..replacement })); + assert_eq!(p.shortcuts[&32769].key, 6); + assert_eq!(p.unassigned_shortcuts[&0].name, "OBS pause"); + } + #[test] + fn unassign_reassign_and_roundtrip_keep_name_and_output() { + let mut p = Profile::default(); + p.change_shortcut(change()); + assert!(p.change_shortcut(Change { original: Some(32769), input: None, ..change() })); + assert!(p.shortcuts.is_empty()); + assert_eq!(p.unassigned_shortcuts[&0].keys.key, 5); + assert_eq!(p.unassigned_shortcuts[&0].hold_ms, 250); + let text = serde_json::to_string(&p).unwrap(); + let mut p: Profile = serde_json::from_str(&text).unwrap(); + assert!(p.change_shortcut(Change { unassigned: Some(0), input: Some(32768), ..change() })); + assert!(p.unassigned_shortcuts.is_empty()); + assert_eq!(p.shortcut_names[&32768], "OBS pause"); + assert_eq!(p.shortcut_hold_ms(32768), 250); + } + #[test] + fn new_profiles_are_empty_and_stale_editor_changes_cannot_cross_profiles() { + let mut p = Profiles::default(); + p.apply(Edit::ChangeShortcut(change())); + let first = p.list[0].clone(); + p.apply(Edit::Add("New".into())); + assert!(p.active().shortcuts.is_empty()); + p.apply(Edit::ChangeShortcut(change())); + assert!(p.active().shortcuts.is_empty()); + assert_eq!(p.list[0], first); + p.apply(Edit::ChangeShortcut(Change { profile: 1, ..change() })); + assert_eq!(p.active().shortcuts.len(), 1); + assert_eq!(p.list[0], first); + } + #[test] + fn held_mode_and_remembered_duration_survive_unassignment_and_reassignment() { + let mut p = Profile::default(); + p.change_shortcut(Change { hold_until_release: true, hold_ms: 60_000, ..change() }); + assert!(p.shortcut_held.contains(&32769)); + assert_eq!(p.shortcut_hold_ms(32769), 60_000); + p.change_shortcut(Change { hold_until_release: true, hold_ms: 60_000, original: Some(32769), input: None, ..change() }); + assert!(p.unassigned_shortcuts[&0].hold_until_release); + let restored: Profile = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); + p = restored; + p.change_shortcut(Change { + hold_until_release: true, + hold_ms: p.unassigned_shortcuts[&0].hold_ms, + unassigned: Some(0), + ..change() + }); + assert!(p.shortcut_held.contains(&32769)); + p.change_shortcut(Change { original: Some(32769), hold_until_release: false, hold_ms: 60_000, ..change() }); + assert!(!p.shortcut_held.contains(&32769)); + assert_eq!(p.shortcut_hold_ms(32769), 60_000); + } +} diff --git a/crates/open-controller-core/src/shortcuts.rs b/crates/open-controller-core/src/shortcuts.rs new file mode 100644 index 0000000..b8d1bb6 --- /dev/null +++ b/crates/open-controller-core/src/shortcuts.rs @@ -0,0 +1,339 @@ +//! Incoming controller chords, distinct from the Xbox buttons an assignment outputs. +//! Triggers use the two high bits; real SDL/extra buttons occupy bits 0..=40. +use crate::binding::{self, Action, Bindings, Chord}; +use crate::mapping::{PadState, axis}; +use std::collections::BTreeMap; + +pub type Shortcuts = BTreeMap; +pub const LT: u64 = 1 << 62; +pub const RT: u64 = 1 << 63; +pub const TRIGGERS: u64 = LT | RT; +pub const MAX_SHORTCUTS: usize = 32; +pub const MAX_NAME: usize = 64; +pub const MAX_UNASSIGNED: usize = 256; +pub const DEFAULT_HOLD_MS: u16 = 100; +pub const MIN_HOLD_MS: u16 = 10; +pub const MAX_HOLD_MS: u16 = 60_000; +pub fn default_hold_ms() -> u16 { + DEFAULT_HOLD_MS +} +pub fn hold_ms(ms: u16) -> u16 { + ms.clamp(MIN_HOLD_MS, MAX_HOLD_MS) +} +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ShortcutAction { + #[serde(default)] + pub hold_until_release: bool, + #[serde(default = "default_hold_ms")] + pub hold_ms: u16, + pub name: String, + pub keys: Chord, + pub anchor: u8, +} + +/// An optional human-readable name, kept independently from input and output bindings. +pub fn clean_name(name: &str) -> String { + name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() +} + +pub fn input(s: &PadState) -> u64 { + (s.buttons & !TRIGGERS) + | if s.axes[axis::LEFT_TRIGGER] > 16383 { LT } else { 0 } + | if s.axes[axis::RIGHT_TRIGGER] > 16383 { RT } else { 0 } +} + +pub fn valid(mask: u64, keys: Chord) -> bool { + mask != 0 && !keys.is_empty() && keys.mods & !15 == 0 && keys.key <= 255 +} + +/// Extra buttons used as chord modifiers act on release when tapped alone. Normal buttons +/// remain immediate when played alone, and are consumed only after a complete chord matches. +pub fn deferred<'a>(shortcuts: impl Iterator) -> u64 { + let normal = binding::NORMAL.iter().fold(0, |mask, &b| mask | 1 << b); + let entries: Vec = shortcuts.map(|(&b, _)| b).collect(); + let singles = entries.iter().filter(|b| b.count_ones() == 1).fold(0, |m, b| m | b); + (entries.iter().fold(0, |m, b| m | b) & !normal & !TRIGGERS) | singles +} + +pub fn suppress(s: &mut PadState, mask: u64) { + s.buttons &= !mask; + for (bit, axis) in [(LT, axis::LEFT_TRIGGER), (RT, axis::RIGHT_TRIGGER)] { + if mask & bit != 0 { + s.axes[axis] = 0; + } + } +} + +#[derive(Default)] +pub struct State { + /// Consumed components stay consumed until each is released: releasing Capture first + /// must not leak the still-held A into a game or run A's keyboard assignment. + pub used: u64, + pending: u64, + held_outputs: BTreeMap, +} + +pub struct Transition { + pub presses: Vec, + pub releases: Vec, + pub before: u64, + pub after: u64, + pub fire: Option, + pub fire_input: Option, + pub taps: Vec, + pub shortcut_taps: Vec<(u64, Chord)>, +} + +impl State { + pub fn update_modes<'a>( + &mut self, + shortcuts: impl Iterator + Clone, + bindings: &Bindings, + held_inputs: &std::collections::BTreeSet, + before: u64, + after: u64, + ) -> Transition { + let delay = deferred(shortcuts.clone()); + let visible_before = before & !self.used & !delay & !TRIGGERS; + let released = self.pending & !after & !self.used; + let shortcut_taps = shortcuts + .clone() + .filter(|(mask, _)| mask.count_ones() == 1 && !held_inputs.contains(mask) && released & **mask != 0) + .map(|(&mask, &c)| (mask, c)) + .collect(); + let taps = bindings + .iter() + .filter(|(b, _)| released & (1u64 << **b) != 0 && !shortcuts.clone().any(|(&m, _)| m == 1u64 << **b)) + .map(|(_, a)| a.clone()) + .collect(); + self.used &= after; + self.pending = (self.pending & after) | (after & !before & delay); + // Longest chord wins if several match the same report; stable order breaks ties. + let matched = shortcuts + .clone() + .filter(|(mask, _)| { + after & **mask == **mask + && (mask.count_ones() > 1 + || held_inputs.contains(mask) && (before & **mask != **mask || self.held_outputs.contains_key(mask))) + }) + .max_by_key(|(mask, _)| (mask.count_ones(), std::cmp::Reverse(**mask))); + let mut releases = Vec::new(); + self.held_outputs.retain(|mask, chord| { + let superseded = matched.is_some_and(|(&other, _)| other != *mask && other & *mask == *mask); + let keep = after & *mask == *mask + && held_inputs.contains(mask) + && shortcuts.clone().any(|(&m, &c)| m == *mask && c == *chord) + && !superseded; + if !keep { + releases.push(*chord); + } + keep + }); + let mut presses = Vec::new(); + let fire = matched.and_then(|(&mask, &chord)| { + self.used |= mask; + self.pending &= !mask; + // No repeat while held, nor a shorter chord firing when a longer one is released. + let starts = before & mask != mask && after & !before & mask != 0; + if held_inputs.contains(&mask) { + if starts && !self.held_outputs.contains_key(&mask) { + self.held_outputs.insert(mask, chord); + presses.push(chord); + } + None + } else { + starts.then_some(chord) + } + }); + let fire_input = matched.filter(|_| fire.is_some()).map(|(&mask, _)| mask); + Transition { + presses, + releases, + fire_input, + before: visible_before, + after: after & !self.used & !delay & !TRIGGERS, + fire, + taps, + shortcut_taps, + } + } + pub fn release_all(&mut self) -> Vec { + std::mem::take(&mut self.held_outputs).into_values().collect() + } + + #[cfg(test)] + pub fn update<'a>( + &mut self, + shortcuts: impl Iterator + Clone, + bindings: &Bindings, + before: u64, + after: u64, + ) -> Transition { + self.update_modes(shortcuts, bindings, &Default::default(), before, after) + } +} + +/// A recorder keeps buttons that were held together, not a sequence of unrelated taps. +/// Buttons already down when it opens must first be released. +#[derive(Default)] +pub struct Recorder { + ready: bool, + peak: u64, + pub shown: u64, +} + +impl Recorder { + pub fn new(held: u64) -> Self { + Self { ready: held == 0, ..Self::default() } + } + pub fn feed(&mut self, held: u64) -> Option { + if !self.ready { + self.ready = held == 0; + return None; + } + if held != 0 { + self.shown = held; + if held.count_ones() >= self.peak.count_ones() { + self.peak = held; + } + None + } else if self.peak != 0 { + let result = std::mem::take(&mut self.peak); + self.shown = result; + Some(result) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + const CAPTURE: u64 = 1 << 15; + const A: u64 = 1; + const B: u64 = 2; + const KEY: Chord = Chord::new(5, 121); + fn setup() -> (Shortcuts, Bindings, State) { + ([(CAPTURE | A, KEY)].into(), [(15, Action::Keys(Chord::new(0, 121)))].into(), State::default()) + } + #[test] + fn chord_fires_once_and_consumes_components_until_both_released() { + let (s, b, mut r) = setup(); + let out = r.update(s.iter(), &b, 0, CAPTURE | A); + assert_eq!(out.fire, Some(KEY)); + assert_eq!(out.after, 0); + assert!(out.taps.is_empty()); + assert_eq!(r.update(s.iter(), &b, CAPTURE | A, CAPTURE | A).fire, None); + let out = r.update(s.iter(), &b, CAPTURE | A, A); + assert_eq!(out.after, 0); + assert_eq!(out.fire, None); + assert!(out.taps.is_empty()); + assert!(r.update(s.iter(), &b, A, 0).taps.is_empty()); + assert_eq!(r.used, 0); + assert_eq!(r.update(s.iter(), &b, 0, A).after, A); + } + #[test] + fn capture_alone_taps_its_original_action_on_release() { + let (s, b, mut r) = setup(); + assert_eq!(r.update(s.iter(), &b, 0, CAPTURE).after, 0); + assert_eq!(r.update(s.iter(), &b, CAPTURE, 0).taps, vec![b[&15].clone()]); + } + #[test] + fn sequential_modifier_does_not_run_capture_and_can_repeat_with_a_held_modifier() { + let (s, b, mut r) = setup(); + r.update(s.iter(), &b, 0, CAPTURE); + assert_eq!(r.update(s.iter(), &b, CAPTURE, CAPTURE | A).fire, Some(KEY)); + r.update(s.iter(), &b, CAPTURE | A, CAPTURE); + assert_eq!(r.update(s.iter(), &b, CAPTURE, CAPTURE | A).fire, Some(KEY)); + assert!(r.update(s.iter(), &b, CAPTURE | A, 0).taps.is_empty()); + } + #[test] + fn longest_chord_wins_and_release_does_not_trigger_the_shorter_one() { + let (mut s, b, mut r) = setup(); + let other = Chord::new(0, 122); + s.insert(CAPTURE | A | B, other); + assert_eq!(r.update(s.iter(), &b, 0, CAPTURE | A | B).fire, Some(other)); + assert_eq!(r.update(s.iter(), &b, CAPTURE | A | B, CAPTURE | A).fire, None); + } + #[test] + fn triggers_participate_in_chords_and_are_suppressed_from_the_game() { + let mut p = PadState { buttons: CAPTURE, ..Default::default() }; + p.axes[axis::LEFT_TRIGGER] = 20000; + assert_eq!(input(&p), CAPTURE | LT); + let s: Shortcuts = [(CAPTURE | LT, KEY)].into(); + let mut r = State::default(); + assert_eq!(r.update(s.iter(), &Bindings::new(), 0, input(&p)).fire, Some(KEY)); + suppress(&mut p, r.used); + assert_eq!(p.buttons, 0); + assert_eq!(p.axes[axis::LEFT_TRIGGER], 0); + } + #[test] + fn record_release_and_reject_preheld_and_sequential_taps() { + let mut r = Recorder::new(CAPTURE); + assert_eq!(r.feed(CAPTURE | A), None); + assert_eq!(r.feed(0), None); + assert_eq!(r.feed(CAPTURE), None); + assert_eq!(r.feed(CAPTURE | A), None); + assert_eq!(r.feed(CAPTURE), None); + assert_eq!(r.feed(0), Some(CAPTURE | A)); + assert_eq!(r.feed(A), None); + assert_eq!(r.feed(0), Some(A)); + assert_eq!(r.feed(B), None); + assert_eq!(r.feed(0), Some(B)); + } + #[test] + fn single_capture_fires_on_release_and_never_after_a_completed_combination() { + let (mut shortcuts, bindings, mut state) = setup(); + let single = Chord::new(0, 122); + shortcuts.insert(CAPTURE, single); + assert_eq!(state.update(shortcuts.iter(), &bindings, 0, CAPTURE).fire, None); + let out = state.update(shortcuts.iter(), &bindings, CAPTURE, 0); + assert_eq!(out.shortcut_taps, [(CAPTURE, single)]); + state.update(shortcuts.iter(), &bindings, 0, CAPTURE); + let out = state.update(shortcuts.iter(), &bindings, CAPTURE, CAPTURE | A); + assert_eq!(out.fire, Some(KEY)); + assert!(out.taps.is_empty()); + assert!(state.update(shortcuts.iter(), &bindings, CAPTURE | A, A).shortcut_taps.is_empty()); + assert!(state.update(shortcuts.iter(), &bindings, A, 0).shortcut_taps.is_empty()); + assert!(state.update(shortcuts.iter(), &bindings, 0, CAPTURE).taps.is_empty()); + assert_eq!(state.update(shortcuts.iter(), &bindings, CAPTURE, 0).shortcut_taps, [(CAPTURE, single)]); + } + #[test] + fn held_chord_presses_once_and_releases_when_any_component_breaks() { + let (s, b, mut state) = setup(); + let held = [CAPTURE | A].into(); + let out = state.update_modes(s.iter(), &b, &held, 0, CAPTURE | A); + assert_eq!(out.presses, [KEY]); + assert!(out.fire.is_none()); + let out = state.update_modes(s.iter(), &b, &held, CAPTURE | A, CAPTURE | A); + assert!(out.presses.is_empty() && out.releases.is_empty()); + let out = state.update_modes(s.iter(), &b, &held, CAPTURE | A, A); + assert_eq!(out.releases, [KEY]); + assert_eq!(out.after, 0); + assert!(state.update_modes(s.iter(), &b, &held, A, 0).releases.is_empty()); + } + #[test] + fn held_single_starts_immediately_and_a_larger_chord_replaces_it_without_restart() { + let single = Chord::new(4, 9); + let (mut s, b, mut state) = setup(); + s.insert(CAPTURE, single); + let held = [CAPTURE, CAPTURE | A].into(); + assert_eq!(state.update_modes(s.iter(), &b, &held, 0, CAPTURE).presses, [single]); + let out = state.update_modes(s.iter(), &b, &held, CAPTURE, CAPTURE | A); + assert_eq!(out.releases, [single]); + assert_eq!(out.presses, [KEY]); + let out = state.update_modes(s.iter(), &b, &held, CAPTURE | A, CAPTURE); + assert_eq!(out.releases, [KEY]); + assert!(out.presses.is_empty()); + assert!(state.update_modes(s.iter(), &b, &held, CAPTURE, 0).shortcut_taps.is_empty()); + } + #[test] + fn held_outputs_can_be_drained_on_profile_change_or_disconnect() { + let (s, b, mut state) = setup(); + let held = [CAPTURE | A].into(); + state.update_modes(s.iter(), &b, &held, 0, CAPTURE | A); + assert_eq!(state.release_all(), [KEY]); + assert!(state.release_all().is_empty()); + } +} diff --git a/crates/open-controller-ui/Cargo.toml b/crates/open-controller-ui/Cargo.toml index 314f860..1e2289d 100644 --- a/crates/open-controller-ui/Cargo.toml +++ b/crates/open-controller-ui/Cargo.toml @@ -23,7 +23,7 @@ serde_json.workspace = true unicode-segmentation = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] } [build-dependencies] embed-resource = "3" diff --git a/crates/open-controller-ui/src/combination_help.rs b/crates/open-controller-ui/src/combination_help.rs new file mode 100644 index 0000000..0ee944d --- /dev/null +++ b/crates/open-controller-ui/src/combination_help.rs @@ -0,0 +1,78 @@ +//! Help for the combination-mode editor. +use crate::{ + ui::MainView, + widgets::{Kind, button, caption, strong}, +}; +use gpui::{AnyElement, Context, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled, div, px}; +impl MainView { + pub fn open_combination_help(&mut self, window: &mut gpui::Window, cx: &mut Context) { + self.stop_press_meter(cx); + self.stop_shortcut_recording(cx); + self.stop_shortcut_key_capture(cx); + if let Some(e) = self.editor.as_mut() { + e.recording = false; + } + self.combination_help = true; + self.focus.focus(window, cx); + cx.notify(); + } + pub fn render_combination_help(&self, cx: &mut Context) -> Option { + if !self.combination_help { + return None; + } + let t = self.theme; + let text = &self.model.read(cx).text.combinations; + let content = div() + .flex() + .flex_col() + .gap(px(16.)) + .child(div().flex().items_center().gap(px(12.)).child(strong(text.help_title, t.text).flex_1()).child( + button("help-close-top", "×", Kind::Subtle, &t).on_click(cx.listener(|this, _, _, cx| { + this.combination_help = false; + cx.notify(); + })), + )) + .children( + text.help_sections + .iter() + .map(|(title, body)| div().flex().flex_col().gap(px(6.)).child(strong(*title, t.text)).child(caption(*body, t.text2))), + ) + .child(button("help-close", text.help_close, Kind::Primary, &t).on_click(cx.listener(|this, _, _, cx| { + this.combination_help = false; + cx.notify(); + }))); + Some( + div() + .id("combination-help-scrim") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p(px(24.)) + .bg(gpui::black().opacity(0.55)) + .occlude() + .on_click(cx.listener(|this, _, _, cx| { + this.combination_help = false; + cx.notify(); + })) + .child( + div() + .id("combination-help-dialog") + .w_full() + .max_w(px(660.)) + .max_h_full() + .overflow_y_scroll() + .p(px(24.)) + .rounded(px(16.)) + .bg(t.layer) + .border_1() + .border_color(t.stroke_strong) + .shadow_lg() + .on_click(|_, _, cx| cx.stop_propagation()) + .child(content), + ) + .into_any_element(), + ) + } +} diff --git a/crates/open-controller-ui/src/configuration.rs b/crates/open-controller-ui/src/configuration.rs new file mode 100644 index 0000000..54807b7 --- /dev/null +++ b/crates/open-controller-ui/src/configuration.rs @@ -0,0 +1,264 @@ +//! One portable file: controller profiles plus a reusable library of named outputs. +use crate::{ + theme::Theme, + ui::MainView, + widgets::{Kind, button, caption, card, strong}, +}; +use gpui::{Context, Div, ParentElement, PathPromptOptions, StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px}; +use open_controller_core::{ + PadKey, PadView, + i18n::fill, + ipc::ToTray, + portable::{self, ControllerFile}, +}; +use std::path::PathBuf; + +#[derive(Default)] +pub struct ConfigUi { + pub busy: bool, + pub status: Option<(bool, String)>, + pub pending: Option<(PadKey, usize, u8, ControllerFile)>, + pub directory: Option, +} + +impl MainView { + fn config_error(&mut self, error: impl ToString, cx: &mut Context) { + self.config.busy = false; + self.config.status = Some((false, fill(self.model.read(cx).text.configuration.failed, error.to_string()))); + cx.notify(); + } + + fn pause_assignment(&mut self, cx: &mut Context) { + self.stop_shortcut_recording(cx); + self.stop_shortcut_key_capture(cx); + self.mouse_picker = None; + if let Some(e) = self.editor.as_mut() { + e.recording = false; + } + } + + fn save_configuration(&mut self, key: PadKey, cx: &mut Context) { + if self.config.busy { + return; + } + let Some(pad) = self.pad(key, cx).filter(|p| p.store.is_some()) else { + return; + }; + let file = match ControllerFile::new(&pad, Default::default()) { + Ok(file) => file, + Err(e) => { + self.config_error(e, cx); + return; + } + }; + self.pause_assignment(cx); + self.config.busy = true; + self.config.pending = None; + self.config.status = None; + let directory = self.config.directory.clone().or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)).unwrap_or_default(); + let name: String = pad.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); + let dialog = cx.prompt_for_new_path(&directory, Some(&format!("OpenController-{name}.json"))); + cx.spawn(async move |this, cx| { + let path = match dialog.await { + Ok(Ok(Some(path))) => path, + Ok(Ok(None)) => { + let _ = this.update(cx, |this, cx| { + this.config.busy = false; + cx.notify(); + }); + return; + } + other => { + let _ = this.update(cx, |this, cx| this.config_error(format!("{other:?}"), cx)); + return; + } + }; + let target = path.clone(); + let actions = file.actions.clone(); + let result = cx.background_executor().spawn(async move { portable::write(&target, &file) }).await; + let _ = this.update(cx, |this, cx| { + this.config.busy = false; + match result { + Ok(()) => { + this.config.directory = path.parent().map(|p| p.to_owned()); + this.config.status = Some((true, fill(this.model.read(cx).text.configuration.saved, path.display()))); + this.send(ToTray::ApplyConfiguration { store: None, profiles: None, actions }, cx); + } + Err(e) => this.config_error(e, cx), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn load_configuration(&mut self, key: PadKey, cx: &mut Context) { + if self.config.busy { + return; + } + self.pause_assignment(cx); + self.config.busy = true; + self.config.pending = None; + self.config.status = None; + let Some(pad) = self.pad(key, cx) else { + self.config.busy = false; + return; + }; + let profile = pad.profiles.active; + let anchor = self.editor.as_ref().map_or(15, |e| e.button); + let dialog = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(self.model.read(cx).text.configuration.load_file.into()), + }); + cx.spawn(async move |this, cx| { + let path = match dialog.await { + Ok(Ok(Some(paths))) if !paths.is_empty() => paths[0].clone(), + Ok(Ok(_)) => { + let _ = this.update(cx, |this, cx| { + this.config.busy = false; + cx.notify(); + }); + return; + } + other => { + let _ = this.update(cx, |this, cx| this.config_error(format!("{other:?}"), cx)); + return; + } + }; + let target = path.clone(); + let result = cx + .background_executor() + .spawn(async move { portable::read::(&target).and_then(ControllerFile::validate) }) + .await; + let _ = this.update(cx, |this, cx| { + this.config.busy = false; + match result { + Ok(file) => { + this.config.directory = path.parent().map(|p| p.to_owned()); + if this.pad(key, cx).is_some() { + this.config.pending = Some((key, profile, anchor, file)); + } + } + Err(e) => this.config_error(e, cx), + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn apply_configuration(&mut self, key: PadKey, full: bool, cx: &mut Context) { + let Some((target, profile, anchor, file)) = self.config.pending.as_ref() else { + return; + }; + if *target != key { + return; + } + let Some(pad) = self.pad(key, cx).filter(|p| p.store.is_some()) else { + return; + }; + if *profile != pad.profiles.active { + self.config.pending = None; + cx.notify(); + return; + } + let profiles = + match if full { file.profiles_for(&(&pad).into(), true) } else { file.global_profiles(&pad.profiles, *anchor).map(Some) } { + Ok(profiles) => profiles.map(Box::new), + Err(e) => { + self.config_error(e, cx); + return; + } + }; + let actions = match self.model.read(cx).action_library.clone().merge(file.actions.clone()) { + Ok(actions) => actions, + Err(e) => { + self.config_error(e, cx); + return; + } + }; + let store = pad.store.clone(); + self.close_shortcut(cx); + self.send(ToTray::ApplyConfiguration { store, profiles, actions }, cx); + self.config.pending = None; + let t = &self.model.read(cx).text.configuration; + self.config.status = Some((true, if full { t.loaded } else { t.global_loaded }.into())); + cx.notify(); + } + + pub fn configuration_panel(&self, pad: &PadView, t: &Theme, cx: &mut Context) -> Div { + let text = &self.model.read(cx).text.configuration; + let key = pad.key; + let mut panel = div() + .flex() + .flex_col() + .gap(px(10.)) + .mt(px(18.)) + .child( + div() + .flex() + .flex_wrap() + .gap(px(8.)) + .child( + button("config-save", text.save_file, Kind::Standard, t) + .on_click(cx.listener(move |this, _, _, cx| this.save_configuration(key, cx))), + ) + .child( + button("config-load", text.load_file, Kind::Standard, t) + .on_click(cx.listener(move |this, _, _, cx| this.load_configuration(key, cx))), + ), + ) + .child(caption(if self.config.busy { text.busy } else { text.file_hint }, t.text2)); + if let Some((ok, message)) = &self.config.status { + panel = panel.child(caption(message.clone(), if *ok { t.text2 } else { t.critical })); + } + if let Some((target, profile, _, file)) = &self.config.pending + && *target == key + && *profile == pad.profiles.active + { + let matches = file.controller.compatible(&pad.into()); + panel = panel.child( + card(t) + .p(px(16.)) + .flex() + .flex_col() + .gap(px(10.)) + .child(strong(if matches { text.match_title } else { text.mismatch_title }, if matches { t.text } else { t.critical })) + .child(caption( + fill( + text.source, + format!("{} ({:04X}:{:04X})", file.controller.name, file.controller.vendor, file.controller.product), + ), + t.text2, + )) + .child(caption(fill(text.current, format!("{} ({:04X}:{:04X})", pad.name, pad.vendor, pad.product)), t.text2)) + .child(caption(if matches { text.match_hint } else { text.mismatch_hint }, t.text2)) + .child( + div() + .flex() + .flex_wrap() + .gap(px(8.)) + .when(matches, |d| { + d.child( + button("config-full", text.full, Kind::Primary, t) + .on_click(cx.listener(move |this, _, _, cx| this.apply_configuration(key, true, cx))), + ) + }) + .child( + button("config-global", text.global, if matches { Kind::Standard } else { Kind::Primary }, t) + .on_click(cx.listener(move |this, _, _, cx| this.apply_configuration(key, false, cx))), + ) + .child(button("config-cancel", text.cancel, Kind::Subtle, t).on_click(cx.listener(|this, _, _, cx| { + this.config.pending = None; + cx.notify(); + }))), + ), + ); + } + panel + } +} diff --git a/crates/open-controller-ui/src/demo.rs b/crates/open-controller-ui/src/demo.rs index 7c0f37b..62ed0a1 100644 --- a/crates/open-controller-ui/src/demo.rs +++ b/crates/open-controller-ui/src/demo.rs @@ -50,17 +50,10 @@ pub(crate) fn pad(key: PadKey, name: &str, brand: Brand, family: Family, (vendor fn eightbitdo_profiles() -> Profiles { let mut p = Profiles::default(); p.apply(Edit::Bind { button: extras::LEFT_PADDLE2, action: Some(Action::Xbox(XboxButton::A)) }); - p.apply(Edit::Bind { - button: extras::RIGHT_PADDLE2, - action: Some(Action::Keys(Chord { mods: modifier::CTRL | modifier::SHIFT, key: 0x4D })), - }); + p.apply(Edit::Bind { button: extras::RIGHT_PADDLE2, action: Some(Action::Keys(Chord::new(modifier::CTRL | modifier::SHIFT, 0x4D))) }); p.apply(Edit::Bind { button: extras::RIGHT_PADDLE1, - action: Some(Action::Macro(vec![ - Step::Keys(Chord { mods: 0, key: 0x31 }), - Step::Wait(80), - Step::Keys(Chord { mods: 0, key: 0x52 }), - ])), + action: Some(Action::Macro(vec![Step::Keys(Chord::new(0, 0x31)), Step::Wait(80), Step::Keys(Chord::new(0, 0x52))])), }); p.apply(Edit::Add("Racing".into())); p.apply(Edit::Select(0)); @@ -106,7 +99,7 @@ pub fn snapshot(real: &Snapshot, edits: &HashMap, swaps: &[(Pa Step::Xbox(Buttons::of(&[XboxButton::Right])), Step::Wait(40), Step::Xbox(Buttons::of(&[XboxButton::X, XboxButton::RightBumper])), - Step::Keys(Chord { mods: 0, key: 0x46 }), + Step::Keys(Chord::new(0, 0x46)), ])), }); @@ -117,7 +110,7 @@ pub fn snapshot(real: &Snapshot, edits: &HashMap, swaps: &[(Pa switch.extras = vec![extras::MISC1]; switch.features = Features { motion: true, rumble: true, player_lights: true, ..Features::default() }; switch.profiles.apply(Edit::Letters(true)); - switch.profiles.apply(Edit::Bind { button: button::BACK as u8, action: Some(Action::Keys(Chord { mods: 0, key: 0x4D })) }); + switch.profiles.apply(Edit::Bind { button: button::BACK as u8, action: Some(Action::Keys(Chord::new(0, 0x4D))) }); let mut joycons = pad(PadKey::Slot(5), "Joy-Con (L/R)", Brand::Nintendo, Family::JoyCons, (0x057E, 0x2008)); joycons.links = vec![Link::Bluetooth]; @@ -208,6 +201,7 @@ pub fn snapshot(real: &Snapshot, edits: &HashMap, swaps: &[(Pa _ => 100, }); Snapshot { + press_measurement: None, pads, vigem: Driver::Ready { version: Some("1.22.0".into()) }, hidhide: Driver::Ready { version: None }, diff --git a/crates/open-controller-ui/src/detail.rs b/crates/open-controller-ui/src/detail.rs index 473d8b5..ce82ceb 100644 --- a/crates/open-controller-ui/src/detail.rs +++ b/crates/open-controller-ui/src/detail.rs @@ -19,6 +19,7 @@ use gpui::{ StatefulInteractiveElement, Styled, Subscription, Window, div, px, rgb, }; use open_controller_core::binding::{self, Action, Buttons, Chord, Step, XboxButton}; +use open_controller_core::button_mode::Mode; use open_controller_core::device::{Form, Power}; use open_controller_core::extras::{self, Art, Family, HANDHELD, Hint}; use open_controller_core::handheld; @@ -122,7 +123,7 @@ fn sideways(pad: &PadView) -> bool { } /// What is printed on one of `binding::NORMAL`, on this controller. -fn normal_label(pad: &PadView, b: u8) -> &'static str { +pub(crate) fn normal_label(pad: &PadView, b: u8) -> &'static str { const XBOX: [&str; 15] = ["A", "B", "X", "Y", "LB", "RB", "View", "Menu", "Xbox", "LS", "RS", "↑", "↓", "←", "→"]; const NINTENDO: [&str; 15] = ["B", "A", "Y", "X", "L", "R", "−", "+", "⌂", "LS", "RS", "↑", "↓", "←", "→"]; const DUALSHOCK3: [&str; 15] = ["✕", "○", "□", "△", "L1", "R1", "Select", "Start", "PS", "L3", "R3", "↑", "↓", "←", "→"]; @@ -222,6 +223,14 @@ impl MainView { } pub(crate) fn edit_now(&mut self, key: PadKey, edit: Edit, cx: &mut Context) { + if matches!(&edit, Edit::Select(_) | Edit::Add(_) | Edit::Delete(_)) { + self.stop_press_meter(cx); + self.press_meter = None; + self.close_shortcut(cx); + self.editor = None; + self.mouse_picker = None; + self.config.pending = None; + } if let Some(pad) = self.pad(key, cx) { self.edit_profiles(&pad, edit, cx); } @@ -690,9 +699,12 @@ impl MainView { .child(self.extras_column(pad, text, &t, 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| { - d.child(self.restore_button(pad.key, text, &t, cx)) - }), + .when( + pad.store.is_some() + && !kept_native(pad) + && (!pad.profiles.active().bindings.is_empty() || !pad.profiles.active().shortcuts.is_empty()), + |d| d.child(self.restore_button(pad.key, text, &t, cx)), + ), ), ) .into_any_element() @@ -740,7 +752,11 @@ impl MainView { .when(printed.is_some(), |d| d.child(caption(place.clone(), t.text2))) .when(printed.is_none(), |d| d.child(strong(place.clone(), t.text))), ) - .child(summary(b, pad.profiles.active().bindings.get(&b), text, &t)) + .child(if pad.profiles.active().button_mode(b) == Mode::Combinations { + caption(text.combinations.tab, t.accent) + } else { + summary(b, pad.profiles.active().bindings.get(&b), text, &t) + }) .child(icon(if selected { glyph::CHEVRON_DOWN } else { glyph::CHEVRON_RIGHT }, 12., t.text2)); let mut g = group().child(row); if selected { @@ -1172,19 +1188,7 @@ impl MainView { .cursor_pointer() .when(on, |d| d.bg(t.layer).border_1().border_color(t.stroke_strong)) .child(body(label, if on { t.text } else { t.text2 }).when(on, |d| d.font_weight(gpui::FontWeight::SEMIBOLD))) - .on_click(cx.listener(move |this, _, window, cx| { - let Some(e) = this.editor.as_mut() else { return }; - e.tab = which; - e.recording = which == Tab::Key; - e.other_keys = false; - if which == Tab::Key { - this.focus.focus(window, cx); - } - if which == Tab::Nothing { - this.bind_now(key, b, None, cx); - } - cx.notify(); - })) + .on_click(cx.listener(move |this, _, _, cx| this.select_button_tab(key, b, which, cx))) }; let tabs = div() .flex() @@ -1196,6 +1200,7 @@ impl MainView { .when(!keys_only, |d| d.child(tab("tab-xbox", text.tab_xbox, Tab::Xbox, cx))) .child(tab("tab-key", text.tab_key, Tab::Key, cx)) .child(tab("tab-macro", text.tab_macro, Tab::Macro, cx)) + .when(remappable(pad), |d| d.child(tab("tab-combinations", text.combinations.tab, Tab::Combinations, cx))) .child(tab("tab-nothing", if normal { text.tab_default } else { text.tab_nothing }, Tab::Nothing, cx)); let content: AnyElement = match e.tab { @@ -1257,7 +1262,7 @@ impl MainView { } Tab::Key => { let shown = match (¤t, e.recording) { - (_, true) if e.holding != 0 => keycaps(Chord { mods: e.holding, key: 0 }, t), + (_, true) if e.holding != 0 => keycaps(Chord::new(e.holding, 0), t), (_, true) => caption(text.listening, t.accent), (Some(Action::Keys(c)), false) => keycaps(*c, t), _ => caption(text.press_key, t.text3), @@ -1289,7 +1294,7 @@ impl MainView { let mut list = div().flex().flex_wrap().gap(px(6.)); if other { for (i, vk) in keys::OTHER.into_iter().enumerate() { - let c = Chord { mods: 0, key: vk }; + let c = Chord::new(0, vk); list = list.child(div().id(("other", i)).cursor_pointer().child(keycap(keyboard::key_name(vk), t)).on_click( cx.listener(move |this, _, _, cx| { if let Some(e) = this.editor.as_mut() { @@ -1306,6 +1311,14 @@ impl MainView { .gap(px(10.)) .child(caption(text.key_desc, t.text2)) .child(field) + .when(cfg!(windows), |d| { + d.child(button("key-mouse", text.configuration.mouse, Kind::Standard, t).on_click(cx.listener( + move |this, _, _, cx| { + this.open_mouse_picker(crate::mouse_picker::Target::Button(key, b), cx); + }, + ))) + }) + .children(self.render_mouse_picker(crate::mouse_picker::Target::Button(key, b), t, cx)) .child(div().flex().child( button("other-keys", text.other_keys, Kind::Subtle, t).px(px(0.)).text_color(t.accent).on_click(cx.listener( |this, _, _, cx| { @@ -1319,6 +1332,7 @@ impl MainView { .when(other, |d| d.child(list)) .into_any_element() } + Tab::Combinations => self.shortcut_panel(pad, b, t, cx).into_any_element(), Tab::Macro => self.macro_editor(key, b, remappable(pad), text, t, cx), Tab::Nothing => caption(if normal { text.default_desc } else { text.nothing_desc }, t.text2).into_any_element(), }; diff --git a/crates/open-controller-ui/src/key_capture.rs b/crates/open-controller-ui/src/key_capture.rs new file mode 100644 index 0000000..46c5187 --- /dev/null +++ b/crates/open-controller-ui/src/key_capture.rs @@ -0,0 +1,402 @@ +//! Temporary exclusive keyboard capture for the explicit shortcut picker on Windows. +//! No keys are replayed or logged. The hook exists only for the picker and drains the keys it +//! consumed before ending, so a captured Ctrl/Alt release cannot reach another application. +use open_controller_core::binding::{Chord, modifier}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Event { + Holding(u8), + Chosen(Chord), + Cancelled, +} + +struct Outcome { + consume: bool, + event: Option, +} + +struct Recorder { + inherited: [bool; 256], + held: [bool; 256], + chosen: Option, + held_order: Vec, + cancelled: bool, + finished: bool, +} + +fn modifier_key(key: u8) -> bool { + matches!(key, 0x10..=0x12 | 0x5B | 0x5C | 0xA0..=0xA5) +} + +impl Recorder { + fn new(inherited: impl IntoIterator) -> Self { + let mut r = + Self { inherited: [false; 256], held: [false; 256], chosen: None, held_order: Vec::new(), cancelled: false, finished: false }; + for key in inherited { + r.inherited[key as usize] = true; + } + r + } + + fn mods(&self) -> u8 { + let any = |keys: &[usize]| keys.iter().any(|&k| self.held[k]); + (if any(&[0x10, 0xA0, 0xA1]) { modifier::SHIFT } else { 0 }) + | (if any(&[0x11, 0xA2, 0xA3]) { modifier::CTRL } else { 0 }) + | (if any(&[0x12, 0xA4, 0xA5]) { modifier::ALT } else { 0 }) + | (if any(&[0x5B, 0x5C]) { modifier::WIN } else { 0 }) + } + + fn cancel(&mut self) -> Option { + self.cancelled = true; + self.chosen = None; + self.finish() + } + + fn finish(&mut self) -> Option { + if !self.finished && !self.held.iter().any(|&h| h) && (self.cancelled || self.chosen.is_some()) { + self.finished = true; + Some(self.chosen.map_or(Event::Cancelled, Event::Chosen)) + } else { + None + } + } + + fn input(&mut self, key: u8, down: bool) -> Outcome { + if self.finished { + return Outcome { consume: false, event: None }; + } + // Key-downs already delivered before capture must have their key-ups delivered too. + // Wait for those keys to be released before recording a new chord. + if self.inherited.iter().any(|&h| h) { + self.inherited[key as usize] = down; + return Outcome { consume: false, event: None }; + } + let was = self.held[key as usize]; + let consume = down || was; + if was == down { + return Outcome { consume, event: None }; + } + self.held[key as usize] = down; + if !modifier_key(key) { + if down { + self.held_order.push(key.into()); + } else { + self.held_order.retain(|&k| k != u16::from(key)); + } + } + if down && !self.cancelled { + let keys = &self.held_order; + if keys.as_slice() == [0x1B] && self.mods() == 0 && self.chosen.is_none() { + self.cancelled = true; + } else if !keys.is_empty() { + // Remember the largest set held together, rather than treating sequential + // taps as a multi-key chord. Modifier order and auto-repeat do not matter. + let previous = self.chosen.map_or(0, |c| c.keys().count()); + if keys.len() >= previous { + self.chosen = Some(Chord::from_keys(self.mods(), keys.iter().copied())); + } + } + } + Outcome { consume, event: self.finish().or_else(|| Some(Event::Holding(self.mods()))) } + } +} + +#[cfg(windows)] +mod windows { + use super::*; + use async_channel::{Receiver, Sender}; + use std::cell::RefCell; + use std::ptr::{null, null_mut}; + use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }; + use std::time::{Duration, Instant}; + use windows_sys::Win32::Foundation::{GetLastError, LPARAM, LRESULT, WPARAM}; + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + use windows_sys::Win32::System::Threading::GetCurrentProcessId; + use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState; + use windows_sys::Win32::UI::WindowsAndMessaging::*; + + static ACTIVE: AtomicBool = AtomicBool::new(false); + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + + struct Local { + recorder: Recorder, + events: Sender, + stop: Arc, + } + thread_local! { static LOCAL: RefCell> = const { RefCell::new(None) }; } + + struct Active; + impl Drop for Active { + fn drop(&mut self) { + ACTIVE.store(false, Ordering::Release); + } + } + + pub struct Capture { + pub id: u64, + pub events: Receiver, + stop: Arc, + } + + impl Drop for Capture { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + } + } + + fn foreground_is_ours() -> bool { + unsafe { + let mut pid = 0; + GetWindowThreadProcessId(GetForegroundWindow(), &mut pid); + pid == GetCurrentProcessId() + } + } + + unsafe extern "system" fn hook(code: i32, message: WPARAM, data: LPARAM) -> LRESULT { + if code < 0 { + return unsafe { CallNextHookEx(null_mut(), code, message, data) }; + } + let down = matches!(message as u32, WM_KEYDOWN | WM_SYSKEYDOWN); + if !down && !matches!(message as u32, WM_KEYUP | WM_SYSKEYUP) { + return unsafe { CallNextHookEx(null_mut(), code, message, data) }; + } + let input = unsafe { &*(data as *const KBDLLHOOKSTRUCT) }; + if input.vkCode > 255 { + return unsafe { CallNextHookEx(null_mut(), code, message, data) }; + } + // Normalise the generic modifier VKeys to their physical left/right versions. + let key = match input.vkCode { + 0x10 => { + if input.scanCode == 0x36 { + 0xA1 + } else { + 0xA0 + } + } + 0x11 => { + if input.flags & LLKHF_EXTENDED != 0 { + 0xA3 + } else { + 0xA2 + } + } + 0x12 => { + if input.flags & LLKHF_EXTENDED != 0 { + 0xA5 + } else { + 0xA4 + } + } + key => key as u8, + }; + let consume = LOCAL.with_borrow_mut(|local| { + let Some(local) = local else { return false }; + if (local.stop.load(Ordering::Acquire) || !foreground_is_ours()) + && let Some(event) = local.recorder.cancel() + { + let _ = local.events.try_send(event); + } + let out = local.recorder.input(key, down); + if let Some(event) = out.event { + let _ = local.events.try_send(event); + } + out.consume + }); + if consume { 1 } else { unsafe { CallNextHookEx(null_mut(), code, message, data) } } + } + + impl Capture { + pub fn start() -> Result { + if ACTIVE.swap(true, Ordering::AcqRel) { + return Err("Release the previous shortcut keys and try again.".into()); + } + let (events, rx) = async_channel::unbounded(); + let (ready, started) = mpsc::sync_channel(1); + let stop = Arc::new(AtomicBool::new(false)); + let cancelled = stop.clone(); + let spawned = std::thread::Builder::new().name("open-controller-key-picker".into()).spawn(move || { + let _active = Active; + let inherited = (8..=255) + .filter(|&k| !matches!(k, 0x10..=0x12)) + .filter(|&k| unsafe { GetAsyncKeyState(k) } < 0) + .map(|k| k as u8) + .collect::>(); + LOCAL.with_borrow_mut(|local| *local = Some(Local { recorder: Recorder::new(inherited), events, stop: cancelled.clone() })); + let handle = unsafe { SetWindowsHookExW(WH_KEYBOARD_LL, Some(hook), GetModuleHandleW(null()), 0) }; + if handle.is_null() { + let _ = ready.send(Err(format!("Windows error {}", unsafe { GetLastError() }))); + LOCAL.with_borrow_mut(|local| *local = None); + return; + } + let timer = unsafe { SetTimer(null_mut(), 0, 30, None) }; + if timer == 0 { + unsafe { + UnhookWindowsHookEx(handle); + } + let _ = ready.send(Err("Could not start the capture timer.".into())); + LOCAL.with_borrow_mut(|local| *local = None); + return; + } + let _ = ready.send(Ok(())); + let deadline = Instant::now() + Duration::from_secs(30); + let mut drain_deadline = None; + let mut message: MSG = unsafe { std::mem::zeroed() }; + while unsafe { GetMessageW(&mut message, null_mut(), 0, 0) } > 0 { + let now = Instant::now(); + let finished = LOCAL.with_borrow_mut(|local| { + let Some(local) = local else { return true }; + if cancelled.load(Ordering::Acquire) || now >= deadline || !foreground_is_ours() { + if let Some(event) = local.recorder.cancel() { + let _ = local.events.try_send(event); + } + drain_deadline.get_or_insert(now + Duration::from_secs(1)); + } + local.recorder.finished + }); + if finished || drain_deadline.is_some_and(|end| now >= end) { + break; + } + } + unsafe { + KillTimer(null_mut(), timer); + UnhookWindowsHookEx(handle); + } + LOCAL.with_borrow_mut(|local| { + if let Some(local) = local.take() { + let _ = local.events.try_send(Event::Cancelled); + } + }); + }); + if let Err(error) = spawned { + ACTIVE.store(false, Ordering::Release); + return Err(error.to_string()); + } + match started.recv_timeout(Duration::from_secs(2)) { + Ok(Ok(())) => Ok(Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), events: rx, stop }), + Ok(Err(error)) => { + stop.store(true, Ordering::Release); + Err(error) + } + Err(error) => { + stop.store(true, Ordering::Release); + Err(error.to_string()) + } + } + } + } +} + +#[cfg(windows)] +pub use windows::Capture; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ctrl_v_up_records_both_keys_and_waits_for_every_release() { + let expected = Chord::from_keys(modifier::CTRL, [0x56, 0x26]); + for releases in [[0x26, 0x56, 0xA2], [0xA2, 0x56, 0x26]] { + let mut r = Recorder::new([]); + for key in [0xA2, 0x56, 0x26] { + assert!(r.input(key, true).consume); + } + assert_eq!(r.chosen, Some(expected)); + assert_eq!(r.input(0x26, true).event, None); + for key in &releases[..2] { + assert!(!matches!(r.input(*key, false).event, Some(Event::Chosen(_)))); + } + assert_eq!(r.input(releases[2], false).event, Some(Event::Chosen(expected))); + } + } + + #[test] + fn sequential_taps_are_not_combined_and_late_modifiers_are_captured() { + let mut r = Recorder::new([]); + r.input(0xA2, true); + r.input(0x56, true); + r.input(0x56, false); + r.input(0x26, true); + r.input(0x26, false); + assert_eq!(r.input(0xA2, false).event, Some(Event::Chosen(Chord::new(modifier::CTRL, 0x26)))); + let mut r = Recorder::new([]); + r.input(0x56, true); + r.input(0xA2, true); + r.input(0x26, true); + assert_eq!(r.chosen, Some(Chord::from_keys(modifier::CTRL, [0x56, 0x26]))); + } + + #[test] + fn shortcut_is_consumed_and_confirmed_only_after_every_key_is_released() { + let mut r = Recorder::new([]); + for key in [0xA2, 0xA4, 0x79] { + assert!(r.input(key, true).consume); + } + assert_eq!(r.chosen, Some(Chord::new(modifier::CTRL | modifier::ALT, 0x79))); + assert_eq!(r.input(0x79, true).event, None, "repeat does not choose twice"); + for key in [0x79, 0xA4] { + let out = r.input(key, false); + assert!(out.consume); + assert!(!matches!(out.event, Some(Event::Chosen(_)))); + } + let out = r.input(0xA2, false); + assert!(out.consume); + assert_eq!(out.event, Some(Event::Chosen(Chord::new(5, 0x79)))); + assert!(!r.input(0x41, true).consume, "normal typing resumes after capture"); + } + #[test] + fn cancellation_drains_captured_modifiers_without_assigning_anything() { + let mut r = Recorder::new([]); + r.input(0xA2, true); + assert_eq!(r.cancel(), None); + assert!(r.input(0x79, true).consume); + r.input(0x79, false); + assert_eq!(r.input(0xA2, false).event, Some(Event::Cancelled)); + assert_eq!(r.chosen, None); + } + #[test] + fn keys_already_down_keep_their_release_and_do_not_become_a_shortcut() { + let mut r = Recorder::new([0xA2]); + assert!(!r.input(0xA2, false).consume); + assert!(r.input(0x79, true).consume); + assert_eq!(r.input(0x79, false).event, Some(Event::Chosen(Chord::new(0, 0x79)))); + } + #[test] + fn escape_cancels_and_unmatched_releases_pass_through() { + let mut r = Recorder::new([]); + assert!(!r.input(0x41, false).consume); + assert!(r.input(0x1B, true).consume); + assert_eq!(r.input(0x1B, false).event, Some(Event::Cancelled)); + } + #[test] + fn modified_escape_is_recorded_including_task_manager_shortcut() { + for modifiers in [vec![0xA2, 0xA0], vec![0xA2], vec![0xA4]] { + let mut r = Recorder::new([]); + for &key in &modifiers { + assert!(r.input(key, true).consume); + } + let expected = Chord::new(r.mods(), 0x1B); + assert!(r.input(0x1B, true).consume); + assert_eq!(r.chosen, Some(expected)); + assert!(!r.cancelled); + assert!(r.input(0x1B, false).consume); + let mut last = None; + for key in modifiers { + last = r.input(key, false).event; + } + assert_eq!(last, Some(Event::Chosen(expected))); + } + } + + #[test] + fn releasing_only_modifiers_keeps_the_picker_open() { + let mut r = Recorder::new([]); + r.input(0xA2, true); + assert_eq!(r.input(0xA2, false).event, Some(Event::Holding(0))); + assert!(!r.finished); + assert_eq!(r.cancel(), Some(Event::Cancelled)); + } +} diff --git a/crates/open-controller-ui/src/keys.rs b/crates/open-controller-ui/src/keys.rs index 5ad9ef6..e0d4930 100644 --- a/crates/open-controller-ui/src/keys.rs +++ b/crates/open-controller-ui/src/keys.rs @@ -54,7 +54,7 @@ pub fn chord(k: &Keystroke) -> Option { chars.next().is_none().then_some(())?; keyboard::vk_for_char(ch) })?; - Some(Chord { mods: mods(&k.modifiers), key }) + Some(Chord::new(mods(&k.modifiers), key)) } /// Keys the window cannot hear, because Windows acts on them first or the keyboard lacks them. @@ -76,10 +76,10 @@ mod tests { #[test] fn keys() { - assert_eq!(chord(&stroke("f5", false)), Some(Chord { mods: 0, key: 0x74 })); - assert_eq!(chord(&stroke("f24", false)), Some(Chord { mods: 0, key: 0x87 })); - assert_eq!(chord(&stroke("a", true)), Some(Chord { mods: modifier::CTRL, key: 0x41 })); - assert_eq!(chord(&stroke("left", false)), Some(Chord { mods: 0, key: 0x25 })); + assert_eq!(chord(&stroke("f5", false)), Some(Chord::new(0, 0x74))); + assert_eq!(chord(&stroke("f24", false)), Some(Chord::new(0, 0x87))); + assert_eq!(chord(&stroke("a", true)), Some(Chord::new(modifier::CTRL, 0x41))); + assert_eq!(chord(&stroke("left", false)), Some(Chord::new(0, 0x25))); assert_eq!(chord(&stroke("f25", false)), None); } } diff --git a/crates/open-controller-ui/src/main.rs b/crates/open-controller-ui/src/main.rs index 55a922b..1764b63 100644 --- a/crates/open-controller-ui/src/main.rs +++ b/crates/open-controller-ui/src/main.rs @@ -5,17 +5,24 @@ //! while the controllers keep working. mod art; +mod combination_help; +mod configuration; mod demo; mod detail; mod frame; mod home; mod icons; +#[cfg(any(windows, test))] +mod key_capture; mod keys; mod learn; +mod mouse_picker; +mod press_meter; mod programs; mod report; mod requirements; mod settings; +mod shortcuts; mod sticks; mod text_field; mod theme; @@ -66,6 +73,7 @@ pub struct Model { /// Shared, so drawing a frame never copies it. pub snapshot: Arc, pub prefs: Prefs, + pub action_library: open_controller_core::portable::ActionLibrary, pub text: &'static Text, pub connection: Connection, /// The resident process's version, when it speaks another version of the pipe's messages. @@ -306,6 +314,7 @@ fn main() { let model = cx.new(|_| Model { snapshot, prefs, + action_library: Default::default(), text, connection: if running { Connection::Connecting } else { Connection::Starting }, other_version: None, @@ -364,6 +373,10 @@ fn main() { m.diagnosis = Some((key, d)); cx.notify(); }), + Event::FromTray(ToWindow::ActionLibrary(library)) => model.update(cx, |m, cx| { + m.action_library = library; + cx.notify(); + }), Event::FromTray(ToWindow::Prefs(mut p)) => model.update(cx, |m, cx| { p.lang = forced_lang.unwrap_or(p.lang); m.prefs = p; diff --git a/crates/open-controller-ui/src/mouse_picker.rs b/crates/open-controller-ui/src/mouse_picker.rs new file mode 100644 index 0000000..5a44e8a --- /dev/null +++ b/crates/open-controller-ui/src/mouse_picker.rs @@ -0,0 +1,105 @@ +//! Pick mouse outputs explicitly; recording a physical side click could trigger other apps. +use crate::{ + theme::Theme, + ui::MainView, + widgets::{Kind, button, caption, card}, +}; +use gpui::{Context, Div, ParentElement, StatefulInteractiveElement, Styled, div, px}; +use open_controller_core::{ + PadKey, + binding::{Action, Chord, modifier}, + keyboard, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Target { + Button(PadKey, u8), + Shortcut, +} +pub struct MousePicker { + pub target: Target, + mods: u8, +} + +impl MainView { + pub fn open_mouse_picker(&mut self, target: Target, cx: &mut Context) { + self.stop_press_meter(cx); + let close = self.mouse_picker.as_ref().is_some_and(|p| p.target == target); + self.stop_shortcut_recording(cx); + self.stop_shortcut_key_capture(cx); + if let Some(e) = self.editor.as_mut() { + e.recording = false; + } + let mods = match target { + Target::Shortcut => self.shortcut_editor.as_ref().map_or(0, |e| e.keys.mods), + Target::Button(key, b) => self + .pad(key, cx) + .and_then(|p| match p.profiles.active().bindings.get(&b) { + Some(Action::Keys(c)) => Some(c.mods), + _ => None, + }) + .unwrap_or(0), + }; + self.mouse_picker = (!close).then_some(MousePicker { target, mods }); + cx.notify(); + } + + pub fn render_mouse_picker(&self, target: Target, t: &Theme, cx: &mut Context) -> Option
{ + let picker = self.mouse_picker.as_ref().filter(|p| p.target == target)?; + let mut modifiers = div().flex().flex_wrap().gap(px(6.)); + for (i, (name, bit)) in + [("Ctrl", modifier::CTRL), ("Shift", modifier::SHIFT), ("Alt", modifier::ALT), ("Win", modifier::WIN)].into_iter().enumerate() + { + modifiers = modifiers.child( + button(("mouse-mod", i), name, if picker.mods & bit != 0 { Kind::Primary } else { Kind::Standard }, t).on_click( + cx.listener(move |this, _, _, cx| { + if let Some(p) = &mut this.mouse_picker { + p.mods ^= bit; + } + cx.notify(); + }), + ), + ); + } + let mut buttons = div().flex().flex_wrap().gap(px(6.)); + for (i, vk) in [1, 2, 4, 5, 6].into_iter().enumerate() { + buttons = buttons.child(button(("mouse-button", i), keyboard::key_name(vk), Kind::Standard, t).on_click(cx.listener( + move |this, _, _, cx| { + let Some(picker) = this.mouse_picker.take().filter(|p| p.target == target) else { + return; + }; + let chord = Chord::new(picker.mods, vk); + match target { + Target::Shortcut => { + if let Some(e) = this.shortcut_editor.as_mut() { + e.keys = chord; + } + } + Target::Button(key, b) => { + if let Some(pad) = this.pad(key, cx) { + this.bind(&pad, b, Some(Action::Keys(chord)), cx); + } + } + } + cx.notify(); + }, + ))); + } + Some( + card(t) + .p(px(12.)) + .flex() + .flex_col() + .gap(px(10.)) + .child(caption(self.model.read(cx).text.configuration.mouse_hint, t.text2)) + .child(modifiers) + .child(buttons) + .child(button("mouse-cancel", self.model.read(cx).text.configuration.cancel, Kind::Subtle, t).on_click(cx.listener( + |this, _, _, cx| { + this.mouse_picker = None; + cx.notify(); + }, + ))), + ) + } +} diff --git a/crates/open-controller-ui/src/press_meter.rs b/crates/open-controller-ui/src/press_meter.rs new file mode 100644 index 0000000..1b8406e --- /dev/null +++ b/crates/open-controller-ui/src/press_meter.rs @@ -0,0 +1,99 @@ +//! Explicit controller hold measurement. Uses resident input observations rather than UI timing. +use crate::{ + theme::Theme, + ui::MainView, + widgets::{Kind, button, caption, card, strong}, +}; +use gpui::{Context, Div, ParentElement, StatefulInteractiveElement, Styled, px}; +use open_controller_core::{PadKey, PadView, Snapshot, i18n::fill, ipc::ToTray, press_meter::Reading}; + +pub struct MeterUi { + pub key: PadKey, + pub active: bool, + reading: Option, +} + +impl MainView { + pub fn stop_press_meter(&mut self, cx: &mut Context) { + if let Some(meter) = self.press_meter.as_mut() + && meter.active + { + meter.active = false; + self.send(ToTray::RecordShortcuts(None), cx); + } + } + + fn start_press_meter(&mut self, key: PadKey, window: &mut gpui::Window, cx: &mut Context) { + self.stop_press_meter(cx); + self.stop_shortcut_recording(cx); + self.stop_shortcut_key_capture(cx); + if let Some(e) = self.editor.as_mut() { + e.recording = false; + } + self.press_meter = Some(MeterUi { key, active: true, reading: None }); + self.send(ToTray::RecordShortcuts(Some(key)), cx); + self.focus.focus(window, cx); + cx.notify(); + } + + pub fn feed_press_meter(&mut self, snapshot: &Snapshot) { + if let Some(meter) = self.press_meter.as_mut() + && meter.active + && let Some(observed) = &snapshot.press_measurement + && let (key, reading) = observed.as_ref() + && *key == meter.key + { + meter.reading = Some(reading.clone()); + } + } + + pub fn press_meter_panel(&self, pad: &PadView, t: &Theme, cx: &mut Context) -> Div { + let text = &self.model.read(cx).text.combinations; + let key = pad.key; + let meter = self.press_meter.as_ref().filter(|m| m.key == key); + let active = meter.is_some_and(|m| m.active); + let mut panel = card(t) + .p(px(12.)) + .flex() + .flex_col() + .gap(px(8.)) + .child(strong(text.meter_title, t.text)) + .child(caption(text.meter_hint, t.text2)) + .child(button("press-measure", if active { text.meter_stop } else { text.meter_start }, Kind::Standard, t).on_click( + cx.listener(move |this, _, window, cx| { + if this.press_meter.as_ref().is_some_and(|m| m.active) { + this.stop_press_meter(cx); + cx.notify(); + } else { + this.start_press_meter(key, window, cx); + } + }), + )); + if active { + panel = panel.child(caption(text.meter_recording, t.accent)); + } + if let Some(reading) = meter.and_then(|m| m.reading.as_ref()) { + panel = panel + .child(strong(crate::shortcuts::names(pad, reading.input, self.model.read(cx).text), t.text)) + .child(caption(fill(text.meter_result, format!("{:.2} ms", reading.held_us as f64 / 1000.)), t.text)) + .child(caption( + fill(text.meter_minimum, format!("{:.2} ms · n={}", reading.minimum_us as f64 / 1000., reading.samples)), + t.text2, + )); + if pad.profiles.active().shortcuts.contains_key(&reading.input) { + panel = panel.child(caption( + fill( + text.meter_output, + if pad.profiles.active().shortcut_held.contains(&reading.input) { + text.held_label.into() + } else { + format!("{} ms", pad.profiles.active().shortcut_hold_ms(reading.input)) + }, + ), + t.text2, + )); + } + } + panel.child(caption(text.meter_limit, t.text3)) + } +} diff --git a/crates/open-controller-ui/src/report.rs b/crates/open-controller-ui/src/report.rs index db767a2..886c939 100644 --- a/crates/open-controller-ui/src/report.rs +++ b/crates/open-controller-ui/src/report.rs @@ -852,7 +852,7 @@ mod tests { assert_eq!(assignments(&Bindings::new()), "none"); let mut b = Bindings::new(); b.insert(0, Action::Xbox(XboxButton::B)); - b.insert(17, Action::Keys(Chord { mods: modifier::CTRL, key: 0x43 })); + b.insert(17, Action::Keys(Chord::new(modifier::CTRL, 0x43))); assert_eq!(assignments(&b), "south (A/✕) → Xbox B, left paddle 1 → Ctrl+C"); } diff --git a/crates/open-controller-ui/src/shortcuts.rs b/crates/open-controller-ui/src/shortcuts.rs new file mode 100644 index 0000000..d739b61 --- /dev/null +++ b/crates/open-controller-ui/src/shortcuts.rs @@ -0,0 +1,618 @@ +//! Record an incoming controller chord, then choose the keyboard chord it taps. +use crate::detail::{normal_label, place_name, printed_name}; +use crate::keys; +use crate::text_field::{TextField, TextFieldEvent}; +use crate::theme::Theme; +use crate::ui::MainView; +use crate::widgets::{Kind, button, caption, card, keycap, strong, switch}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AppContext, Context, Div, Entity, Focusable, InteractiveElement, KeyDownEvent, ParentElement, StatefulInteractiveElement, Styled, + Subscription, div, px, +}; +use open_controller_core::binding::Chord; +use open_controller_core::ipc::ToTray; +use open_controller_core::keyboard; +use open_controller_core::profile::Edit; +use open_controller_core::shortcuts::{self, Recorder}; +use open_controller_core::{PadKey, PadView}; + +pub struct ShortcutEditor { + original: Option, + unassigned: Option, + profile: usize, + conflict: Option, + pub name: Entity, + pub hold: Entity, + pub fixed_duration: bool, + duration_info: bool, + _hold_events: Subscription, + _hold_focus: Subscription, + _name_events: Subscription, + _name_focus: Subscription, + anchor: u8, + input: u64, + pub(crate) keys: Chord, + pub recorder: Option, + pub key_recording: bool, + pub holding: u8, + error: Option, + #[cfg(windows)] + key_capture: Option, +} + +pub(crate) fn names(pad: &PadView, mask: u64, text: &open_controller_core::i18n::Text) -> String { + (0..64) + .filter(|b| mask & (1 << b) != 0) + .map(|b| match 1u64 << b { + shortcuts::LT => "LT / ZL".into(), + shortcuts::RT => "RT / ZR".into(), + _ => { + let normal = normal_label(pad, b); + if !normal.is_empty() { + normal.into() + } else { + printed_name(pad, b).map(str::to_owned).unwrap_or_else(|| place_name(text, pad, b)) + } + } + }) + .collect::>() + .join(" + ") +} + +impl MainView { + pub fn stop_shortcut_recording(&mut self, cx: &mut Context) { + if let Some(e) = self.shortcut_editor.as_mut() + && e.recorder.take().is_some() + { + self.send(ToTray::RecordShortcuts(None), cx); + } + } + + pub fn stop_shortcut_key_capture(&mut self, cx: &mut Context) { + if let Some(e) = self.shortcut_editor.as_mut() { + e.key_recording = false; + e.holding = 0; + #[cfg(windows)] + { + e.key_capture.take(); + } + } + cx.notify(); + } + + fn choose_shortcut_key(&mut self, window: &mut gpui::Window, cx: &mut Context) { + self.mouse_picker = None; + self.stop_press_meter(cx); + self.stop_shortcut_recording(cx); + self.stop_shortcut_key_capture(cx); + self.focus.focus(window, cx); + if self.shortcut_editor.is_none() { + return; + } + #[cfg(windows)] + match crate::key_capture::Capture::start() { + Ok(capture) => { + let id = capture.id; + let events = capture.events.clone(); + if let Some(e) = self.shortcut_editor.as_mut() { + e.key_recording = true; + e.error = None; + e.key_capture = Some(capture); + } + cx.spawn(async move |this, cx| { + while let Ok(event) = events.recv().await { + let done = !matches!(event, crate::key_capture::Event::Holding(_)); + if this + .update(cx, |this, cx| { + let Some(e) = this.shortcut_editor.as_mut().filter(|e| e.key_capture.as_ref().is_some_and(|c| c.id == id)) + else { + return; + }; + match event { + crate::key_capture::Event::Holding(mods) => e.holding = mods, + crate::key_capture::Event::Chosen(chord) => { + e.keys = chord; + e.key_recording = false; + e.holding = 0; + e.key_capture.take(); + } + crate::key_capture::Event::Cancelled => { + e.key_recording = false; + e.holding = 0; + e.key_capture.take(); + } + } + cx.notify(); + }) + .is_err() + || done + { + break; + } + } + }) + .detach(); + } + Err(error) => { + let message = format!("{}: {error}", self.model.read(cx).text.combinations.key_capture_error); + if let Some(e) = self.shortcut_editor.as_mut() { + e.error = Some(message); + } + } + } + #[cfg(not(windows))] + if let Some(e) = self.shortcut_editor.as_mut() { + e.key_recording = true; + } + cx.notify(); + } + + pub fn close_shortcut(&mut self, cx: &mut Context) { + self.stop_shortcut_recording(cx); + self.stop_shortcut_key_capture(cx); + self.shortcut_editor = None; + self.mouse_picker = None; + cx.notify(); + } + + fn open_shortcut(&mut self, key: PadKey, anchor: u8, input: Option, window: &mut gpui::Window, cx: &mut Context) { + self.stop_press_meter(cx); + self.close_shortcut(cx); + let Some(pad) = self.pad(key, cx) else { return }; + let keys = input.and_then(|i| pad.profiles.active().shortcuts.get(&i)).copied().unwrap_or_default(); + let current_name = input.and_then(|i| pad.profiles.active().shortcut_names.get(&i)).cloned().unwrap_or_default(); + let placeholder = self.model.read(cx).text.combinations.name_placeholder; + let name = cx.new(|cx| TextField::new(cx, false, shortcuts::MAX_NAME).placeholder(placeholder)); + name.update(cx, |field, cx| field.set_text(¤t_name, cx)); + let name_events = cx.subscribe(&name, move |this, _, event, cx| { + if let TextFieldEvent::Submit = event { + this.save_shortcut(key, cx); + } + }); + let name_focus = cx.on_focus(&name.focus_handle(cx), window, |this, _, cx| { + this.stop_press_meter(cx); + this.stop_shortcut_recording(cx); + this.stop_shortcut_key_capture(cx); + }); + let hold = cx.new(|cx| TextField::new(cx, false, 5)); + hold.update(cx, |field, cx| field.set_text(&pad.profiles.active().shortcut_hold_ms(input.unwrap_or(0)).to_string(), cx)); + let hold_events = cx.subscribe(&hold, move |this, _, event, cx| { + if let TextFieldEvent::Submit = event { + this.save_shortcut(key, cx); + } + }); + let hold_focus = cx.on_focus(&hold.focus_handle(cx), window, |this, _, cx| { + this.stop_press_meter(cx); + this.stop_shortcut_recording(cx); + this.stop_shortcut_key_capture(cx); + }); + name.focus_handle(cx).focus(window, cx); + self.shortcut_editor = Some(ShortcutEditor { + original: input, + unassigned: None, + profile: pad.profiles.active, + conflict: None, + name, + hold, + fixed_duration: !pad.profiles.active().shortcut_held.contains(&input.unwrap_or(0)), + duration_info: false, + _hold_events: hold_events, + _hold_focus: hold_focus, + _name_events: name_events, + _name_focus: name_focus, + anchor, + input: input.unwrap_or(0), + keys, + recorder: None, + key_recording: false, + holding: 0, + error: None, + #[cfg(windows)] + key_capture: None, + }); + cx.notify(); + } + + fn record_shortcut(&mut self, key: PadKey, cx: &mut Context) { + self.stop_press_meter(cx); + self.stop_shortcut_key_capture(cx); + let Some(pad) = self.pad(key, cx) else { return }; + let Some(e) = self.shortcut_editor.as_mut() else { return }; + e.recorder = Some(Recorder::new(shortcuts::input(&pad.input))); + e.key_recording = false; + e.error = None; + self.send(ToTray::RecordShortcuts(Some(key)), cx); + cx.notify(); + } + + pub fn feed_shortcut(&mut self, pad: &PadView, cx: &mut Context) { + let Some(e) = self.shortcut_editor.as_mut() else { return }; + let Some(r) = e.recorder.as_mut() else { return }; + if let Some(mask) = r.feed(shortcuts::input(&pad.input)) { + e.recorder = None; + if mask != 0 && mask & (1 << e.anchor) != 0 { + e.input = mask; + e.conflict = None; + e.error = None; + } else { + e.error = Some(self.model.read(cx).text.combinations.need_buttons.into()); + } + self.send(ToTray::RecordShortcuts(None), cx); + cx.notify(); + } + } + + pub fn shortcut_key(&mut self, ev: &KeyDownEvent, cx: &mut Context) { + let Some(e) = self.shortcut_editor.as_mut() else { return }; + #[cfg(windows)] + if e.key_capture.is_some() { + return; + } + if ev.keystroke.key == "escape" && keys::mods(&ev.keystroke.modifiers) == 0 { + if e.key_recording { + e.key_recording = false; + } else if e.recorder.is_some() { + self.stop_shortcut_recording(cx); + } else { + self.close_shortcut(cx); + } + } else if e.key_recording + && !ev.is_held + && let Some(chord) = keys::chord(&ev.keystroke) + { + e.keys = chord; + e.key_recording = false; + e.holding = 0; + e.error = None; + } + cx.notify(); + } + + fn open_unassigned(&mut self, key: PadKey, anchor: u8, id: u64, window: &mut gpui::Window, cx: &mut Context) { + let Some(pad) = self.pad(key, cx) else { return }; + let Some(action) = pad.profiles.active().unassigned_shortcuts.get(&id).cloned() else { return }; + self.open_shortcut(key, anchor, None, window, cx); + if let Some(e) = self.shortcut_editor.as_mut() { + e.unassigned = Some(id); + e.keys = action.keys; + e.fixed_duration = !action.hold_until_release; + e.hold.update(cx, |field, cx| field.set_text(&action.hold_ms.to_string(), cx)); + e.name.update(cx, |field, cx| field.set_text(&action.name, cx)); + } + } + + fn save_shortcut(&mut self, key: PadKey, cx: &mut Context) { + self.commit_shortcut(key, false, cx); + } + + fn commit_shortcut(&mut self, key: PadKey, replace: bool, cx: &mut Context) { + let Some(pad) = self.pad(key, cx) else { return }; + let Some(e) = self.shortcut_editor.as_mut() else { return }; + if e.profile != pad.profiles.active { + self.close_shortcut(cx); + return; + } + let entered = + e.hold.read(cx).text().trim().parse::().ok().filter(|ms| (shortcuts::MIN_HOLD_MS..=shortcuts::MAX_HOLD_MS).contains(ms)); + if e.fixed_duration && entered.is_none() { + e.error = Some(self.model.read(cx).text.combinations.duration_error.into()); + cx.notify(); + return; + } + let hold_ms = entered.unwrap_or(shortcuts::DEFAULT_HOLD_MS); + if e.keys.is_empty() || (e.input != 0 && !shortcuts::valid(e.input, e.keys)) { + e.error = Some(self.model.read(cx).text.combinations.need_pair.into()); + } else if !replace && e.input != 0 && e.original != Some(e.input) && pad.profiles.active().shortcuts.contains_key(&e.input) { + e.conflict = Some( + pad.profiles + .active() + .shortcut_names + .get(&e.input) + .cloned() + .unwrap_or_else(|| names(&pad, e.input, self.model.read(cx).text)), + ); + e.error = None; + } else if e.input != 0 + && e.original.is_none() + && !pad.profiles.active().shortcuts.contains_key(&e.input) + && pad.profiles.active().shortcuts.len() >= shortcuts::MAX_SHORTCUTS + { + e.error = Some(self.model.read(cx).text.combinations.limit.into()); + } else if pad.profiles.active().unassigned_shortcuts.len() >= shortcuts::MAX_UNASSIGNED + && e.unassigned.is_none() + && (e.input == 0 || (replace && e.original != Some(e.input))) + { + e.error = Some(self.model.read(cx).text.combinations.capacity.into()); + } else { + let change = open_controller_core::shortcut_edit::Change { + hold_until_release: !e.fixed_duration, + hold_ms, + profile: e.profile, + original: e.original, + unassigned: e.unassigned, + anchor: e.anchor, + input: (e.input != 0).then_some(e.input), + keys: e.keys, + name: e.name.read(cx).text(), + replace, + }; + self.stop_shortcut_recording(cx); + self.edit_profiles(&pad, Edit::ChangeShortcut(change), cx); + self.close_shortcut(cx); + } + cx.notify(); + } + + /// Render one draft where it belongs, directly after its own combination row. + fn shortcut_editor_panel(&self, pad: &PadView, anchor: u8, t: &Theme, cx: &mut Context) -> Div { + let text = self.model.read(cx).text; + let key = pad.key; + let Some(e) = self.shortcut_editor.as_ref().filter(|e| e.anchor == anchor) else { return div() }; + let recording = e.recorder.is_some(); + let shown = e.recorder.as_ref().map_or(e.input, |r| r.shown); + let input_label = if shown == 0 { text.combinations.unassigned.into() } else { names(pad, shown, text) }; + let key_label = if e.key_recording { + if e.holding == 0 { text.press_key.into() } else { keyboard::chord_names(Chord::new(e.holding, 0)).join(" + ") } + } else if e.keys.is_empty() { + text.not_assigned.into() + } else { + keyboard::chord_names(e.keys).join(" + ") + }; + card(t) + .p(px(16.)) + .flex() + .flex_col() + .gap(px(12.)) + .child(caption(text.combinations.name, t.text2)) + .child(e.name.clone()) + .child(caption(text.combinations.input, t.text2)) + .child(div().flex().flex_wrap().gap(px(8.)).items_center().child(keycap(input_label, t)).child( + button("shortcut-record", if recording { text.stop } else { text.combinations.record }, Kind::Standard, t).on_click( + cx.listener(move |this, _, window, cx| { + if this.shortcut_editor.as_ref().is_some_and(|e| e.recorder.is_some()) { + this.stop_shortcut_recording(cx); + } else { + this.record_shortcut(key, cx); + } + this.focus.focus(window, cx); + }), + ), + )) + .when(e.input != 0, |d| { + d.child(button("shortcut-unassign", text.combinations.unassign, Kind::Subtle, t).on_click(cx.listener(|this, _, _, cx| { + this.stop_shortcut_recording(cx); + if let Some(e) = this.shortcut_editor.as_mut() { + e.input = 0; + e.conflict = None; + } + cx.notify(); + }))) + }) + .when(recording, |d| { + d.child(caption(open_controller_core::i18n::fill(text.combinations.record_hint, names(pad, 1 << anchor, text)), t.accent)) + }) + .child(caption(text.combinations.output, t.text2)) + .child( + div() + .flex() + .flex_wrap() + .gap(px(8.)) + .items_center() + .child(keycap(key_label, t)) + .child(button("shortcut-key", text.combinations.choose_key, Kind::Standard, t).on_click(cx.listener( + |this, _, window, cx| { + this.choose_shortcut_key(window, cx); + }, + ))) + .when(cfg!(windows), |d| { + d.child(button("shortcut-mouse", text.configuration.mouse, Kind::Standard, t).on_click(cx.listener( + |this, _, _, cx| { + this.open_mouse_picker(crate::mouse_picker::Target::Shortcut, cx); + }, + ))) + }), + ) + .children(self.render_mouse_picker(crate::mouse_picker::Target::Shortcut, t, cx)) + .child(caption(text.combinations.duration_title, t.text2)) + .child( + div() + .flex() + .items_center() + .flex_wrap() + .gap(px(8.)) + .child( + switch(e.fixed_duration, text.combinations.fixed_on, text.combinations.fixed_off, t) + .id("shortcut-duration-mode") + .on_click(cx.listener(|this, _, window, cx| { + this.stop_press_meter(cx); + this.stop_shortcut_recording(cx); + this.stop_shortcut_key_capture(cx); + if let Some(e) = this.shortcut_editor.as_mut() { + e.fixed_duration = !e.fixed_duration; + e.error = None; + } + this.focus.focus(window, cx); + cx.notify(); + })), + ) + .when(e.fixed_duration, |d| d.child(div().w(px(100.)).child(e.hold.clone())).child(caption("ms", t.text2))) + .when(!e.fixed_duration, |d| d.child(caption(text.combinations.held_label, t.text2))) + .child(button("duration-info", "?", Kind::Subtle, t).size(px(22.)).p(px(0.)).on_click(cx.listener( + |this, _, _, cx| { + if let Some(e) = this.shortcut_editor.as_mut() { + e.duration_info = !e.duration_info; + } + cx.notify(); + }, + ))), + ) + .when(e.duration_info, |d| d.child(caption(text.combinations.duration_modes_help, t.text2))) + .when(e.key_recording && cfg!(windows), |d| d.child(caption(text.combinations.key_capture_hint, t.accent))) + .children(e.error.as_ref().map(|error| caption(error.clone(), t.critical))) + .children(e.conflict.as_ref().map(|name| { + card(t) + .p(px(12.)) + .flex() + .flex_col() + .gap(px(8.)) + .child(caption(open_controller_core::i18n::fill(text.combinations.conflict, name), t.text2)) + .child( + div() + .flex() + .gap(px(8.)) + .child( + button("shortcut-replace", text.combinations.replace, Kind::Primary, t) + .on_click(cx.listener(move |this, _, _, cx| this.commit_shortcut(key, true, cx))), + ) + .child(button("shortcut-keep", text.combinations.cancel, Kind::Standard, t).on_click(cx.listener( + |this, _, _, cx| { + if let Some(e) = this.shortcut_editor.as_mut() { + e.conflict = None; + } + cx.notify(); + }, + ))), + ) + })) + .child( + div() + .flex() + .gap(px(8.)) + .child( + button("shortcut-save", text.combinations.save, Kind::Primary, t) + .on_click(cx.listener(move |this, _, _, cx| this.save_shortcut(key, cx))), + ) + .child( + button("shortcut-cancel", text.combinations.cancel, Kind::Standard, t) + .on_click(cx.listener(|this, _, _, cx| this.close_shortcut(cx))), + ), + ) + } + + pub fn shortcut_panel(&self, pad: &PadView, anchor: u8, t: &Theme, cx: &mut Context) -> Div { + let text = self.model.read(cx).text; + let key = pad.key; + let pad_profile = pad.profiles.active; + let mut col = div().flex().flex_col().gap(px(10.)).child( + div().flex().items_start().gap(px(12.)).child(caption(text.combinations.description, t.text2).flex_1().min_w(px(0.))).child( + button("combinations-help", "?", Kind::Standard, t) + .on_click(cx.listener(|this, _, window, cx| this.open_combination_help(window, cx))), + ), + ); + for (i, (&mask, &keys)) in + pad.profiles.active().shortcuts.iter().filter(|(mask, _)| pad.profiles.active().shortcut_anchor(**mask) == anchor).enumerate() + { + col = col.child( + card(t) + .p(px(12.)) + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_1() + .min_w(px(140.)) + .flex() + .flex_col() + .gap(px(4.)) + .children(pad.profiles.active().shortcut_names.get(&mask).map(|name| strong(name.clone(), t.text))) + .child(caption( + names(pad, mask, text), + if pad.profiles.active().shortcut_names.contains_key(&mask) { t.text2 } else { t.text }, + )), + ) + .child(caption("→", t.text2)) + .child(caption( + format!( + "{} · {}", + keyboard::chord_names(keys).join(" + "), + if pad.profiles.active().shortcut_held.contains(&mask) { + text.combinations.held_label.into() + } else { + format!("{} ms", pad.profiles.active().shortcut_hold_ms(mask)) + } + ), + t.accent, + )) + .child( + button(("shortcut-edit", i), text.combinations.edit, Kind::Subtle, t) + .on_click(cx.listener(move |this, _, window, cx| this.open_shortcut(key, anchor, Some(mask), window, cx))), + ) + .child(button(("shortcut-delete", i), text.delete, Kind::Subtle, t).on_click(cx.listener(move |this, _, _, cx| { + this.close_shortcut(cx); + this.edit_now(key, Edit::RemoveShortcut { profile: pad_profile, input: mask }, cx); + }))), + ); + if self.shortcut_editor.as_ref().is_some_and(|e| e.anchor == anchor && e.original == Some(mask)) { + col = col.child(self.shortcut_editor_panel(pad, anchor, t, cx)); + } + } + for (i, (&id, action)) in pad.profiles.active().unassigned_shortcuts.iter().filter(|(_, a)| a.anchor == anchor).enumerate() { + col = col.child( + card(t) + .p(px(12.)) + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_1() + .min_w(px(140.)) + .flex() + .flex_col() + .gap(px(4.)) + .child(strong( + if action.name.is_empty() { keyboard::chord_names(action.keys).join(" + ") } else { action.name.clone() }, + t.text, + )) + .child(caption(text.combinations.unassigned, t.text3)), + ) + .child(caption( + format!( + "{} · {}", + keyboard::chord_names(action.keys).join(" + "), + if action.hold_until_release { text.combinations.held_label.into() } else { format!("{} ms", action.hold_ms) } + ), + t.accent, + )) + .child( + button(("unassigned-edit", i), text.combinations.edit, Kind::Subtle, t) + .on_click(cx.listener(move |this, _, window, cx| this.open_unassigned(key, anchor, id, window, cx))), + ) + .child(button(("unassigned-delete", i), text.delete, Kind::Subtle, t).on_click(cx.listener(move |this, _, _, cx| { + this.close_shortcut(cx); + if let Some(pad) = this.pad(key, cx) { + this.edit_profiles(&pad, Edit::RemoveUnassigned { profile: pad.profiles.active, id }, cx); + } + }))), + ); + if self.shortcut_editor.as_ref().is_some_and(|e| e.anchor == anchor && e.unassigned == Some(id)) { + col = col.child(self.shortcut_editor_panel(pad, anchor, t, cx)); + } + } + if let Some(keys) = pad.profiles.active().button_presets.get(&anchor).and_then(|p| p.key) { + col = col.child(caption( + open_controller_core::i18n::fill(text.combinations.saved_key, keyboard::chord_names(keys).join(" + ")), + t.text2, + )); + } + if let Some(e) = self.shortcut_editor.as_ref().filter(|e| e.anchor == anchor) { + if e.original.is_none() && e.unassigned.is_none() { + col = col.child(self.shortcut_editor_panel(pad, anchor, t, cx)); + } + } else { + col = col.child( + div().flex().child( + button("shortcut-add", text.combinations.add, Kind::Standard, t) + .on_click(cx.listener(move |this, _, window, cx| this.open_shortcut(key, anchor, None, window, cx))), + ), + ); + } + col.child(self.press_meter_panel(pad, t, cx)) + .child(caption(text.combinations.native_hint, t.text2)) + .child(self.configuration_panel(pad, t, cx)) + } +} diff --git a/crates/open-controller-ui/src/ui.rs b/crates/open-controller-ui/src/ui.rs index b1e3f1b..7a9a95d 100644 --- a/crates/open-controller-ui/src/ui.rs +++ b/crates/open-controller-ui/src/ui.rs @@ -8,10 +8,12 @@ use crate::widgets::{caption, icon_button, strong}; use crate::{Connection, Model}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, Context, Entity, FocusHandle, Image, ImageFormat, InteractiveElement, IntoElement, KeyDownEvent, ModifiersChangedEvent, - MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription, Window, WindowAppearance, div, img, px, + AnyElement, Context, Entity, FocusHandle, Focusable, Image, ImageFormat, InteractiveElement, IntoElement, KeyDownEvent, + ModifiersChangedEvent, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription, Window, WindowAppearance, + div, img, px, }; use open_controller_core::binding::{self, Action, Buttons, Step}; +use open_controller_core::button_mode::Mode; use open_controller_core::ipc::ToTray; use open_controller_core::platform::VirtualDriver; use open_controller_core::profile::Edit; @@ -53,9 +55,33 @@ pub enum Tab { Xbox, Key, Macro, + Combinations, Nothing, } +impl From for Tab { + fn from(mode: Mode) -> Self { + match mode { + Mode::Xbox => Self::Xbox, + Mode::Key => Self::Key, + Mode::Macro => Self::Macro, + Mode::Combinations => Self::Combinations, + Mode::Nothing => Self::Nothing, + } + } +} +impl From for Mode { + fn from(tab: Tab) -> Self { + match tab { + Tab::Xbox => Self::Xbox, + Tab::Key => Self::Key, + Tab::Macro => Self::Macro, + Tab::Combinations => Self::Combinations, + Tab::Nothing => Self::Nothing, + } + } +} + /// The assignment being edited: one extra button of the open controller. pub struct Editor { pub button: u8, @@ -120,6 +146,12 @@ pub struct MainView { pub screen: Screen, pub section: Section, pub editor: Option, + pub shortcut_editor: Option, + pub combination_help: bool, + pub press_meter: Option, + pub config: crate::configuration::ConfigUi, + pub mouse_picker: Option, + pub editing_profile: Option<(usize, String)>, pub confirm_reset: bool, pub profile_menu: bool, pub renaming: Option, @@ -174,6 +206,11 @@ 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); + if !window.is_window_active() { + this.stop_press_meter(cx); + this.stop_shortcut_recording(cx); + this.stop_shortcut_key_capture(cx); + } // 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); @@ -192,6 +229,12 @@ impl MainView { screen: Screen::Home, section: Section::Buttons, editor: None, + shortcut_editor: None, + combination_help: false, + press_meter: None, + config: Default::default(), + mouse_picker: None, + editing_profile: None, confirm_reset: false, profile_menu: false, renaming: None, @@ -260,6 +303,11 @@ impl MainView { /// before is told again. The snapshot and the preferences come on their own. pub fn linked(&self, window: &Window, cx: &mut Context) { self.send(ToTray::Focused(window.is_window_active()), cx); + if self.shortcut_editor.as_ref().is_some_and(|e| e.recorder.is_some()) + && let Screen::Device(key, _) = self.screen + { + self.send(ToTray::RecordShortcuts(Some(key)), cx); + } let m = self.model.read(cx); if let Some(r) = &self.report && !m.diagnosis.as_ref().is_some_and(|(k, _)| *k == r.key) @@ -268,8 +316,16 @@ impl MainView { } } - fn reset_page(&mut self) { + fn reset_page(&mut self, cx: &mut Context) { + self.close_shortcut(cx); + self.combination_help = false; + self.stop_press_meter(cx); + self.press_meter = None; + self.config.pending = None; + self.config.status = None; + self.mouse_picker = None; self.editor = None; + self.editing_profile = None; self.confirm_reset = false; self.profile_menu = false; self.renaming = None; @@ -285,7 +341,7 @@ impl MainView { let pad = snap.pads.iter().find(|p| p.key == key); self.screen = Screen::Device(key, pad.and_then(|p| p.store.clone())); self.section = Section::Buttons; - self.reset_page(); + self.reset_page(cx); self.learn = None; self.stick_right = false; self.held = pad.map(extras_held).unwrap_or(0); @@ -294,7 +350,7 @@ impl MainView { pub fn home(&mut self, cx: &mut Context) { self.screen = Screen::Home; - self.reset_page(); + self.reset_page(cx); self.learn = None; self.follow = None; cx.notify(); @@ -302,7 +358,7 @@ impl MainView { pub fn settings(&mut self, cx: &mut Context) { self.screen = Screen::Settings; - self.reset_page(); + self.reset_page(cx); self.learn = None; self.follow = None; self.read_installed(cx); @@ -311,7 +367,7 @@ impl MainView { pub fn show(&mut self, section: Section, cx: &mut Context) { self.section = section; - self.reset_page(); + self.reset_page(cx); cx.notify(); } @@ -379,18 +435,13 @@ impl MainView { /// Opens the editor for `button`, on the tab of what it does now. pub fn edit(&mut self, pad: &PadView, button: u8, cx: &mut Context) { + self.close_shortcut(cx); if self.editor.as_ref().is_some_and(|e| e.button == button) { self.editor = None; } else { let current = pad.profiles.active().bindings.get(&button); let keys_only = crate::detail::native(pad); - let tab = match current { - None if keys_only => Tab::Key, - None if open_controller_core::binding::is_normal(button) => Tab::Nothing, - Some(Action::Xbox(_) | Action::Combo(_)) | None => Tab::Xbox, - Some(Action::Keys(_)) => Tab::Key, - Some(Action::Macro(_)) => Tab::Macro, - }; + let tab = if keys_only && current.is_none() { Tab::Key } else { pad.profiles.active().button_mode(button).into() }; let steps = match current { Some(Action::Macro(s)) => s.clone(), _ => Vec::new(), @@ -412,6 +463,28 @@ impl MainView { cx.notify(); } + pub fn select_button_tab(&mut self, key: PadKey, button: u8, tab: Tab, cx: &mut Context) { + self.close_shortcut(cx); + let Some(pad) = self.pad(key, cx) else { return }; + let mut preview = pad.profiles.active().clone(); + preview.select_button_mode(button, tab.into()); + if let Some(e) = self.editor.as_mut() { + e.tab = tab; + e.recording = false; + e.other_keys = false; + e.holding = 0; + e.last_key = None; + e.xbox_held = None; + e.steps = match preview.bindings.get(&button) { + Some(Action::Macro(s)) => s.clone(), + _ => Vec::new(), + }; + e.several = matches!(preview.bindings.get(&button), Some(Action::Combo(_))); + } + self.edit_profiles(&pad, Edit::ButtonMode { button, mode: tab.into() }, cx); + cx.notify(); + } + pub fn bind(&self, pad: &PadView, button: u8, action: Option, cx: &mut Context) { self.edit_profiles(pad, Edit::Bind { button, action }, cx); } @@ -443,7 +516,23 @@ impl MainView { if let Some(m) = self.measuring.as_mut().filter(|m| m.key == pad.key) { m.sample(&pad.input); } + let identity = (pad.profiles.active, pad.profiles.active().name.clone()); + if self.editing_profile.as_ref().is_some_and(|old| old != &identity) { + self.close_shortcut(cx); + self.stop_press_meter(cx); + self.press_meter = None; + self.editor = None; + self.mouse_picker = None; + self.config.pending = None; + } + self.editing_profile = Some(identity); + self.feed_press_meter(&snap); self.feed_learning(pad); + if self.shortcut_editor.is_some() { + self.feed_shortcut(pad, cx); + self.held = extras_held(pad); + return; + } self.record_xbox(pad, cx); let held = extras_held(pad); let pressed = held & !self.held; @@ -478,7 +567,28 @@ impl MainView { fn key_down(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context) { let key = ev.keystroke.key.as_str(); - let escape = key == "escape"; + let escape = key == "escape" && keys::mods(&ev.keystroke.modifiers) == 0; + if self.combination_help { + if escape { + self.combination_help = false; + cx.notify(); + } + cx.stop_propagation(); + return; + } + if self.shortcut_editor.is_some() { + if !escape + && self + .shortcut_editor + .as_ref() + .is_some_and(|e| e.name.focus_handle(cx).is_focused(window) || e.hold.focus_handle(cx).is_focused(window)) + { + return; + } + self.shortcut_key(ev, cx); + cx.stop_propagation(); + return; + } if self.report.is_some() { if escape { self.close_report(window, cx); @@ -550,6 +660,10 @@ impl MainView { } fn modifiers_changed(&mut self, ev: &ModifiersChangedEvent, _: &mut Window, cx: &mut Context) { + if let Some(e) = self.shortcut_editor.as_mut().filter(|e| e.key_recording) { + e.holding = keys::mods(&ev.modifiers); + cx.notify(); + } if let Some(e) = self.editor.as_mut().filter(|e| e.recording) { e.holding = keys::mods(&ev.modifiers); cx.notify(); @@ -637,6 +751,7 @@ impl Render for MainView { }, }; let report = self.render_report(cx); + let combination_help = self.render_combination_help(cx); let page = div() .id("root") .relative() @@ -660,7 +775,8 @@ impl Render for MainView { .child(div().w_full().max_w(px(max_w)).px(px(layout::GUTTER)).pt(px(28.)).pb(px(40.)).child(content)), ), ) - .children(report); + .children(report) + .children(combination_help); crate::frame::frame(page.into_any_element(), &t, window) } } @@ -687,7 +803,7 @@ mod tests { #[test] fn a_recorded_pause_leaves_out_the_time_a_tap_takes() { - let (a, key) = (Step::Xbox(Buttons::of(&[XboxButton::A])), Step::Keys(Chord { mods: 0, key: 0x41 })); + let (a, key) = (Step::Xbox(Buttons::of(&[XboxButton::A])), Step::Keys(Chord::new(0, 0x41))); let mut e = editor(); let t = Instant::now(); assert!(e.record(a, t)); diff --git a/crates/open-controller/src/main.rs b/crates/open-controller/src/main.rs index ababae5..ee62fdb 100644 --- a/crates/open-controller/src/main.rs +++ b/crates/open-controller/src/main.rs @@ -210,6 +210,10 @@ impl Control { self.engine.send(Command::SetMappings(s.mappings.clone())); } + pub fn action_library(&self) -> open_controller_core::portable::ActionLibrary { + self.settings.lock().map(|s| s.action_library.clone()).unwrap_or_default() + } + pub fn apply(&self, req: ToTray) { match req { ToTray::SetHiding(on) => { @@ -248,7 +252,25 @@ impl Control { } self.engine.send(Command::SplitJoyCons(on)); } + ToTray::ApplyConfiguration { store, profiles, actions } => { + let Ok(actions) = actions.validate() else { return }; + if profiles.is_some() && store.is_none() { + return; + } + let profiles = profiles.map(|p| p.sanitised()); + let Ok(mut settings) = self.settings.lock() else { return }; + if let (Some(store), Some(profiles)) = (&store, &profiles) { + settings.controllers.insert(store.clone(), profiles.clone()); + } + settings.action_library = actions; + settings.save(&self.data_dir); + drop(settings); + if let (Some(store), Some(profiles)) = (store, profiles) { + self.engine.send(Command::SetProfiles(store, profiles)); + } + } ToTray::Focused(on) => return self.engine.send(Command::Focused(on)), + ToTray::RecordShortcuts(key) => return self.engine.send(Command::RecordShortcuts(key)), ToTray::Identify(key) => return self.engine.send(Command::Identify(key)), ToTray::PowerOff(key) => return self.engine.send(Command::PowerOff(key)), ToTray::SwapPlayers(a, b) => return self.engine.send(Command::SwapPlayers(a, b)), diff --git a/crates/open-controller/src/server.rs b/crates/open-controller/src/server.rs index 42f37e0..ee402a0 100644 --- a/crates/open-controller/src/server.rs +++ b/crates/open-controller/src/server.rs @@ -142,7 +142,9 @@ fn serve(pipe: &Pipe, control: &Control, notify: &Notify) { let what = notify.pending.swap(0, Ordering::AcqRel); let mut sent = Ok(()); if what & PREFS != 0 { - sent = pipe.send(&ToWindow::Prefs(control.prefs())); + sent = pipe + .send(&ToWindow::Prefs(control.prefs())) + .and_then(|_| pipe.send(&ToWindow::ActionLibrary(control.action_library()))); } if sent.is_ok() && what & SNAPSHOT != 0 { sent = pipe.send(&ToWindow::Snapshot(control.engine.snapshot())); diff --git a/crates/open-controller/src/settings.rs b/crates/open-controller/src/settings.rs index 28bb7d6..52994ec 100644 --- a/crates/open-controller/src/settings.rs +++ b/crates/open-controller/src/settings.rs @@ -2,6 +2,7 @@ use open_controller_core::i18n::Lang; use open_controller_core::platform::VirtualDriver; +use open_controller_core::portable::ActionLibrary; use open_controller_core::profile::{Profiles, readable_fields}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -27,6 +28,7 @@ pub struct Settings { pub language: Lang, /// Each controller's profiles, by where its settings are kept. pub controllers: BTreeMap, + pub action_library: ActionLibrary, /// The driver that makes the virtual controllers on Windows: ViGEmBus, or the experimental /// VIIPER. A value this version does not know reads as ViGEmBus; a version without the field /// ignores it. @@ -48,6 +50,7 @@ impl Default for Settings { check_updates: true, language: Lang::En, controllers: BTreeMap::new(), + action_library: ActionLibrary::default(), virtual_driver: VirtualDriver::ViGEmBus, split_joycons: false, mappings: BTreeMap::new(), @@ -67,6 +70,7 @@ impl Settings { keep_bad(dir, &bytes); Settings::salvage(&bytes) }); + s.action_library = std::mem::take(&mut s.action_library).validate().unwrap_or_default(); s.controllers = std::mem::take(&mut s.controllers).into_iter().map(|(k, v)| (k, v.sanitised())).collect(); s } @@ -327,7 +331,7 @@ mod tests { let p = s.controllers.entry("serial:e417d8bc366c".into()).or_default(); p.apply(Edit::Bind { button: 17, action: Some(Action::Xbox(XboxButton::A)) }); p.apply(Edit::Add("Racing".into())); - p.apply(Edit::Bind { button: 19, action: Some(Action::Macro(vec![Step::Keys(Chord { mods: 1, key: 0x43 }), Step::Wait(40)])) }); + p.apply(Edit::Bind { button: 19, action: Some(Action::Macro(vec![Step::Keys(Chord::new(1, 0x43)), Step::Wait(40)])) }); p.apply(Edit::Light(Light::Color([255, 80, 0]))); s.save(&dir); assert_eq!(Settings::load(&dir), s); @@ -381,7 +385,7 @@ mod tests { let mut s = Settings::default(); let p = s.controllers.entry("serial:98b6e9a1c2d3".into()).or_default(); p.apply(Edit::Letters(true)); - p.apply(Edit::Bind { button: 6, action: Some(Action::Keys(Chord { mods: 0, key: 0x1B })) }); + p.apply(Edit::Bind { button: 6, action: Some(Action::Keys(Chord::new(0, 0x1B))) }); s.save(&dir); let back = Settings::load(&dir); assert_eq!(back, s); @@ -401,4 +405,31 @@ mod tests { assert!(back.controllers["model:054c:0ce6"].native); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn global_mouse_actions_survive_save_alongside_unchanged_controller_profiles() { + use open_controller_core::{binding::Chord, portable::NamedAction, profile::Edit}; + let dir = std::env::temp_dir().join(format!("oc-settings-library-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut settings = Settings::default(); + let mut profiles = Profiles::default(); + profiles.apply(Edit::Shortcut { + anchor: 15, + input: (1 << 15) | 1, + name: Some("Existing action".into()), + keys: Some(Chord::new(5, 121)), + }); + settings.controllers.insert("serial:current".into(), profiles.clone()); + settings.action_library.actions.push(NamedAction { + name: "OBS recording".into(), + keys: Chord::new(3, 5), + hold_ms: 100, + hold_until_release: false, + }); + settings.save(&dir); + let loaded = Settings::load(&dir); + assert_eq!(loaded, settings); + assert_eq!(loaded.controllers["serial:current"], profiles); + assert_eq!(loaded.action_library.actions[0].keys, Chord::new(3, 5)); + std::fs::remove_dir_all(dir).unwrap(); + } }