Deterministic, platform-independent control bindings and state machines for
inputs.
inputs captures physical input and decides whether an OS event passes or is
suppressed. controls sits directly above it:
physical events → inputs → controls → application-defined actions
The dependency direction is one-way: inputs ← controls ← applications.
controls contains no platform FFI, permission or device enumeration, foreground
application detection, input injection, GUI, configuration-file management, or
application-specific action enum.
- synchronous deterministic
Engine<A>over&inputs::Event; - physical key, mouse-button, and mixed chords using the types from
inputs; - press/release, repeat, shortcut/exact/contains modifiers, and exact optional device scope;
- compiled immutable bindings with typed same-layer conflict errors;
- permanent base layer plus an explicitly ordered active-layer stack;
- fail-open
inputs::EventSinkadapter with paired press/release suppression; - pure shortcut recorder preserving
PhysicalKeyandNativeKeyCode; - optional
serdefor stable configuration types.
use controls::{Binding, Bindings, Chord, Engine, Layer, ModifierMatch, Trigger};
use inputs::{
Event, EventDisposition, EventKind, KeyCode, KeyEvent, Modifiers, PhysicalKey,
PressState,
};
#[derive(Debug, PartialEq, Eq)]
enum Action {
OpenPalette,
}
let trigger = Trigger::new(Chord::single(KeyCode::KeyK), PressState::Pressed)
.modifiers(ModifierMatch::shortcut(Modifiers {
control: true,
..Modifiers::default()
}));
let base = Layer::new(
"base",
[Binding::dispatch(
trigger,
Action::OpenPalette,
EventDisposition::Suppress,
)],
);
let mut engine = Engine::new(Bindings::compile(base, []).unwrap());
let event = Event::new(
EventKind::Key(KeyEvent {
physical: PhysicalKey::Code(KeyCode::KeyK),
logical: None,
state: PressState::Pressed,
repeat: false,
modifiers: Modifiers {
control: true,
..Modifiers::default()
},
}),
None,
false,
);
let outcome = engine.process(&event);
assert_eq!(outcome.action(), Some(&Action::OpenPalette));
assert_eq!(outcome.disposition(), EventDisposition::Suppress);An Outcome only recommends suppression. If it carries an action, do not return
Suppress to an OS hook until that action has been accepted by a nonblocking
queue. ControlSink enforces this invariant and pairs key/button suppression so
a passed press cannot be followed by a suppressed release.
use std::sync::{Arc, mpsc::sync_channel};
use controls::{Binding, Bindings, Chord, ControlSink, Engine, Layer, Trigger};
use inputs::{EventDisposition, EventSink, KeyCode, Listener, PressState};
#[derive(Debug)]
enum Action {
OpenPalette,
}
let trigger = Trigger::new(Chord::single(KeyCode::F8), PressState::Pressed);
let table = Bindings::compile(
Layer::new(
"base",
[Binding::dispatch(
trigger,
Action::OpenPalette,
EventDisposition::Suppress,
)],
),
[],
).unwrap();
// SyncSender::try_send is bounded and nonblocking. The receiver owns action
// execution on another thread; the OS callback never runs user code.
let (action_tx, action_rx) = sync_channel::<Arc<Action>>(64);
let control = Arc::new(ControlSink::new(Engine::new(table), action_tx));
let worker = std::thread::spawn(move || {
while let Ok(action) = action_rx.recv() {
println!("execute outside callback: {action:?}");
}
});
let event_sink: Arc<dyn EventSink> = control.clone();
let listener = Listener::builder().start_arc(event_sink).unwrap();
// Run the application. Layer changes can use control.try_engine().
listener.stop();
drop(control);
worker.join().unwrap();The adapter uses try_lock; contention, poison, a full/disconnected queue,
dispatcher rejection, or dispatcher panic passes the affected event. Only an
actually suppressed non-repeat press can authorize suppression of its matching
release. If no release binding remains (for example after a layer switch), an
owned release is suppressed automatically. A matched release action still
fails open to Pass if dispatch fails, rather than swallowing the event and
losing its action. If an event is missed under contention, pressed and
suppression-ownership state are cleared before the next event. A stable-toolchain
release benchmark for the allocation-free Engine path lives in
benches/engine.rs; dispatcher implementations must still obey the documented
nonblocking contract and the hard 200 ms macOS inputs watchdog budget.
- Chord: one event-producing key/button plus a bounded set of already-held physical keys/buttons. Extra held inputs are allowed.
- Modifiers: the default
shortcutpolicy compares Shift, Control, Alt, and Meta exactly while ignoring Caps Lock and Num Lock.exactcompares every bit, including lock state;containsrequires set bits and permits extras. Left/right fidelity is expressed with physical modifier keys in the chord. - Repeat: ignored by default;
Allowfires on initial and repeated presses. Releases are never classified as repeats by controls. - Device scope: exact
DeviceIdequality.Event::device == Nonenever matches a scoped binding. Current Windows hooks do not attribute devices, so per-device bindings do not match there. - Injected input: foreign injected events always pass and never mutate state.
- Interruption:
CaptureInterruptedclears all pressed/chord/modifier state.
ShortcutRecorder preserves the physical left/right modifier keys it observes.
If recording begins while a momentary modifier is already held, the first
ordinary key exposes only the aggregate bit, not its physical side. The recorder
therefore enters WaitingForRelease and resumes only after Shift, Control, Alt,
and Meta are all clear; it never emits a plausible-looking shortcut with a
missing side. Lock state does not require a physical side.
See docs/DESIGN.md for conflict, layer, recorder, and
extension-boundary details. Runnable examples:
cargo run --example chord_matching
cargo run --example layer_switching
cargo run --example shortcut_recording
cargo run --example listener_controlsThe listener example requires the platform permissions and hardware access
documented by inputs.
serde— serializes/deserializesBinding<A>, trigger/chord/layer types, and recorded shortcuts whenAsatisfies serde's bounds. It forwardsinputs/serde. Runtime engine state, locks, and dispatchers are never serialized.
Default features are empty.
inputs 0.1.1 is published on crates.io, and controls uses it as the minimum
registry version:
inputs = "0.1.1"cargo package --locked is part of CI. Releases are published from the matching
v{version} Git tag.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.