From cc1e7af7e62fab4bb43731dc54b2800ce7fbff0a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 16 Jul 2026 14:25:44 -0700 Subject: [PATCH 1/2] feat(core): add HoldShortcut action for push-to-talk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every action was a one-shot tap fired on button-down, so there was no way to bind a key that stays held — the shape push-to-talk needs. Append Action::HoldShortcut(KeyCombo), carrying the same recorded chord as CustomShortcut but with press/release semantics. Appended at the end: serde encodes the declaration index, so existing variants keep their wire positions and the goldens are unchanged. PROTOCOL_VERSION 8 -> 9 since Action crosses the IPC boundary. openlogi-inject gains phase-aware primitives (press_hold/release_hold) built from the existing per-platform helpers. Down order is modifiers-then-key and up order is the reverse: a chord whose modifiers lifted before its key would surface as a bare keypress. A HoldShortcut reaching execute() came from a dispatch path with no release edge (the HID++ gesture button, which reports an instantaneous click), so it degrades to a tap rather than jamming the chord down. Like CustomShortcut, this is hand-authored TOML for now — the recorder UI that would offer a Hold toggle does not exist yet, so the GUI only labels and renders it. --- crates/openlogi-agent-core/src/ipc.rs | 3 +- .../openlogi-agent-core/tests/wire_format.rs | 2 +- crates/openlogi-core/src/binding.rs | 107 +++++++++++++++++- crates/openlogi-gui/locales/da.yml | 1 + crates/openlogi-gui/locales/de.yml | 1 + crates/openlogi-gui/locales/el.yml | 1 + crates/openlogi-gui/locales/en.yml | 1 + crates/openlogi-gui/locales/es.yml | 1 + crates/openlogi-gui/locales/fi.yml | 1 + crates/openlogi-gui/locales/fr.yml | 1 + crates/openlogi-gui/locales/it.yml | 1 + crates/openlogi-gui/locales/ja.yml | 1 + crates/openlogi-gui/locales/ko.yml | 1 + crates/openlogi-gui/locales/nb.yml | 1 + crates/openlogi-gui/locales/nl.yml | 1 + crates/openlogi-gui/locales/pl.yml | 1 + crates/openlogi-gui/locales/pt-BR.yml | 1 + crates/openlogi-gui/locales/pt-PT.yml | 1 + crates/openlogi-gui/locales/ru.yml | 1 + crates/openlogi-gui/locales/sv.yml | 1 + crates/openlogi-gui/locales/zh-CN.yml | 1 + crates/openlogi-gui/locales/zh-HK.yml | 1 + crates/openlogi-gui/locales/zh-TW.yml | 1 + crates/openlogi-gui/src/mouse_model/picker.rs | 2 +- crates/openlogi-gui/src/mouse_model/view.rs | 1 + crates/openlogi-inject/src/inject.rs | 55 ++++++++- crates/openlogi-inject/src/inject/linux.rs | 53 +++++++++ crates/openlogi-inject/src/inject/macos.rs | 79 ++++++++----- crates/openlogi-inject/src/inject/windows.rs | 47 +++++++- crates/openlogi-inject/src/lib.rs | 4 +- 30 files changed, 337 insertions(+), 36 deletions(-) diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index 9acdc281..0edb1a32 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -28,7 +28,8 @@ use serde::{Deserialize, Serialize}; /// v8: [`WriteError`] carries typed HID++ operation failures. /// v9: `poll_event_monitor` appended + [`MonitorEvent`] (live event monitor). /// v10: `Capabilities::hires_wheel` appended. -pub const PROTOCOL_VERSION: u32 = 10; +/// v11: `Action::HoldShortcut` appended (push-to-talk). +pub const PROTOCOL_VERSION: u32 = 11; /// Where the agent's device enumeration stands. The distinction matters /// because an empty inventory list is ambiguous on its own: the GUI must keep diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index c5b14e4c..e89988d7 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -61,7 +61,7 @@ fn assert_wire(value: &T, golden: &str) { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 10); + assert_eq!(PROTOCOL_VERSION, 11); } /// tarpc encodes the request enum's variant index, so trait *method order* is diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 97ddea09..8d7b8964 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -363,6 +363,24 @@ pub enum Action { /// The `display` field is used by [`Action::label`] so the popover /// shows the user-friendly chord name. CustomShortcut(KeyCombo), + + /// Hold an arbitrary recorded key chord for as long as the physical + /// button is held — push-to-talk. + /// + /// Unlike [`Action::CustomShortcut`], which taps the chord (down+up) on + /// button-down and ignores the release, this synthesises the key-down on + /// press and the matching key-up on release. The chord therefore stays + /// held from the OS's point of view, which is what conferencing and games + /// listen for. + /// + /// Only reachable from the OS-hook path ([`ButtonId::is_os_hook_button`]), + /// which is the only capture route that observes both edges. The HID++ + /// gesture button reports an instantaneous click with no release, so a + /// hold bound there degrades to a tap rather than jamming the key down. + /// + /// [`KeyCombo::key_code`] must be non-zero: a modifier-only hold is + /// representable but the recorder rejects it, so nothing can produce one. + HoldShortcut(KeyCombo), } /// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or @@ -609,6 +627,17 @@ impl Action { Action::HorizontalScrollLeft => "Scroll Left".into(), Action::HorizontalScrollRight => "Scroll Right".into(), Action::CustomShortcut(combo) => combo.rendered_label(), + Action::HoldShortcut(combo) => format!("Hold {}", combo.rendered_label()), + } + } + + /// The chord this action holds down for the duration of the physical + /// press, if any. `None` for every tap-style action. + #[must_use] + pub fn held_combo(&self) -> Option<&KeyCombo> { + match self { + Action::HoldShortcut(combo) => Some(combo), + _ => None, } } @@ -631,7 +660,8 @@ impl Action { | Action::SelectAll | Action::Find | Action::Save - | Action::CustomShortcut(_) => Category::Editing, + | Action::CustomShortcut(_) + | Action::HoldShortcut(_) => Category::Editing, Action::BrowserBack | Action::BrowserForward | Action::NewTab @@ -667,7 +697,8 @@ impl Action { /// All pickable actions in a deterministic order. /// - /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via + /// [`Action::CustomShortcut`] and [`Action::HoldShortcut`] are + /// intentionally excluded — both carry a recorded chord and are opened via /// "Record shortcut…" (P1.3), not selected from the catalog. #[must_use] pub fn catalog() -> Vec { @@ -843,6 +874,78 @@ mod tests { } } + #[test] + fn catalog_excludes_hold_shortcut() { + let catalog = Action::catalog(); + for action in &catalog { + assert!( + !matches!(action, Action::HoldShortcut(_)), + "catalog must not contain HoldShortcut — it carries a recorded \ + chord and is reached via the recorder's Hold toggle" + ); + } + } + + // ── HoldShortcut ────────────────────────────────────────────────────────── + + fn ptt_combo() -> KeyCombo { + KeyCombo { + modifiers: KeyCombo::MOD_OPTION, + key_code: 0x31, + display: "⌥Space".into(), + } + } + + #[test] + fn held_combo_is_some_only_for_hold_shortcut() { + assert_eq!( + Action::HoldShortcut(ptt_combo()).held_combo(), + Some(&ptt_combo()) + ); + assert_eq!( + Action::CustomShortcut(ptt_combo()).held_combo(), + None, + "a tap shortcut must never be treated as a hold — it has no release" + ); + assert_eq!(Action::BrowserBack.held_combo(), None); + assert_eq!(Action::None.held_combo(), None); + } + + #[test] + fn hold_shortcut_label_distinguishes_itself_from_a_tap() { + assert_eq!(Action::HoldShortcut(ptt_combo()).label(), "Hold ⌥Space"); + assert_eq!(Action::CustomShortcut(ptt_combo()).label(), "⌥Space"); + } + + #[test] + fn hold_shortcut_roundtrips_through_toml() { + let mut bindings = BTreeMap::new(); + bindings.insert( + ButtonId::Back, + Binding::Single(Action::HoldShortcut(ptt_combo())), + ); + let back = binding_roundtrip(bindings) + .remove(&ButtonId::Back) + .expect("Back survives the roundtrip"); + assert_eq!(back, Binding::Single(Action::HoldShortcut(ptt_combo()))); + } + + /// The untagged `Binding` enum tries `Single(Action)` first, so a payload + /// variant must not be mistaken for a gesture map (same hazard the + /// `CustomShortcut` case guards). + #[test] + fn hold_shortcut_stays_single_not_gesture() { + let mut bindings = BTreeMap::new(); + bindings.insert( + ButtonId::Back, + Binding::Single(Action::HoldShortcut(ptt_combo())), + ); + assert!(matches!( + binding_roundtrip(bindings).remove(&ButtonId::Back), + Some(Binding::Single(Action::HoldShortcut(_))) + )); + } + // ── Binding (merged model) serde routing ────────────────────────────────── /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings` diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index cf5983fb..8530abdf 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Midterknap" "DPI Preset %{index}": "DPI-forindstilling %{index}" "Gesture %{name}": "Bevægelse %{name}" +"Hold %{chord}": "Hold %{chord}" "Left Click": "Venstreklik" "Right Click": "Højreklik" "Middle Click": "Midterklik" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 203108dd..c47986de 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Mitteltaste" "DPI Preset %{index}": "DPI-Voreinstellung %{index}" "Gesture %{name}": "Geste %{name}" +"Hold %{chord}": "%{chord} halten" "Left Click": "Linksklick" "Right Click": "Rechtsklick" "Middle Click": "Mittelklick" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index 80d7156d..d0e2b157 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Μεσαίο" "DPI Preset %{index}": "Προεπιλογή DPI %{index}" "Gesture %{name}": "Χειρονομία %{name}" +"Hold %{chord}": "Κράτημα %{chord}" "Left Click": "Αριστερό κλικ" "Right Click": "Δεξί κλικ" "Middle Click": "Μεσαίο κλικ" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index e574c8c5..bce8808c 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Middle" "DPI Preset %{index}": "DPI Preset %{index}" "Gesture %{name}": "Gesture %{name}" +"Hold %{chord}": "Hold %{chord}" "Left Click": "Left Click" "Right Click": "Right Click" "Middle Click": "Middle Click" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index 37f0c3a2..6913fb17 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Central" "DPI Preset %{index}": "Preajuste de DPI %{index}" "Gesture %{name}": "Gesto %{name}" +"Hold %{chord}": "Mantener %{chord}" "Left Click": "Clic izquierdo" "Right Click": "Clic derecho" "Middle Click": "Clic central" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index b14640ce..c0e2c385 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Keskipainike" "DPI Preset %{index}": "DPI-esiasetus %{index}" "Gesture %{name}": "Ele %{name}" +"Hold %{chord}": "Pidä %{chord}" "Left Click": "Vasen napsautus" "Right Click": "Oikea napsautus" "Middle Click": "Keskinapsautus" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index cdb201d9..576cdf6b 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Milieu" "DPI Preset %{index}": "Préréglage DPI %{index}" "Gesture %{name}": "Geste %{name}" +"Hold %{chord}": "Maintenir %{chord}" "Left Click": "Clic gauche" "Right Click": "Clic droit" "Middle Click": "Clic du milieu" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index f35d58a8..1f854ce5 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Centrale" "DPI Preset %{index}": "Preset DPI %{index}" "Gesture %{name}": "Gesture %{name}" +"Hold %{chord}": "Tieni premuto %{chord}" "Left Click": "Click sinistro" "Right Click": "Click destro" "Middle Click": "Click centrale" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index ba765827..7fa03bf4 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "中ボタン" "DPI Preset %{index}": "DPI プリセット %{index}" "Gesture %{name}": "ジェスチャー %{name}" +"Hold %{chord}": "%{chord} を長押し" "Left Click": "左クリック" "Right Click": "右クリック" "Middle Click": "中クリック" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index 1f6cfa17..fbcb141a 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "가운데 버튼" "DPI Preset %{index}": "DPI 프리셋 %{index}" "Gesture %{name}": "제스처 %{name}" +"Hold %{chord}": "%{chord} 길게 누르기" "Left Click": "왼쪽 클릭" "Right Click": "오른쪽 클릭" "Middle Click": "가운데 클릭" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index 3c74b79e..46ce87bd 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Midtknapp" "DPI Preset %{index}": "DPI-forhåndsinnstilling %{index}" "Gesture %{name}": "Bevegelse %{name}" +"Hold %{chord}": "Hold %{chord}" "Left Click": "Venstreklikk" "Right Click": "Høyreklikk" "Middle Click": "Midtklikk" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index f9e485dc..0ce2cef0 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Middenknop" "DPI Preset %{index}": "DPI-voorinstelling %{index}" "Gesture %{name}": "Gebaar %{name}" +"Hold %{chord}": "%{chord} ingedrukt houden" "Left Click": "Linkerklik" "Right Click": "Rechterklik" "Middle Click": "Middenklik" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index e72d22b1..d1223fbe 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Środkowy" "DPI Preset %{index}": "Ustawienie wstępne DPI %{index}" "Gesture %{name}": "Gest %{name}" +"Hold %{chord}": "Przytrzymaj %{chord}" "Left Click": "Lewy przycisk" "Right Click": "Prawy przycisk" "Middle Click": "Środkowy przycisk" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index a298fe26..4e23a551 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Meio" "DPI Preset %{index}": "Predefinição de DPI %{index}" "Gesture %{name}": "Gesto %{name}" +"Hold %{chord}": "Manter %{chord}" "Left Click": "Clique Esquerdo" "Right Click": "Clique Direito" "Middle Click": "Clique do Meio" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index dfe9346b..364a1445 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Central" "DPI Preset %{index}": "Predefinição de DPI %{index}" "Gesture %{name}": "Gesto %{name}" +"Hold %{chord}": "Manter %{chord}" "Left Click": "Clique esquerdo" "Right Click": "Clique direito" "Middle Click": "Clique central" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index c50a9281..59c63912 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Средняя" "DPI Preset %{index}": "Пресет DPI %{index}" "Gesture %{name}": "Жест %{name}" +"Hold %{chord}": "Удерживать %{chord}" "Left Click": "Левый щелчок" "Right Click": "Правый щелчок" "Middle Click": "Средний щелчок" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index df82147f..ae2d223c 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "Mittenknapp" "DPI Preset %{index}": "DPI-förinställning %{index}" "Gesture %{name}": "Gest %{name}" +"Hold %{chord}": "Håll %{chord}" "Left Click": "Vänsterklick" "Right Click": "Högerklick" "Middle Click": "Mittenklick" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index 318d29eb..c8e7db90 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "中键" "DPI Preset %{index}": "灵敏度预设 %{index}" "Gesture %{name}": "手势 %{name}" +"Hold %{chord}": "按住 %{chord}" "Left Click": "左键单击" "Right Click": "右键单击" "Middle Click": "中键单击" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index 65a363c5..9a80cd63 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "中鍵" "DPI Preset %{index}": "靈敏度預設 %{index}" "Gesture %{name}": "手勢 %{name}" +"Hold %{chord}": "按住 %{chord}" "Left Click": "左鍵單擊" "Right Click": "右鍵單擊" "Middle Click": "中鍵單擊" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index 7505b25a..741a247b 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -146,6 +146,7 @@ _version: 1 "Middle": "中鍵" "DPI Preset %{index}": "靈敏度預設 %{index}" "Gesture %{name}": "手勢 %{name}" +"Hold %{chord}": "按住 %{chord}" "Left Click": "左鍵按一下" "Right Click": "右鍵按一下" "Middle Click": "中鍵按一下" diff --git a/crates/openlogi-gui/src/mouse_model/picker.rs b/crates/openlogi-gui/src/mouse_model/picker.rs index cfdec04d..7b2b4aad 100644 --- a/crates/openlogi-gui/src/mouse_model/picker.rs +++ b/crates/openlogi-gui/src/mouse_model/picker.rs @@ -339,7 +339,7 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str { Action::ScrollDown => "action-icons/chevrons-down.svg", Action::HorizontalScrollLeft => "action-icons/chevrons-left.svg", Action::HorizontalScrollRight => "action-icons/chevrons-right.svg", - Action::CustomShortcut(_) => "action-icons/keyboard.svg", + Action::CustomShortcut(_) | Action::HoldShortcut(_) => "action-icons/keyboard.svg", } } diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index c25801d2..d246be94 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -653,6 +653,7 @@ fn localized_action_label(action: &Action) -> gpui::SharedString { tr!("DPI Preset %{index}", index => (index + 1).to_string()) } Action::CustomShortcut(combo) => combo.rendered_label().into(), + Action::HoldShortcut(combo) => tr!("Hold %{chord}", chord => combo.rendered_label()), _ => tr!(action.label()), } } diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs index 6c5c67be..5c038a28 100644 --- a/crates/openlogi-inject/src/inject.rs +++ b/crates/openlogi-inject/src/inject.rs @@ -6,7 +6,7 @@ //! of which translates an [`Action`] into the native event(s) — CGEvent/NSEvent //! on macOS, uinput/D-Bus on Linux, SendInput on Windows. -use openlogi_core::binding::Action; +use openlogi_core::binding::{Action, KeyCombo}; #[cfg(target_os = "macos")] mod macos; @@ -70,6 +70,59 @@ pub fn execute(action: &Action) { } } +/// Synthesise the key-down half of `combo`, leaving the chord held. +/// +/// The caller **must** pair every `press_hold` with a [`release_hold`] of the +/// same combo — including on abnormal paths (lost button-up, capture +/// interrupted, agent shutdown). A chord left down jams those keys for every +/// app on the system until something releases them. +/// +/// A `key_code` of 0 (the recorder's modifier-only placeholder) is ignored, so +/// a hand-edited config can't leave bare modifiers stuck down. +pub fn press_hold(combo: &KeyCombo) { + hold_phase(combo, true); +} + +/// Synthesise the key-up half of `combo`, releasing a [`press_hold`]. +/// +/// Safe to call without a matching press — a redundant key-up for a key the OS +/// doesn't think is down is a no-op, which is what makes the force-release +/// paths safe to fire unconditionally. +pub fn release_hold(combo: &KeyCombo) { + hold_phase(combo, false); +} + +/// Shared body of [`press_hold`] / [`release_hold`] — `down` picks the edge. +fn hold_phase(combo: &KeyCombo, down: bool) { + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + down, + "hold chord has no key code — ignored" + ); + return; + } + + cfg_select! { + target_os = "macos" => { + macos::post_key_phase(combo.key_code, macos::combo_flags(combo), down); + } + target_os = "linux" => { + linux::key_phase(combo, down); + } + target_os = "windows" => { + windows::key_phase(combo, down); + } + _ => { + let _ = down; + tracing::warn!( + chord = %combo.rendered_label(), + "hold unsupported on this platform" + ); + } + } +} + /// Synthesise a horizontal scroll of `delta` wheel lines at the current focus. /// /// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb diff --git a/crates/openlogi-inject/src/inject/linux.rs b/crates/openlogi-inject/src/inject/linux.rs index 15216e20..7657dc92 100644 --- a/crates/openlogi-inject/src/inject/linux.rs +++ b/crates/openlogi-inject/src/inject/linux.rs @@ -108,6 +108,28 @@ pub(super) fn execute(action: &Action) { }; press_key(&modifiers_to_keycodes(combo.modifiers), key); } + // See the macOS arm: a hold with no release edge degrades to a tap. + Action::HoldShortcut(combo) => { + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + "HoldShortcut with no key code — press ignored" + ); + return; + } + let Some(key) = macos_vk_to_linux(combo.key_code) else { + tracing::warn!( + key_code = combo.key_code, + "HoldShortcut key code has no Linux mapping — press ignored" + ); + return; + }; + tracing::debug!( + chord = %combo.rendered_label(), + "HoldShortcut dispatched without a release edge — tapping instead" + ); + press_key(&modifiers_to_keycodes(combo.modifiers), key); + } } } @@ -236,6 +258,37 @@ fn press_key(mods: &[KeyCode], key: KeyCode) { emit(&up); } +/// Inject one edge of a held chord in a single SYN frame. +/// +/// Down order is modifiers-then-key and up order is key-then-modifiers +/// (the reverse), matching [`press_key`] — a chord whose modifiers lifted +/// before its key would surface to the focused app as a bare keypress. +pub(super) fn key_phase(combo: &openlogi_core::binding::KeyCombo, down: bool) { + let Some(key) = macos_vk_to_linux(combo.key_code) else { + tracing::warn!( + key_code = combo.key_code, + "hold chord key code has no Linux mapping — ignored" + ); + return; + }; + let mods = modifiers_to_keycodes(combo.modifiers); + + let mut events: Vec = Vec::with_capacity(mods.len() + 2); + if down { + for &m in &mods { + events.push(key_ev(m, 1)); + } + events.push(key_ev(key, 1)); + } else { + events.push(key_ev(key, 0)); + for &m in mods.iter().rev() { + events.push(key_ev(m, 0)); + } + } + events.push(syn()); + emit(&events); +} + /// Inject a button-down in one SYN frame and button-up in a second. fn click(button: KeyCode) { emit(&[key_ev(button, 1), syn()]); diff --git a/crates/openlogi-inject/src/inject/macos.rs b/crates/openlogi-inject/src/inject/macos.rs index a492d62a..6e03522c 100644 --- a/crates/openlogi-inject/src/inject/macos.rs +++ b/crates/openlogi-inject/src/inject/macos.rs @@ -7,7 +7,7 @@ use core_graphics::event::{ use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use core_graphics::geometry::CGPoint; -use openlogi_core::binding::Action; +use openlogi_core::binding::{Action, KeyCombo}; // NX_KEYTYPE_* constants from . const NX_KEYTYPE_SOUND_UP: i32 = 0; @@ -34,8 +34,6 @@ const VK_TAB: u16 = 0x30; /// macOS implementation: dispatch to the appropriate event helper. pub(super) fn execute(action: &Action) { - use openlogi_core::binding::KeyCombo; - // Modifier bit shorthands. let cmd = CGEventFlags::CGEventFlagCommand; let shift = CGEventFlags::CGEventFlagShift; @@ -131,20 +129,24 @@ pub(super) fn execute(action: &Action) { ); return; } - let mut flags = CGEventFlags::CGEventFlagNull; - if combo.modifiers & KeyCombo::MOD_CMD != 0 { - flags |= CGEventFlags::CGEventFlagCommand; - } - if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { - flags |= CGEventFlags::CGEventFlagShift; - } - if combo.modifiers & KeyCombo::MOD_CTRL != 0 { - flags |= CGEventFlags::CGEventFlagControl; - } - if combo.modifiers & KeyCombo::MOD_OPTION != 0 { - flags |= CGEventFlags::CGEventFlagAlternate; + post_key(combo.key_code, combo_flags(combo)); + } + // A hold that reaches `execute` came from a dispatch path with no + // release edge (the HID++ gesture button), so it degrades to a tap + // rather than jamming the chord down forever. + Action::HoldShortcut(combo) => { + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + "HoldShortcut with no key code — press ignored" + ); + return; } - post_key(combo.key_code, flags); + tracing::debug!( + chord = %combo.rendered_label(), + "HoldShortcut dispatched without a release edge — tapping instead" + ); + post_key(combo.key_code, combo_flags(combo)); } } } @@ -220,24 +222,45 @@ fn tag_synthetic(ev: &CGEvent) { ); } -/// Post a key-down + key-up pair for `vk` with `flags` set. -fn post_key(vk: u16, flags: CGEventFlags) { +/// Post a single key event for `vk` with `flags` set — `down` picks the edge. +/// +/// Isolated edges are what [`super::press_hold`] and [`super::release_hold`] +/// need; [`post_key`] pairs them for a tap. +pub(super) fn post_key_phase(vk: u16, flags: CGEventFlags, down: bool) { let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { tracing::warn!("CGEventSource::new failed"); return; }; - let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { - tracing::warn!("CGEvent::new_keyboard_event(down) failed"); - return; - }; - down.set_flags(flags); - down.post(CGEventTapLocation::HID); - let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { - tracing::warn!("CGEvent::new_keyboard_event(up) failed"); + let Ok(ev) = CGEvent::new_keyboard_event(src, vk, down) else { + tracing::warn!(down, "CGEvent::new_keyboard_event failed"); return; }; - up.set_flags(flags); - up.post(CGEventTapLocation::HID); + ev.set_flags(flags); + ev.post(CGEventTapLocation::HID); +} + +/// Post a key-down + key-up pair for `vk` with `flags` set. +fn post_key(vk: u16, flags: CGEventFlags) { + post_key_phase(vk, flags, true); + post_key_phase(vk, flags, false); +} + +/// Translate a [`KeyCombo`]'s modifier bitmask into `CGEventFlags`. +pub(super) fn combo_flags(combo: &KeyCombo) -> CGEventFlags { + let mut flags = CGEventFlags::CGEventFlagNull; + if combo.modifiers & KeyCombo::MOD_CMD != 0 { + flags |= CGEventFlags::CGEventFlagCommand; + } + if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { + flags |= CGEventFlags::CGEventFlagShift; + } + if combo.modifiers & KeyCombo::MOD_CTRL != 0 { + flags |= CGEventFlags::CGEventFlagControl; + } + if combo.modifiers & KeyCombo::MOD_OPTION != 0 { + flags |= CGEventFlags::CGEventFlagAlternate; + } + flags } /// Post a media/system key event (play/pause, track navigation, volume). diff --git a/crates/openlogi-inject/src/inject/windows.rs b/crates/openlogi-inject/src/inject/windows.rs index 74c74536..b293077e 100644 --- a/crates/openlogi-inject/src/inject/windows.rs +++ b/crates/openlogi-inject/src/inject/windows.rs @@ -124,6 +124,14 @@ pub(super) fn execute(action: &Action) { | Action::HorizontalScrollLeft | Action::HorizontalScrollRight => post_scroll(action), Action::CustomShortcut(combo) => post_custom_shortcut(combo), + // See the macOS arm: a hold with no release edge degrades to a tap. + Action::HoldShortcut(combo) => { + tracing::debug!( + chord = %combo.rendered_label(), + "HoldShortcut dispatched without a release edge — tapping instead" + ); + post_custom_shortcut(combo); + } Action::None => {} } } @@ -191,6 +199,13 @@ fn post_custom_shortcut(combo: &KeyCombo) { return; }; + post_key(vk, &combo_modifiers(combo)); +} + +/// Translate a [`KeyCombo`]'s modifier bitmask into Windows virtual-key codes. +/// macOS Cmd maps to Ctrl, so a chord carrying both Cmd and Ctrl must not push +/// `VK_CONTROL` twice. +fn combo_modifiers(combo: &KeyCombo) -> Vec { let mut modifiers = Vec::new(); if combo.modifiers & KeyCombo::MOD_CMD != 0 { modifiers.push(VK_CONTROL); @@ -204,7 +219,37 @@ fn post_custom_shortcut(combo: &KeyCombo) { if combo.modifiers & KeyCombo::MOD_OPTION != 0 { modifiers.push(VK_MENU); } - post_key(vk, &modifiers); + modifiers +} + +/// Inject one edge of a held chord in a single `SendInput` batch. +/// +/// Down order is modifiers-then-key and up order is key-then-modifiers +/// (the reverse), matching [`post_key`]. +pub(super) fn key_phase(combo: &KeyCombo, down: bool) { + let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else { + tracing::warn!( + key_code = combo.key_code, + chord = %combo.rendered_label(), + "hold chord key has no Windows mapping yet; ignored" + ); + return; + }; + let modifiers = combo_modifiers(combo); + + let mut inputs = Vec::with_capacity(modifiers.len() + 1); + if down { + for modifier in &modifiers { + inputs.push(key_input(*modifier, false)); + } + inputs.push(key_input(vk, false)); + } else { + inputs.push(key_input(vk, true)); + for modifier in modifiers.iter().rev() { + inputs.push(key_input(*modifier, true)); + } + } + send_inputs(&inputs); } fn send_inputs(inputs: &[INPUT]) { diff --git a/crates/openlogi-inject/src/lib.rs b/crates/openlogi-inject/src/lib.rs index 347b7605..2732d589 100644 --- a/crates/openlogi-inject/src/lib.rs +++ b/crates/openlogi-inject/src/lib.rs @@ -2,7 +2,9 @@ mod inject; -pub use inject::{SYNTHETIC_EVENT_USER_DATA, execute, post_horizontal_scroll}; +pub use inject::{ + SYNTHETIC_EVENT_USER_DATA, execute, post_horizontal_scroll, press_hold, release_hold, +}; #[cfg(target_os = "linux")] pub use inject::action_device_path; From c8e1cb0ef9e4dba86825003dae799007008a6a5a Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 16 Jul 2026 14:25:53 -0700 Subject: [PATCH 2/2] feat(agent): hold push-to-talk chords for the button press duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook already delivered both edges on all three platforms; the single-action path dropped the release with an `if pressed` guard. Wire the release edge up to Action::HoldShortcut: press synthesises the key-down, release the matching key-up. Held chords live in a thread-local keyed by button, matching HOLD's thread-per-device reasoning — press and release for a device arrive on the same thread, so the pair always balances. A stuck key-down jams that key for every app on the system, so the unbalanced paths get the care: - The release resolves from stored state *before* the binding maps are read. A rebind or unbind mid-hold would otherwise resolve to a different action (or none, returning PassThrough early) and strand the chord that actually went down. - A repeated button-down does not stack a second key-down the single release could never balance. - CaptureInterrupted force-releases every held chord: the button-ups are never coming. Extract the button arm into on_button. It was already at the pedantic line limit and the hold paths pushed it over; the callback was doing too much to read. Not runtime-tested on hardware — no Logitech device available here. --- .../openlogi-agent-core/src/hook_runtime.rs | 304 ++++++++++++++---- 1 file changed, 241 insertions(+), 63 deletions(-) diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 27234f1d..61a7d796 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -10,7 +10,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; use openlogi_core::binding::{ - Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, + Action, ButtonId, GestureDirection, KeyCombo, SwipeAccumulator, default_binding, }; use openlogi_hid::CaptureChannel; use openlogi_hook::{EventDisposition, Hook, MouseEvent}; @@ -87,6 +87,41 @@ impl HoldState { } } +/// Chords currently held down by an [`Action::HoldShortcut`] binding, keyed by +/// the physical button holding them — push-to-talk. +/// +/// The combo is **stored on press** rather than re-resolved on release, so a +/// rebind, an unbind, or a per-app profile switch mid-hold still releases the +/// chord that actually went down. Re-resolving would strand it. +#[derive(Default)] +struct HeldChords(BTreeMap); + +impl HeldChords { + /// Record `combo` as held by `button`. Returns the combo to press, or + /// `None` when the button is already holding one — a repeated button-down + /// (key auto-repeat, a re-delivered event) must not stack a second + /// key-down that the single release could never balance. + fn press(&mut self, button: ButtonId, combo: KeyCombo) -> Option { + if self.0.contains_key(&button) { + return None; + } + self.0.insert(button, combo.clone()); + Some(combo) + } + + /// Release `button`'s chord. `None` for a stray release of a button that + /// wasn't holding one. + fn release(&mut self, button: ButtonId) -> Option { + self.0.remove(&button) + } + + /// Drain every held chord — used when the OS interrupts capture and the + /// button-ups are never coming. + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.0).into_values().collect() + } +} + thread_local! { /// In-progress gesture hold, one instance per hook-callback thread: the /// single macOS tap thread, or — on Linux — one thread per device, so two @@ -94,6 +129,109 @@ thread_local! { /// Thread-local rather than a shared `Mutex` keeps the hot path lock-free and /// free of cross-thread contention on the freeze-sensitive callback. static HOLD: RefCell = RefCell::new(HoldState::default()); + + /// Push-to-talk chords held down on this callback thread. Same + /// thread-per-device reasoning as [`HOLD`]: press and release for a given + /// device arrive on the same thread, so the pair always balances. + static HELD_CHORDS: RefCell = RefCell::new(HeldChords::default()); +} + +/// Handle one OS-hook button edge: gesture holds, push-to-talk holds, and +/// single-action dispatch. +/// +/// Every path resolves to owned data and drops the `HOLD` / `HELD_CHORDS` +/// borrow (and any `hooks` read guard) *before* injecting. A synthesized event +/// can re-enter the tap on this same thread, and a re-borrow would be a +/// `RefCell` double-borrow panic — a freeze hazard on the callback thread. +fn on_button( + id: ButtonId, + pressed: bool, + hooks: &SharedHookMaps, + dpi_cycle: &Arc>, + capture: &CaptureChannel, +) -> EventDisposition { + // The CGEventTap only sees standard buttons 0-4. We remap + // Middle/Back/Forward; the primary L/R clicks always pass through + // (suppressing them would brick the mouse), and the DPI / thumb / + // dedicated gesture button aren't visible to the tap at all — the + // dedicated gesture button is captured separately over HID++. + if !id.is_os_hook_button() { + return EventDisposition::PassThrough; + } + + // Release of a push-to-talk chord, resolved from stored state *before* the + // binding maps are consulted: if the binding changed or was removed + // mid-hold, resolution below would yield a different action (or none, + // returning PassThrough early) and the chord would stay down system-wide. + if !pressed { + let held = HELD_CHORDS.with_borrow_mut(|h| h.release(id)); + if let Some(combo) = held { + info!(button = %id, chord = %combo.rendered_label(), "hold released"); + openlogi_inject::release_hold(&combo); + return EventDisposition::Suppress; + } + } + + // Gesture button: suppress the native click and begin a hold. The swipe + // commits mid-motion in the `Moved` arm; here, on release, we only fire the + // plain `Click` when no swipe committed. The cursor is free to drift via + // the pass-through `Moved` events during the hold. + if pressed { + let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id)); + if is_gesture { + HOLD.with_borrow_mut(|h| h.begin(id)); + return EventDisposition::Suppress; + } + } else { + let ended = HOLD.with_borrow_mut(|h| h.end(id)); + if let Some(was_click) = ended { + if was_click { + // No swipe committed → fire the plain click. + let action = hooks + .read() + .ok() + .map(|m| resolve_gesture_click(&m.gestures, id)); + if let Some(action) = action { + info!(button = %id, action = %action.label(), "gesture click → executing bound action"); + dispatch_action(&action, dpi_cycle, capture); + } + } + return EventDisposition::Suppress; + } + } + + // Single-action button. + let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned()); + let Some(action) = action else { + // Unbound → leave the physical button to the OS. + return EventDisposition::PassThrough; + }; + + // A button left on its own native click (e.g. Middle → MiddleClick) should + // just do that click; suppressing and re-synthesising it would be pointless + // churn. + if is_native_click(id, &action) { + return EventDisposition::PassThrough; + } + + // Push-to-talk: hold the chord down for the duration of the press instead + // of tapping it. The release is handled above, from stored state. + if let Some(combo) = action.held_combo() { + if pressed { + let press = HELD_CHORDS.with_borrow_mut(|h| h.press(id, combo.clone())); + if let Some(combo) = press { + info!(button = %id, chord = %combo.rendered_label(), "hold pressed"); + openlogi_inject::press_hold(&combo); + } + } + return EventDisposition::Suppress; + } + + if pressed { + info!(button = %id, action = %action.label(), "button → executing bound action"); + dispatch_action(&action, dpi_cycle, capture); + } + EventDisposition::Suppress } /// Attempt to start the OS hook. Returns `None` if Accessibility is not @@ -121,68 +259,7 @@ pub fn start( monitor.record(&event); match event { MouseEvent::Button { id, pressed } => { - // The CGEventTap only sees standard buttons 0-4. We remap - // Middle/Back/Forward; the primary L/R clicks always pass through - // (suppressing them would brick the mouse), and the DPI / thumb / - // dedicated gesture button aren't visible to the tap at all — the - // dedicated gesture button is captured separately over HID++. - if !id.is_os_hook_button() { - return EventDisposition::PassThrough; - } - - // Gesture button: suppress the native click and begin a hold. The - // swipe commits mid-motion in the `Moved` arm; here, on release, we - // only fire the plain `Click` when no swipe committed. The cursor is - // free to drift via the pass-through `Moved` events during the hold. - if pressed { - let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id)); - if is_gesture { - HOLD.with_borrow_mut(|h| h.begin(id)); - return EventDisposition::Suppress; - } - } else { - // Release: end the hold and release the `HOLD` borrow *before* any - // dispatch — the callback must stay lock-light, since a - // synthesized event could otherwise re-enter the tap and re-borrow - // `HOLD` (a RefCell double-borrow panic, freeze hazard). - let ended = HOLD.with_borrow_mut(|h| h.end(id)); - if let Some(was_click) = ended { - if was_click { - // No swipe committed → fire the plain click. Resolve to an - // owned action (so no lock is held across dispatch), then - // dispatch with the guard already dropped. - let action = hooks - .read() - .ok() - .map(|m| resolve_gesture_click(&m.gestures, id)); - if let Some(action) = action { - info!(button = %id, action = %action.label(), "gesture click → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); - } - } - return EventDisposition::Suppress; - } - } - - // Single-action button. - let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned()); - let Some(action) = action else { - // Unbound → leave the physical button to the OS. - return EventDisposition::PassThrough; - }; - - // A button left on its own native click (e.g. Middle → MiddleClick) - // should just do that click; suppressing and re-synthesising it - // would be pointless churn. - if is_native_click(id, &action) { - return EventDisposition::PassThrough; - } - - if pressed { - info!(button = %id, action = %action.label(), "button → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); - } - EventDisposition::Suppress + on_button(id, pressed, &hooks, &dpi_cycle, &capture) } MouseEvent::Moved { delta_x, delta_y } => { // Feed an in-progress hold; a committed swipe fires here, mid-motion. @@ -215,6 +292,16 @@ pub fn start( // The OS dropped events (tap disabled); cancel any hold so a lost // button-up can't later commit a phantom swipe off ordinary motion. HOLD.with_borrow_mut(HoldState::cancel); + + // The button-ups are never coming, so force-release every held + // chord: a stuck key-down jams that key for every app on the + // system, which no amount of clicking recovers. Drain first, then + // inject with the borrow dropped. + let stranded = HELD_CHORDS.with_borrow_mut(HeldChords::drain); + for combo in stranded { + warn!(chord = %combo.rendered_label(), "capture interrupted mid-hold — force-releasing chord"); + openlogi_inject::release_hold(&combo); + } EventDisposition::PassThrough } MouseEvent::Scroll { .. } => EventDisposition::PassThrough, @@ -395,4 +482,95 @@ mod tests { default_binding(ButtonId::Forward) ); } + + // `HeldChords` is the push-to-talk bookkeeping. Every unbalanced path here + // ends with a chord stuck down system-wide, so the edge cases below matter + // more than the happy path. + + fn combo(key_code: u16) -> KeyCombo { + KeyCombo { + modifiers: KeyCombo::MOD_OPTION, + key_code, + display: String::new(), + } + } + + #[test] + fn press_then_release_returns_the_stored_chord() { + let mut held = HeldChords::default(); + assert_eq!(held.press(ButtonId::Back, combo(49)), Some(combo(49))); + assert_eq!(held.release(ButtonId::Back), Some(combo(49))); + assert_eq!( + held.release(ButtonId::Back), + None, + "a chord is released exactly once" + ); + } + + #[test] + fn repeated_press_does_not_stack_a_second_key_down() { + let mut held = HeldChords::default(); + assert_eq!(held.press(ButtonId::Back, combo(49)), Some(combo(49))); + assert_eq!( + held.press(ButtonId::Back, combo(49)), + None, + "a re-delivered button-down must not press twice — the single \ + release could not balance it" + ); + // The one release still clears the hold entirely. + assert_eq!(held.release(ButtonId::Back), Some(combo(49))); + assert!(held.0.is_empty()); + } + + #[test] + fn release_returns_the_pressed_chord_not_a_rebound_one() { + // A rebind mid-hold must release what actually went down. The map is + // the only record of that — this is why the release path reads stored + // state instead of re-resolving the binding. + let mut held = HeldChords::default(); + held.press(ButtonId::Back, combo(49)); + assert_eq!(held.release(ButtonId::Back), Some(combo(49))); + } + + #[test] + fn stray_release_of_an_unheld_button_is_inert() { + let mut held = HeldChords::default(); + held.press(ButtonId::Back, combo(49)); + assert_eq!( + held.release(ButtonId::Forward), + None, + "releasing a button that holds nothing must not touch another's chord" + ); + assert_eq!(held.release(ButtonId::Back), Some(combo(49))); + } + + #[test] + fn buttons_hold_independent_chords() { + let mut held = HeldChords::default(); + held.press(ButtonId::Back, combo(49)); + held.press(ButtonId::Forward, combo(50)); + assert_eq!(held.release(ButtonId::Forward), Some(combo(50))); + assert_eq!( + held.release(ButtonId::Back), + Some(combo(49)), + "one release must not disturb the other button's hold" + ); + } + + #[test] + fn drain_force_releases_every_chord_and_empties() { + let mut held = HeldChords::default(); + held.press(ButtonId::Back, combo(49)); + held.press(ButtonId::Forward, combo(50)); + + let mut stranded = held.drain(); + stranded.sort_by_key(|c| c.key_code); + assert_eq!(stranded, vec![combo(49), combo(50)]); + assert!(held.0.is_empty(), "drain leaves nothing to strand"); + assert_eq!( + held.release(ButtonId::Back), + None, + "a button-up arriving after the force-release is inert" + ); + } }