AprilNEA/controls-rs

★ 0Forks 0RustGitHub ↗Compare

README

controls

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.

Features

  • 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::EventSink adapter with paired press/release suppression;
  • pure shortcut recorder preserving PhysicalKey and NativeKeyCode;
  • optional serde for stable configuration types.

Minimal use

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.

Safe inputs integration

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.

Trigger semantics

  • Chord: one event-producing key/button plus a bounded set of already-held physical keys/buttons. Extra held inputs are allowed.
  • Modifiers: the default shortcut policy compares Shift, Control, Alt, and Meta exactly while ignoring Caps Lock and Num Lock. exact compares every bit, including lock state; contains requires set bits and permits extras. Left/right fidelity is expressed with physical modifier keys in the chord.
  • Repeat: ignored by default; Allow fires on initial and repeated presses. Releases are never classified as repeats by controls.
  • Device scope: exact DeviceId equality. Event::device == None never 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: CaptureInterrupted clears all pressed/chord/modifier state.

Shortcut recorder

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_controls

The listener example requires the platform permissions and hardware access documented by inputs.

Optional features

  • serde — serializes/deserializes Binding<A>, trigger/chord/layer types, and recorded shortcuts when A satisfies serde's bounds. It forwards inputs/serde. Runtime engine state, locks, and dispatchers are never serialized.

Default features are empty.

Dependency and publication status

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.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

Contributors

AprilNEA

Issues