diff --git a/README.md b/README.md index 31aee9fe..086b4231 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,10 @@ without a checkout of the toolchain. daemon, reads its answers, subscribes to its notifications, and re-exports the types they are made of. - [`komorebi-hotkeys`](komorebi-hotkeys): reads the key binding files of skhd, - whkd and sxhkd into one model, with the keys as KDE stores them, and plans - each binding as a KDE shortcut file or as a key komorebi registers itself. It - does not depend on the daemon. Its tests read the files in + whkd and sxhkd into one model, with the keys as KDE stores them, plans each + binding as a KDE shortcut file or as a key komorebi registers itself, and has + the engine that decides, with no I/O of its own, which keys komorebi holds + and what each press does. It does not depend on the daemon. Its tests read the files in [`komorebi-hotkeys/tests/fixtures`](komorebi-hotkeys/tests/fixtures), where each case is written in every format that can express it. - [`komorebic`](komorebic): the command line. A subcommand that asks something diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index b735f301..aae46f0f 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -1592,6 +1592,10 @@ daemons: skhd (macOS), whkd (Windows) and sxhkd (X11). for everything a static shortcut cannot be. - **Plan**: the bindings of a keymap sorted into static shortcuts and the engine's keymap, with notes on why each binding goes where it goes. +- **Active set**: the combos the engine holds: KDE swallows an active combo and reports its + presses, and lets every other key reach the focused application. +- **Effect**: what the caller of the engine does after an event: activate or deactivate a combo, + or run a command. ### The parts @@ -1614,6 +1618,12 @@ daemons: skhd (macOS), whkd (Windows) and sxhkd (X11). - [`plan`](../../komorebi-hotkeys/src/plan.rs): sorts a keymap into static shortcuts and the engine's keymap, by mode: `Auto` (static where it can be), `Engine` (the default: everything through the engine) or `Static` (no engine; what needs it is left out with a warning). +- [`engine`](../../komorebi-hotkeys/src/engine.rs): the engine, a state machine with no I/O and + no clock. Its inputs are a press or a release of an active combo, a change of focus, and the + time; after each it computes the active set it wants from the mode, the chord chain being + typed, the focused application and whether it is paused, and returns the commands to run and + the difference with the active set as effects. The daemon carries them out with kglobalacceld + (`setShortcutKeys`, `setInactive`) and feeds it kglobalacceld's signals. - [`kde`](../../komorebi-hotkeys/src/kde.rs): writes static shortcuts as `.desktop` files for kglobalacceld (`X-KDE-Shortcuts`, `X-KDE-GlobalAccel-CommandShortcut`, an `Exec` line quoted as the desktop entry specification requires), and replaces the files it wrote before, and no @@ -1638,6 +1648,11 @@ daemons: skhd (macOS), whkd (Windows) and sxhkd (X11). | A rewrite of the shortcut files removes only the files it generated | test | `rewriting_replaces_our_files_and_nothing_else` | none | | An `Exec` line runs the command as written (quoting, `%%`) | test | `exec_lines_are_quoted_for_the_desktop_entry_spec` | none | | The sample configuration plans into the same 50 shortcut files from each format | test | `the_sample_plans_into_the_same_shortcut_files_in_every_format` | none | +| After every event, the active set is the one the engine wants for its state, and each effect changes it (no combo is activated twice) | framework path: every event handler that changes the state ends in the one reconcile step, which diffs two sets | `a_chain_activates_its_next_keys_until_it_ends`, `a_mode_has_its_own_keys` | none | +| A key the engine does not want reaches the application | convention only: kglobalacceld must be told with `setInactive`, by the daemon | none | none | +| A scoped key is active only for an application a scope gives an action | test | `a_scoped_key_is_active_only_where_it_does_something` | none | +| Stopping the engine gives every key back | test, of the effects; carrying them out is the daemon's | `start_activates_the_default_mode_and_stop_gives_everything_back` | none | +| The engine does no I/O and reads no clock | convention only | none | none | | The whkd reader reads a whkdrc as whkd does | test, on the files it is given | `every_whkdrc_of_the_corpus_reads_as_whkd_reads_it`, `the_example_of_whkds_readme_reads_as_whkd_reads_it` | none | ## Where to look in the code @@ -1820,7 +1835,7 @@ daemons: skhd (macOS), whkd (Windows) and sxhkd (X11). [`whkd.rs`](../../komorebi-hotkeys/src/whkd.rs) and [`sxhkd.rs`](../../komorebi-hotkeys/src/sxhkd.rs); [`plan.rs`](../../komorebi-hotkeys/src/plan.rs) and [`kde.rs`](../../komorebi-hotkeys/src/kde.rs) - for the shortcuts. + for the shortcuts, [`engine.rs`](../../komorebi-hotkeys/src/engine.rs) for the engine. - [`komorebi-hotkeys/tests`](../../komorebi-hotkeys/tests): the corpus test and the comparison with `whkd-parser`. - [`nix/nixos-module.nix`](../../nix/nixos-module.nix): the NixOS module; diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index 21513f9e..613cbe80 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -746,3 +746,26 @@ at the median, while the engine was told of the key 3 ms after it. So the defaul The cost is the robustness of static shortcuts: the engine works only while komorebi runs, and a komorebi process that was killed leaves its keys registered and dead until they are released. + +## Why does the engine grab and give back keys, instead of using KDE's own key sequences? + +Because KDE's do not work for this, as measured on KWin 6.8.1 (2026-10-27). kglobalacceld +accepts a sequence of keys for one action (`Meta+A, B`), but the action never fired: the +second key reached the focused application instead. What does work is grabbing and giving +back single combos: a combo registered with `setShortcutKeys` is swallowed and reported, +also a bare key with no modifier, and after `setInactive` the same key reaches the +application again. Sixty such calls, sent without waiting for the answers, were all answered +in 2.4 ms. + +So the engine realizes everything with one set of active combos. A chord chain is a combo +that, when pressed, makes only the combos that can come next (and Escape) active, until one +is pressed or the chord timeout passes. A mode is a different set. A per-application binding +is in the set only while the focused application has an action for it, so that elsewhere +the key reaches the application. After every event the engine computes the set it wants and +returns the difference. + +A held key sends one pressed signal, then repeated ones, then one released signal. The +engine acts on the pressed signal only, so holding a key runs its binding once. + +The engine itself does no I/O: it returns what to do, and the daemon does it. This entry +needs the reason for that split. diff --git a/komorebi-hotkeys/src/engine.rs b/komorebi-hotkeys/src/engine.rs new file mode 100644 index 00000000..878edcd3 --- /dev/null +++ b/komorebi-hotkeys/src/engine.rs @@ -0,0 +1,746 @@ +//! The engine: the bindings komorebi realizes itself, as a state machine with no I/O. +//! +//! The engine is what makes modes, chord chains, release bindings, per-application bindings, a +//! blacklist and a pause binding work on KDE, where a shortcut is a key KDE's global shortcut +//! daemon, kglobalacceld, either grabs or does not. The engine owns a set of keys, its *active* +//! set: KDE swallows an active key and says that it was pressed, and lets any other key reach the +//! focused application. After every event the engine computes the set it wants from its state +//! (the mode, the chord being typed, the focused application, whether it is paused) and returns +//! the difference as [`Effect`]s, with the commands to run. +//! +//! The engine does no I/O and reads no clock: the caller feeds it events and the time, and carries +//! out the effects. With kglobalacceld that is: +//! +//! - [`Effect::Activate`]: `setShortcutKeys(action, [combo], 6)` for the action of the combo, +//! after `doRegister` the first time, so that the key is grabbed; +//! - [`Effect::Deactivate`]: `setInactive(action)`, so that the key reaches the application; +//! - [`Effect::Komorebic`] and [`Effect::Shell`]: the command, `komorebic`'s in-process and any +//! other through `sh -c`; +//! - `globalShortcutPressed` and `globalShortcutReleased` of an action become +//! [`Engine::on_pressed`] and [`Engine::on_released`] with its combo. A key held down also sends +//! `globalShortcutRepeated`, which the engine has no input for, so a held key runs its binding +//! once. +//! +//! ```rust +//! use std::time::Instant; +//! use komorebi_hotkeys::{Format, ParseOptions, parse}; +//! use komorebi_hotkeys::engine::{Effect, Engine, EngineOptions}; +//! use komorebi_hotkeys::qt::{Combo, Key, Mods, key_to_qt}; +//! +//! let text = "alt - r ; resize\n:: resize\nresize < h : komorebic resize-edge left increase\n\ +//! resize < escape ; default\n"; +//! let keymap = parse(Format::Skhd, text, &ParseOptions::default()).keymap; +//! let key = |name| key_to_qt(Format::Skhd, name).unwrap(); +//! let alt_r = Combo::new(Mods::ALT, key("r")); +//! let h = Combo::new(Mods::NONE, key("h")); +//! let escape = Combo::new(Mods::NONE, Key::ESCAPE); +//! +//! let mut engine = Engine::new(keymap, EngineOptions::default()); +//! assert_eq!(engine.start(), [Effect::Activate(alt_r)]); +//! +//! // alt + r enters the mode: its keys are grabbed, the default mode's given back +//! let now = Instant::now(); +//! assert_eq!( +//! engine.on_pressed(alt_r, now), +//! [Effect::Deactivate(alt_r), Effect::Activate(h), Effect::Activate(escape)], +//! ); +//! assert_eq!( +//! engine.on_pressed(h, now), +//! [Effect::Komorebic("resize-edge left increase".into())], +//! ); +//! assert_eq!(engine.on_pressed(escape, now).len(), 3); +//! assert_eq!(engine.mode(), "default"); +//! ``` +//! +//! # What the engine does +//! +//! - **Modes**: a [`Run::Mode`] switches the mode, runs its [`Mode::on_enter`] command, and makes +//! the new mode's keys the active ones. A capturing mode does not capture: only its own keys are +//! active. +//! - **Chord chains**: the first combo of a chain is an active key. When it is pressed, only the +//! combos that can come next, and Escape, are active, until one of them is pressed or the chord +//! timeout ([`EngineOptions::chord_timeout`]) passes, which [`Engine::on_tick`] checks. Escape, +//! unless a chain is bound to it, abandons the chain. A key that is not the next of the chain is +//! not active, so it reaches the application and does not end the chain. +//! - **Per-application bindings**: a scoped binding's key is active if and only if a [`Scope`] +//! resolves for the focused application ([`Engine::on_focus`]) to an action. A [`Then::Ignore`], +//! or an application no scope names when there is no [`App::Other`] scope, leaves the key +//! inactive, so that it reaches the application. +//! - **The blacklist**: while a blacklisted application has the focus, no key is active but the +//! pause binding's. +//! - **The pause binding**: pressing it turns every other key off, or on again, and runs its hook. +//! - **Release bindings**: pressing the key does nothing; [`Engine::on_released`] runs the action. +//! +//! An application is matched by name, without regard to case, against the executable, the +//! `app_id` and the desktop entry of the focused window ([`Focus`]), and against the aliases +//! [`EngineOptions::app_aliases`] gives the name. +//! +//! [`App::Other`]: crate::App::Other +//! [`Mode::on_enter`]: crate::Mode::on_enter +//! [`Run::Mode`]: crate::Run::Mode +//! [`Scope`]: crate::Scope +//! [`Then::Ignore`]: crate::Then::Ignore + +use crate::App; +use crate::Binding; +use crate::Body; +use crate::DEFAULT_MODE; +use crate::Edge; +use crate::Keymap; +use crate::Mode; +use crate::Run; +use crate::Then; +use crate::qt::Combo; +use crate::qt::Key; +use crate::qt::Mods; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::time::Duration; +use std::time::Instant; + +/// Options for an [`Engine`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EngineOptions { + /// How long the engine waits for the next combo of a chord chain before it abandons the chain. + /// + /// The default for this is 3 seconds. + pub chord_timeout: Duration, + /// More names for an application, by the name a file gives it: `"firefox"` might also be + /// `["org.mozilla.firefox", "librewolf"]`. The names are matched without regard to case. + /// + /// The default for this is no aliases. + pub app_aliases: BTreeMap>, +} + +impl Default for EngineOptions { + fn default() -> EngineOptions { + EngineOptions { + chord_timeout: Duration::from_millis(3000), + app_aliases: BTreeMap::new(), + } + } +} + +/// The window that has the focus, as the names an application can be matched by. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Focus { + /// The file name of the executable (`firefox`). + pub exe: String, + /// The Wayland `app_id`, or the X11 class (`org.mozilla.firefox`). + pub app_id: String, + /// The name of the desktop entry (`firefox`). + pub desktop_entry: String, +} + +/// What the caller of an [`Engine`] does after an event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Effect { + /// Grab the combo: its presses come to the engine, and the application does not get it. + Activate(Combo), + /// Give the combo back: it reaches the focused application. + Deactivate(Combo), + /// Run `komorebic` with these arguments, as written after `komorebic `. + Komorebic(String), + /// Run this command through `sh -c`. + Shell(String), +} + +/// A chord chain being typed: the combos so far, and when the last was pressed. +#[derive(Clone, Debug)] +struct Pending { + prefix: Vec, + since: Instant, +} + +/// The bindings of a keymap, as a state machine. +/// +/// An engine is made from the keymap of a [`Plan`](crate::plan::Plan)'s engine and its options, +/// then [`started`](Engine::start); each event returns the [`Effect`]s to carry out, in order: +/// the commands to run first, then the combos to deactivate, then the combos to activate. It is +/// guaranteed that after the effects are carried out, the active combos are exactly the ones the +/// engine wants for its state, and that a combo is activated only when it is not active, and +/// deactivated only when it is. +/// +/// The [module documentation](self) describes what the engine does. +#[derive(Debug)] +pub struct Engine { + keymap: Keymap, + options: EngineOptions, + mode: usize, + pending: Option, + focus: Option, + paused: bool, + active: BTreeSet, + /// Release bindings whose key is down, and what each runs when it is released. + held: Vec<(Combo, Run)>, +} + +impl Engine { + /// Returns an engine in the default mode, with no active combo and no focused application. + pub fn new(keymap: Keymap, options: EngineOptions) -> Engine { + let mode = keymap + .modes + .iter() + .position(|mode| mode.name == DEFAULT_MODE) + .unwrap_or_default(); + Engine { + keymap, + options, + mode, + pending: None, + focus: None, + paused: false, + active: BTreeSet::new(), + held: Vec::new(), + } + } + + /// Returns the effects that activate the combos of the default mode. + pub fn start(&mut self) -> Vec { + let mut effects = Vec::new(); + self.reconcile(&mut effects); + effects + } + + /// Returns the effects that deactivate every active combo, for when the engine stops: a combo + /// left active stays grabbed by KDE, and dead, after its process is gone. + pub fn stop(&mut self) -> Vec { + let effects = self + .active + .iter() + .map(|&combo| Effect::Deactivate(combo)) + .collect(); + self.active.clear(); + effects + } + + /// Returns the name of the current mode. + pub fn mode(&self) -> &str { + self.keymap + .modes + .get(self.mode) + .map_or(DEFAULT_MODE, |mode| mode.name.as_str()) + } + + /// Returns the combos that are active, in order. + pub fn active(&self) -> impl Iterator + '_ { + self.active.iter().copied() + } + + /// Handles the press of an active combo, at `now`. + /// + /// A combo that is not active (KDE should not report one) does nothing but put the active set + /// right. + pub fn on_pressed(&mut self, combo: Combo, now: Instant) -> Vec { + let mut effects = Vec::new(); + + if let Some(pause) = &self.keymap.pause + && pause.combo == combo + { + self.paused = !self.paused; + self.pending = None; + if let Some(hook) = pause.hook.clone() { + effects.push(command(&hook)); + } + self.reconcile(&mut effects); + return effects; + } + if !self.active.contains(&combo) { + self.reconcile(&mut effects); + return effects; + } + + let prefix = self.pending.take().map(|p| p.prefix).unwrap_or_default(); + let at = prefix.len(); + let candidates: Vec<(usize, Run, Edge)> = self + .current() + .bindings + .iter() + .filter(|b| b.chord.len() > at && b.chord[..at] == prefix[..] && b.chord[at] == combo) + .filter_map(|b| Some((b.chord.len(), self.resolve(b)?.clone(), b.edge))) + .collect(); + + if let Some((_, run, edge)) = candidates.iter().find(|(len, _, _)| *len == at + 1) { + match edge { + Edge::Press => self.run(run.clone(), &mut effects), + Edge::Release => self.held.push((combo, run.clone())), + } + } else if !candidates.is_empty() { + let mut prefix = prefix; + prefix.push(combo); + self.pending = Some(Pending { prefix, since: now }); + } + // anything else, Escape included, abandons the chain + self.reconcile(&mut effects); + effects + } + + /// Handles the release of an active combo: runs the release binding whose key it is, if its + /// press was handled. + pub fn on_released(&mut self, combo: Combo) -> Vec { + let mut effects = Vec::new(); + if let Some(index) = self.held.iter().position(|(held, _)| *held == combo) { + let (_, run) = self.held.remove(index); + self.run(run, &mut effects); + self.reconcile(&mut effects); + } + effects + } + + /// Handles a change of the focused window; `None` is no window. + pub fn on_focus(&mut self, focus: Option) -> Vec { + self.focus = focus; + let mut effects = Vec::new(); + self.reconcile(&mut effects); + effects + } + + /// Handles the passing of time: abandons a chord chain whose last combo was pressed + /// [`EngineOptions::chord_timeout`] or longer before `now`. + pub fn on_tick(&mut self, now: Instant) -> Vec { + let mut effects = Vec::new(); + if let Some(pending) = &self.pending + && now.saturating_duration_since(pending.since) >= self.options.chord_timeout + { + self.pending = None; + self.reconcile(&mut effects); + } + effects + } + + fn current(&self) -> &Mode { + &self.keymap.modes[self.mode] + } + + /// Runs an action: a command, or a switch to a mode. + fn run(&mut self, run: Run, effects: &mut Vec) { + match run { + Run::Command(text) => effects.push(command(&text)), + Run::Mode(name) => { + if let Some(index) = self.keymap.modes.iter().position(|m| m.name == name) { + self.mode = index; + self.pending = None; + if let Some(on_enter) = &self.keymap.modes[index].on_enter { + effects.push(command(on_enter)); + } + } + } + } + } + + /// What a binding does for the focused application, if anything. + fn resolve<'a>(&self, binding: &'a Binding) -> Option<&'a Run> { + match &binding.body { + Body::Run(run) => Some(run), + Body::Scoped(scopes) => { + let named = scopes.iter().find(|scope| match &scope.app { + App::Named(name) => self.focused(name), + App::Other => false, + }); + let scope = named.or_else(|| scopes.iter().find(|s| s.app == App::Other))?; + match &scope.then { + Then::Run(run) => Some(run), + Then::Ignore => None, + } + } + } + } + + /// Whether the focused window is the application with this name. + fn focused(&self, name: &str) -> bool { + let Some(focus) = &self.focus else { + return false; + }; + let aliases = self + .options + .app_aliases + .iter() + .filter(|(key, _)| key.eq_ignore_ascii_case(name)) + .flat_map(|(_, aliases)| aliases.iter().map(String::as_str)); + std::iter::once(name).chain(aliases).any(|name| { + [&focus.exe, &focus.app_id, &focus.desktop_entry] + .iter() + .any(|candidate| !candidate.is_empty() && candidate.eq_ignore_ascii_case(name)) + }) + } + + /// The combos the engine wants active in its state. + fn wanted(&self) -> BTreeSet { + let mut wanted = BTreeSet::new(); + if let Some(pause) = &self.keymap.pause { + wanted.insert(pause.combo); + } + let blacklisted = self.keymap.blacklist.iter().any(|app| self.focused(app)); + if self.paused || blacklisted { + return wanted; + } + + let prefix = self + .pending + .as_ref() + .map(|p| p.prefix.as_slice()) + .unwrap_or_default(); + let at = prefix.len(); + for binding in &self.current().bindings { + if binding.chord.len() > at + && binding.chord[..at] == *prefix + && self.resolve(binding).is_some() + { + wanted.insert(binding.chord[at]); + } + } + if at > 0 { + wanted.insert(Combo::new(Mods::NONE, Key::ESCAPE)); + } + wanted.extend(self.held.iter().map(|(combo, _)| *combo)); + wanted + } + + /// Makes the active set the wanted one, and says how. + fn reconcile(&mut self, effects: &mut Vec) { + let wanted = self.wanted(); + effects.extend( + self.active + .difference(&wanted) + .map(|&c| Effect::Deactivate(c)), + ); + effects.extend( + wanted + .difference(&self.active) + .map(|&c| Effect::Activate(c)), + ); + self.active = wanted; + } +} + +/// A command as an effect: `komorebic`'s in-process, anything else through a shell. +fn command(text: &str) -> Effect { + match text.strip_prefix("komorebic ") { + Some(arguments) => Effect::Komorebic(arguments.trim().to_string()), + None => Effect::Shell(text.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Format; + use crate::ParseOptions; + use crate::parse; + use crate::qt::key_to_qt; + + fn engine(format: Format, text: &str) -> Engine { + let parsed = parse(format, text, &ParseOptions::default()); + assert!( + parsed + .diagnostics + .iter() + .all(|d| d.severity != crate::Severity::Error), + "{:?}", + parsed.diagnostics + ); + let mut engine = Engine::new(parsed.keymap, EngineOptions::default()); + engine.start(); + engine + } + + fn combo(mods: Mods, name: &str) -> Combo { + Combo::new(mods, key_to_qt(Format::Skhd, name).unwrap()) + } + + fn active(engine: &Engine) -> Vec { + engine.active().map(|c| c.to_string()).collect() + } + + fn focus(name: &str) -> Option { + Some(Focus { + exe: name.to_string(), + ..Focus::default() + }) + } + + #[test] + fn start_activates_the_default_mode_and_stop_gives_everything_back() { + let mut engine = Engine::new( + parse( + Format::Skhd, + "alt - h : komorebic focus left\nalt - l : echo right\n", + &ParseOptions::default(), + ) + .keymap, + EngineOptions::default(), + ); + assert_eq!( + engine.start(), + [ + Effect::Activate(combo(Mods::ALT, "h")), + Effect::Activate(combo(Mods::ALT, "l")) + ] + ); + assert!(engine.start().is_empty(), "nothing changed"); + assert_eq!(engine.stop().len(), 2); + assert_eq!(engine.active().count(), 0); + } + + #[test] + fn a_press_runs_the_command() { + let mut engine = engine( + Format::Skhd, + "alt - h : komorebic focus left\nalt - l : echo right\n", + ); + let now = Instant::now(); + assert_eq!( + engine.on_pressed(combo(Mods::ALT, "h"), now), + [Effect::Komorebic("focus left".into())] + ); + assert_eq!( + engine.on_pressed(combo(Mods::ALT, "l"), now), + [Effect::Shell("echo right".into())] + ); + // the release of a press binding does nothing + assert!(engine.on_released(combo(Mods::ALT, "h")).is_empty()); + } + + #[test] + fn a_mode_has_its_own_keys() { + let mut engine = engine( + Format::Skhd, + ":: resize @ : notify-send resize\nalt - r ; resize\nresize < h : komorebic resize-edge left increase\n\ + resize < escape ; default\ndefault, resize < alt - q : komorebic close\n", + ); + let now = Instant::now(); + assert_eq!(active(&engine), ["Alt+Q", "Alt+R"]); + let effects = engine.on_pressed(combo(Mods::ALT, "r"), now); + assert_eq!(effects[0], Effect::Shell("notify-send resize".into())); + assert_eq!(engine.mode(), "resize"); + assert_eq!(active(&engine), ["H", "Esc", "Alt+Q"]); + assert_eq!( + engine.on_pressed(combo(Mods::NONE, "h"), now), + [Effect::Komorebic("resize-edge left increase".into())] + ); + engine.on_pressed(Combo::new(Mods::NONE, Key::ESCAPE), now); + assert_eq!(engine.mode(), "default"); + assert_eq!(active(&engine), ["Alt+Q", "Alt+R"]); + } + + #[test] + fn a_chain_activates_its_next_keys_until_it_ends() { + let mut engine = engine( + Format::Sxhkd, + "super + w ; {h,l}\n komorebic move {left,right}\nsuper + q\n komorebic close\n", + ); + let super_w = combo(Mods::META, "w"); + let h = combo(Mods::NONE, "h"); + let escape = Combo::new(Mods::NONE, Key::ESCAPE); + let start = Instant::now(); + assert_eq!(active(&engine), ["Meta+Q", "Meta+W"]); + + // the prefix: only the next keys and Escape are active + let effects = engine.on_pressed(super_w, start); + assert!( + !effects + .iter() + .any(|e| matches!(e, Effect::Komorebic(_) | Effect::Shell(_))) + ); + assert_eq!(active(&engine), ["H", "L", "Esc"]); + // the second key runs the binding and puts the default keys back + assert_eq!( + engine.on_pressed(h, start), + [ + Effect::Komorebic("move left".into()), + Effect::Deactivate(h), + Effect::Deactivate(combo(Mods::NONE, "l")), + Effect::Deactivate(escape), + Effect::Activate(combo(Mods::META, "q")), + Effect::Activate(super_w), + ] + ); + + // Escape abandons the chain + engine.on_pressed(super_w, start); + assert!( + engine + .on_pressed(escape, start) + .iter() + .all(|e| matches!(e, Effect::Activate(_) | Effect::Deactivate(_))) + ); + assert_eq!(active(&engine), ["Meta+Q", "Meta+W"]); + + // and so does the timeout, but not before + engine.on_pressed(super_w, start); + assert!( + engine + .on_tick(start + Duration::from_millis(2999)) + .is_empty() + ); + assert!( + !engine + .on_tick(start + Duration::from_millis(3000)) + .is_empty() + ); + assert_eq!(active(&engine), ["Meta+Q", "Meta+W"]); + } + + #[test] + fn a_chain_that_stays_open_is_left_with_escape() { + let mut engine = engine( + Format::Sxhkd, + "alt + r : {h,l}\n komorebic resize-edge {left,right} increase\n", + ); + let now = Instant::now(); + engine.on_pressed(combo(Mods::ALT, "r"), now); + assert_eq!(engine.mode(), "Alt+R"); + for _ in 0..3 { + assert_eq!( + engine.on_pressed(combo(Mods::NONE, "l"), now), + [Effect::Komorebic("resize-edge right increase".into())] + ); + } + engine.on_pressed(Combo::new(Mods::NONE, Key::ESCAPE), now); + assert_eq!(engine.mode(), "default"); + } + + #[test] + fn a_scoped_key_is_active_only_where_it_does_something() { + let mut engine = engine( + Format::Whkd, + "alt + n [\n Default : komorebic focus-workspace 0\n Firefox : komorebic focus-workspace 1\n kitty : Ignore\n]\n\ + alt + m [\n firefox : komorebic toggle-monocle\n]\n", + ); + let alt_n = combo(Mods::ALT, "n"); + let alt_m = combo(Mods::ALT, "m"); + let now = Instant::now(); + + // no window: only the Default scope applies + assert_eq!(active(&engine), ["Alt+N"]); + assert_eq!( + engine.on_pressed(alt_n, now), + [Effect::Komorebic("focus-workspace 0".into())] + ); + + // the application's own scope wins over Default, wherever Default is listed + assert_eq!(engine.on_focus(focus("firefox")), [Effect::Activate(alt_m)]); + assert_eq!( + engine.on_pressed(alt_n, now), + [Effect::Komorebic("focus-workspace 1".into())] + ); + + // Ignore: the key reaches kitty + assert_eq!( + engine.on_focus(focus("kitty")), + [Effect::Deactivate(alt_m), Effect::Deactivate(alt_n)] + ); + assert_eq!(active(&engine), Vec::::new()); + } + + #[test] + fn applications_are_matched_by_any_name_without_regard_to_case_and_by_alias() { + let parsed = parse( + Format::Skhd, + "alt - m [\n \"Firefox\" : komorebic toggle-monocle\n]\n", + &ParseOptions::default(), + ); + let options = EngineOptions { + app_aliases: BTreeMap::from([("firefox".to_string(), vec!["librewolf".to_string()])]), + ..EngineOptions::default() + }; + let mut engine = Engine::new(parsed.keymap, options); + engine.start(); + for focus in [ + Focus { + exe: "firefox".into(), + ..Focus::default() + }, + Focus { + app_id: "FIREFOX".into(), + ..Focus::default() + }, + Focus { + desktop_entry: "firefox".into(), + ..Focus::default() + }, + Focus { + exe: "librewolf".into(), + ..Focus::default() + }, + ] { + engine.on_focus(Some(focus.clone())); + assert_eq!(active(&engine), ["Alt+M"], "{focus:?}"); + engine.on_focus(None); + assert_eq!(engine.active().count(), 0); + } + engine.on_focus(Some(Focus { + exe: "chromium".into(), + ..Focus::default() + })); + assert_eq!(engine.active().count(), 0); + } + + #[test] + fn the_blacklist_turns_every_key_off() { + let mut engine = engine( + Format::Skhd, + ".blacklist [ \"steam\" ]\nalt - h : komorebic focus left\n", + ); + assert_eq!(active(&engine), ["Alt+H"]); + assert_eq!( + engine.on_focus(focus("Steam")), + [Effect::Deactivate(combo(Mods::ALT, "h"))] + ); + assert_eq!( + engine.on_focus(focus("konsole")), + [Effect::Activate(combo(Mods::ALT, "h"))] + ); + } + + #[test] + fn the_pause_binding_turns_every_other_key_off_and_on() { + let mut engine = engine( + Format::Whkd, + ".pause alt + shift + p\n.pause_hook komorebic toggle-pause\nalt + h : komorebic focus left\n", + ); + let pause = combo(Mods::ALT | Mods::SHIFT, "p"); + let alt_h = combo(Mods::ALT, "h"); + let now = Instant::now(); + assert_eq!(active(&engine), ["Alt+H", "Alt+Shift+P"]); + assert_eq!( + engine.on_pressed(pause, now), + [ + Effect::Komorebic("toggle-pause".into()), + Effect::Deactivate(alt_h) + ] + ); + assert_eq!( + engine.on_pressed(pause, now), + [ + Effect::Komorebic("toggle-pause".into()), + Effect::Activate(alt_h) + ] + ); + } + + #[test] + fn a_release_binding_runs_when_the_key_is_released() { + let mut engine = engine( + Format::Sxhkd, + "super + @space\n komorebic toggle-float\n", + ); + let key = combo(Mods::META, "space"); + assert!(engine.on_pressed(key, Instant::now()).is_empty()); + assert_eq!( + engine.on_released(key), + [Effect::Komorebic("toggle-float".into())] + ); + assert!(engine.on_released(key).is_empty()); + } + + #[test] + fn a_press_of_an_inactive_key_does_nothing() { + let mut engine = engine(Format::Skhd, "alt - h : komorebic focus left\n"); + assert!( + engine + .on_pressed(combo(Mods::ALT, "j"), Instant::now()) + .is_empty() + ); + } +} diff --git a/komorebi-hotkeys/src/lib.rs b/komorebi-hotkeys/src/lib.rs index 9c662307..9cedd262 100644 --- a/komorebi-hotkeys/src/lib.rs +++ b/komorebi-hotkeys/src/lib.rs @@ -114,14 +114,32 @@ //! assert!(!plan.needs_engine()); //! ``` //! -//! # Diagnostics +//! [`engine::Engine`] realizes the rest: it is a state machine, with no I/O, that takes key presses, +//! focus changes and the time, and returns the keys to grab and give back and the commands to run. //! -//! The diagnostics of reading and of planning say everything that is not realized the way the -//! file's own daemon does it. A front end with a strict option fails when any of them is a -//! warning or an error; notes never matter for that. +//! # What is emulated, and what is not +//! +//! Everything the three formats bind keys with is realized, by a static shortcut or by the +//! engine, except what follows. A diagnostic reports each case where the file has it. +//! +//! - **Approximated** ([`Support::Approximated`]): side-specific modifiers (`lalt`, `rwin`...) +//! are the modifier itself, since KDE does not tell the sides apart. A capturing mode (skhd's +//! `@`) lets the keys it has no binding for reach the application. skhd's `->` and sxhkd's `~` +//! do not pass the key on to the application. A key that is not the next of an sxhkd chain +//! does not end the chain: only Escape and the chord timeout do. A release binding (sxhkd's +//! `@`) fires when any key of its combo is released first. `.shell` is not followed: commands +//! run through `sh -c`. +//! - **Unsupported** ([`Support::Unsupported`]): skhd's `fn` modifier, `.load`, `.alias`, key +//! forwarding and key sequences; mouse buttons; keypad keys; the X modifiers KDE has no +//! equivalent for (`hyper`, `meta`, `mode_switch`, `lock`, `mod2`, `mod3`, `mod5`, `any`). +//! +//! The diagnostics of reading and of planning together say everything that is not realized the +//! way the file's own daemon does it. A front end with a strict option fails when any of them is +//! a warning or an error; notes never matter for that. #![deny(missing_docs)] +pub mod engine; mod ir; pub mod kde; pub mod plan; diff --git a/komorebi-hotkeys/tests/corpus.rs b/komorebi-hotkeys/tests/corpus.rs index e3e20bee..1781bc8c 100644 --- a/komorebi-hotkeys/tests/corpus.rs +++ b/komorebi-hotkeys/tests/corpus.rs @@ -17,6 +17,9 @@ use komorebi_hotkeys::Run; use komorebi_hotkeys::Severity; use komorebi_hotkeys::Span; use komorebi_hotkeys::Then; +use komorebi_hotkeys::engine::Effect; +use komorebi_hotkeys::engine::Engine; +use komorebi_hotkeys::engine::EngineOptions; use komorebi_hotkeys::kde::desktop_files; use komorebi_hotkeys::plan::PlanMode; use komorebi_hotkeys::plan::PlanOptions; @@ -386,3 +389,26 @@ fn the_sample_plans_into_the_same_shortcut_files_in_every_format() { assert_eq!(pair[0].1, pair[1].1, "{} and {}", pair[0].0, pair[1].0); } } + +#[test] +fn the_files_of_a_case_start_the_same_engine() { + for (case, extensions) in CASES { + let started: Vec<(&str, Vec)> = extensions + .iter() + .map(|extension| { + let parsed = parse(extension, &read(case, extension)).unwrap(); + let plan = plan(&parsed.keymap, &PlanOptions::default()); + let mut engine = Engine::new(plan.engine, EngineOptions::default()); + (*extension, engine.start()) + }) + .collect(); + assert!(!started[0].1.is_empty(), "{case}"); + for pair in started.windows(2) { + assert_eq!( + pair[0].1, pair[1].1, + "{case}: {} and {}", + pair[0].0, pair[1].0 + ); + } + } +}