Skip to main content

a7n_core/
hotkey.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Local safety hotkeys. Time is supplied by the input owner.
4
5use std::time::{Duration, Instant};
6
7const KEYBOARD_PAGE: u32 = 0x0007_0000;
8const MODIFIER_START: u32 = 0xe0;
9const MODIFIER_COUNT: u32 = 8;
10const KEY_COUNT: usize = 256;
11mod keys;
12mod recording;
13mod shake;
14mod trigger;
15
16pub use keys::{key_name, key_usage, trigger_name};
17pub use recording::{recorded_chord, recorded_modifiers, recorded_trigger};
18pub use shake::{
19    SHAKE_DEFAULT, SHAKE_MAX, SHAKE_MIN, SHAKE_MIN_TRAVEL, SHAKE_WINDOW, ShakeDetector,
20};
21pub use trigger::{DoubleTap, HOLD_DEFAULT, HOLD_MAX, HOLD_MIN, TriggerMode};
22
23const DIGIT_ONE: u32 = KEYBOARD_PAGE | 0x1e;
24const DIGIT_NINE: u32 = KEYBOARD_PAGE | 0x26;
25pub const DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(400);
26
27fn modifier_bit(usage: u32) -> Option<u8> {
28    let offset = usage.checked_sub(KEYBOARD_PAGE | MODIFIER_START)?;
29    (offset < MODIFIER_COUNT).then(|| 1 << offset)
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33#[error("{0}")]
34pub struct HotkeyError(String);
35
36/// Either side of each requested modifier matches.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct Mods(u8);
39
40impl Mods {
41    /// Left-side modifier usages in ctrl, shift, alt, meta order.
42    pub(crate) fn usages(self) -> impl DoubleEndedIterator<Item = u32> {
43        (0..MODIFIER_COUNT / 2)
44            .filter(move |bit| self.0 & (1 << bit) != 0)
45            .map(|bit| KEYBOARD_PAGE | (MODIFIER_START + bit))
46    }
47
48    fn token(token: &str) -> Option<Self> {
49        match token {
50            "ctrl" => Some(Self(1)),
51            "shift" => Some(Self(2)),
52            "alt" => Some(Self(4)),
53            "meta" => Some(Self(8)),
54            _ => None,
55        }
56    }
57
58    /// # Errors
59    /// Rejects empty, repeated or unknown modifier tokens.
60    pub fn parse(text: &str) -> Result<Self, HotkeyError> {
61        let mut mods = Self::default();
62        for token in text.split('+') {
63            let token = token.trim().to_ascii_lowercase();
64            let value = Self::token(&token)
65                .ok_or_else(|| HotkeyError(format!("unknown modifier '{token}'")))?;
66            mods.add(value, &token)?;
67        }
68        Ok(mods)
69    }
70
71    fn add(&mut self, other: Self, token: &str) -> Result<(), HotkeyError> {
72        if self.0 & other.0 != 0 {
73            return Err(HotkeyError(format!("repeated modifier '{token}'")));
74        }
75        self.0 |= other.0;
76        Ok(())
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct Chord {
82    pub mods: Mods,
83    pub key: u32,
84}
85
86impl Chord {
87    /// # Errors
88    /// Rejects unknown tokens, repeated modifiers and anything but one key.
89    pub fn parse(text: &str) -> Result<Self, HotkeyError> {
90        let mut mods = Mods::default();
91        let mut key = None;
92        for token in text.split('+') {
93            let token = token.trim().to_ascii_lowercase();
94            if let Some(value) = Mods::token(&token) {
95                mods.add(value, &token)?;
96            } else {
97                let usage = key_usage(&token)
98                    .filter(|usage| {
99                        *usage >> 16 == KEYBOARD_PAGE >> 16 && modifier_bit(*usage).is_none()
100                    })
101                    .ok_or_else(|| HotkeyError(format!("unknown key '{token}'")))?;
102                if key.replace(usage).is_some() {
103                    return Err(HotkeyError(format!("multiple keys at '{token}'")));
104                }
105            }
106        }
107        Ok(Self {
108            mods,
109            key: key.ok_or_else(|| HotkeyError(format!("missing key in {text}")))?,
110        })
111    }
112    /// The one-based jump number when this chord uses the jump modifiers.
113    #[must_use]
114    pub fn jump_target(self, jump_mods: Mods) -> Option<u8> {
115        if self.mods != jump_mods || !(DIGIT_ONE..=DIGIT_NINE).contains(&self.key) {
116            return None;
117        }
118        u8::try_from(self.key - DIGIT_ONE + 1).ok()
119    }
120}
121
122#[derive(Debug, Clone)]
123pub struct Bindings {
124    pub double_tap: DoubleTap,
125    pub trigger_mode: TriggerMode,
126    pub hold: Duration,
127    pub home: Vec<Chord>,
128    pub lock: Chord,
129    pub jump_mods: Mods,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum Hotkey {
134    Home,
135    Lock,
136    Jump(u8),
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct Verdict {
141    pub forward: bool,
142    pub action: Option<Hotkey>,
143}
144
145#[derive(Debug)]
146pub struct HotkeyMatcher {
147    bindings: Bindings,
148    held_mods: u8,
149    pressed: [bool; KEY_COUNT],
150    consumed: [bool; KEY_COUNT],
151    first_tap: Option<Instant>,
152    tap_released: bool,
153    armed: Option<(u32, Instant)>,
154}
155
156impl HotkeyMatcher {
157    #[must_use]
158    pub fn new(bindings: Bindings) -> Self {
159        Self {
160            bindings,
161            held_mods: 0,
162            pressed: [false; KEY_COUNT],
163            consumed: [false; KEY_COUNT],
164            first_tap: None,
165            tap_released: false,
166            armed: None,
167        }
168    }
169
170    pub fn on_key(&mut self, usage: u32, down: bool, now: Instant) -> Verdict {
171        let mut verdict = Verdict {
172            forward: true,
173            action: None,
174        };
175        let Some(index) = usage
176            .checked_sub(KEYBOARD_PAGE)
177            .and_then(|key| usize::try_from(key).ok())
178            .filter(|key| *key < KEY_COUNT)
179        else {
180            if down {
181                self.first_tap = None;
182                self.armed = None;
183            }
184            return verdict;
185        };
186        let was_down = std::mem::replace(&mut self.pressed[index], down);
187        let modifier = modifier_bit(usage);
188        if let Some(bit) = modifier {
189            if down {
190                self.held_mods |= bit;
191            } else {
192                self.held_mods &= !bit;
193            }
194        }
195        verdict.action = self.trigger(usage, down, was_down, now);
196        if modifier.is_some() {
197            return verdict;
198        }
199        if self.consumed[index] {
200            self.consumed[index] = down;
201            verdict.forward = false;
202        } else if down && !was_down {
203            verdict.action = verdict.action.or_else(|| self.chord(usage));
204            verdict.forward = verdict.action.is_none();
205            self.consumed[index] = !verdict.forward;
206        }
207        verdict
208    }
209
210    #[must_use]
211    pub fn next_deadline(&self) -> Option<Instant> {
212        self.armed.map(|(_, deadline)| deadline)
213    }
214
215    pub fn poll(&mut self, now: Instant) -> Option<Hotkey> {
216        if self.next_deadline().is_none_or(|deadline| now < deadline) {
217            return None;
218        }
219        if let Some((usage, _)) = self.armed.take() {
220            if let Ok(index) = usize::try_from(usage - KEYBOARD_PAGE) {
221                if let Some(consumed) = self.consumed.get_mut(index) {
222                    *consumed = true;
223                }
224            }
225        }
226        self.first_tap = None;
227        Some(Hotkey::Home)
228    }
229
230    fn trigger(&mut self, usage: u32, down: bool, was_down: bool, now: Instant) -> Option<Hotkey> {
231        if !self.bindings.double_tap.matches(usage) {
232            if down {
233                self.first_tap = None;
234                self.armed = None;
235            }
236            return None;
237        }
238        if !down && self.armed.is_some_and(|(key, _)| key == usage) {
239            self.armed = None;
240        }
241        self.arm_hold(usage, down, was_down, now);
242        if self.bindings.trigger_mode == TriggerMode::Hold {
243            return None;
244        }
245        if !down {
246            self.tap_released = true;
247            return None;
248        }
249        if was_down {
250            return None;
251        }
252        let matched = self.tap_released
253            && self.first_tap.is_some_and(|first| {
254                now.checked_duration_since(first)
255                    .is_some_and(|elapsed| elapsed <= DOUBLE_TAP_WINDOW)
256            });
257        if matched {
258            self.armed = None;
259        }
260        self.first_tap = if matched { None } else { Some(now) };
261        self.tap_released = false;
262        matched.then_some(Hotkey::Home)
263    }
264
265    fn arm_hold(&mut self, usage: u32, down: bool, was_down: bool, now: Instant) {
266        if !down || was_down || self.bindings.trigger_mode == TriggerMode::DoubleTap {
267            return;
268        }
269        let other_held = self.pressed.iter().enumerate().any(|(index, held)| {
270            *held && u32::try_from(index).is_ok_and(|key| KEYBOARD_PAGE | key != usage)
271        });
272        self.armed = if other_held {
273            None
274        } else {
275            now.checked_add(self.bindings.hold)
276                .map(|deadline| (usage, deadline))
277        };
278    }
279
280    fn chord(&self, usage: u32) -> Option<Hotkey> {
281        let mods = Mods((self.held_mods & 0x0f) | (self.held_mods >> 4));
282        let chord = Chord { mods, key: usage };
283        if self.bindings.home.contains(&chord) {
284            return Some(Hotkey::Home);
285        }
286        if self.bindings.lock == chord {
287            return Some(Hotkey::Lock);
288        }
289        chord.jump_target(self.bindings.jump_mods).map(Hotkey::Jump)
290    }
291}
292
293#[cfg(test)]
294mod tests;