kbd_global/manager.rs
1//! [`HotkeyManager`] — the public API entry point.
2//!
3//! Thin. Sends commands to the engine, returns handles. Does not own
4//! mutable state — the engine owns everything.
5//!
6//! # Architecture
7//!
8//! The manager holds a command channel sender and a wake mechanism.
9//! Every public method translates to a `Command` sent to the engine.
10//! Operations that can fail (register, `define_layer`) use a reply
11//! channel to return `Result` synchronously to the caller.
12//!
13//! ```text
14//! HotkeyManager::register()
15//! -> send Command::Register { binding, reply }
16//! -> engine processes the command
17//! -> engine sends Result back on reply
18//! -> manager returns BindingGuard or error
19//! ```
20
21use std::fmt;
22use std::path::PathBuf;
23use std::sync::Mutex;
24use std::sync::mpsc;
25
26use kbd::action::Action;
27use kbd::binding::Binding;
28use kbd::binding::BindingId;
29use kbd::binding::BindingOptions;
30use kbd::hotkey::HotkeyInput;
31use kbd::hotkey::ModifierSet;
32use kbd::introspection::ActiveLayerInfo;
33use kbd::introspection::BindingInfo;
34use kbd::introspection::ConflictInfo;
35use kbd::key::Key;
36use kbd::layer::Layer;
37use kbd::layer::LayerName;
38use kbd::sequence::PendingSequenceInfo;
39use kbd::sequence::SequenceInput;
40use kbd::sequence::SequenceOptions;
41
42use crate::backend::Backend;
43use crate::binding_guard::BindingGuard;
44use crate::engine::Command;
45use crate::engine::CommandSender;
46use crate::engine::EngineRuntime;
47use crate::engine::GrabState;
48use crate::error::ManagerStopped;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum BackendSelection {
52 Auto,
53 Explicit(Backend),
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum GrabConfiguration {
58 Disabled,
59 Enabled,
60}
61
62/// Builder for explicit backend and runtime options.
63#[derive(Debug)]
64pub struct HotkeyManagerBuilder {
65 backend: BackendSelection,
66 grab: GrabConfiguration,
67}
68
69impl Default for HotkeyManagerBuilder {
70 fn default() -> Self {
71 Self {
72 backend: BackendSelection::Auto,
73 grab: GrabConfiguration::Disabled,
74 }
75 }
76}
77
78impl HotkeyManagerBuilder {
79 /// Force a specific backend instead of auto-detection.
80 #[must_use]
81 pub fn backend(mut self, backend: Backend) -> Self {
82 self.backend = BackendSelection::Explicit(backend);
83 self
84 }
85
86 /// Enable grab mode for exclusive device capture.
87 #[must_use]
88 pub fn grab(mut self) -> Self {
89 self.grab = GrabConfiguration::Enabled;
90 self
91 }
92
93 /// Build and start a new manager instance.
94 ///
95 /// Spawns the engine thread and begins listening for input device events.
96 ///
97 /// # Errors
98 ///
99 /// Returns an error if the backend cannot be initialized, grab mode
100 /// is requested without the `grab` feature, or the engine thread
101 /// fails to start.
102 pub fn build(self) -> Result<HotkeyManager, crate::error::StartupError> {
103 let backend = resolve_backend(self.backend)?;
104 validate_grab_configuration(backend, self.grab)?;
105
106 let grab_state = create_grab_state(self.grab)?;
107 let runtime = if let Some(input_directory) = internal_test_input_directory() {
108 EngineRuntime::spawn_with_input_dir(grab_state, &input_directory)?
109 } else {
110 EngineRuntime::spawn(grab_state)?
111 };
112 let commands = runtime.commands();
113
114 Ok(HotkeyManager {
115 backend,
116 commands,
117 runtime: Mutex::new(Some(runtime)),
118 })
119 }
120}
121
122/// Public manager API.
123pub struct HotkeyManager {
124 backend: Backend,
125 commands: CommandSender,
126 runtime: Mutex<Option<EngineRuntime>>,
127}
128
129impl fmt::Debug for HotkeyManager {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 let running = self
132 .runtime
133 .lock()
134 .map(|guard| guard.is_some())
135 .unwrap_or(false);
136
137 f.debug_struct("HotkeyManager")
138 .field("backend", &self.backend)
139 .field("running", &running)
140 .finish_non_exhaustive()
141 }
142}
143
144impl HotkeyManager {
145 /// Create a manager with backend auto-detection.
146 ///
147 /// # Errors
148 ///
149 /// Returns an error if the backend cannot be initialized or input
150 /// devices are not accessible.
151 pub fn new() -> Result<Self, crate::error::StartupError> {
152 Self::builder().build()
153 }
154
155 /// Configure manager startup options.
156 #[must_use]
157 pub fn builder() -> HotkeyManagerBuilder {
158 HotkeyManagerBuilder::default()
159 }
160
161 /// Returns the backend this manager is using.
162 #[must_use]
163 pub const fn active_backend(&self) -> Backend {
164 self.backend
165 }
166
167 /// Register a simple hotkey callback.
168 ///
169 /// Accepts any type implementing [`HotkeyInput`]: a [`Hotkey`](kbd::hotkey::Hotkey), a
170 /// [`Key`], or a string (`&str` / `String`).
171 ///
172 /// # Errors
173 ///
174 /// Returns [`RegisterError::Parse`](crate::error::RegisterError::Parse) when
175 /// string input conversion fails,
176 /// [`RegisterError::AlreadyRegistered`](crate::error::RegisterError::AlreadyRegistered)
177 /// if the hotkey is already bound, or
178 /// [`RegisterError::ManagerStopped`](crate::error::RegisterError::ManagerStopped)
179 /// if the engine has shut down.
180 pub fn register<F>(
181 &self,
182 hotkey: impl HotkeyInput,
183 callback: F,
184 ) -> Result<BindingGuard, crate::error::RegisterError>
185 where
186 F: Fn() + Send + Sync + 'static,
187 {
188 self.register_with_options(hotkey, Action::from(callback), BindingOptions::default())
189 }
190
191 /// Register a multi-step sequence callback.
192 ///
193 /// # Errors
194 ///
195 /// Returns [`RegisterError::Parse`](crate::error::RegisterError::Parse) when
196 /// sequence input conversion fails,
197 /// [`RegisterError::AlreadyRegistered`](crate::error::RegisterError::AlreadyRegistered)
198 /// if the sequence is already bound, or
199 /// [`RegisterError::ManagerStopped`](crate::error::RegisterError::ManagerStopped)
200 /// if the engine has shut down.
201 pub fn register_sequence<F>(
202 &self,
203 sequence: impl SequenceInput,
204 callback: F,
205 ) -> Result<BindingGuard, crate::error::RegisterError>
206 where
207 F: Fn() + Send + Sync + 'static,
208 {
209 self.register_sequence_with_options(
210 sequence,
211 Action::from(callback),
212 SequenceOptions::default(),
213 )
214 }
215
216 /// Register a multi-step sequence with explicit action and options.
217 ///
218 /// # Errors
219 ///
220 /// Returns [`RegisterError::Parse`](crate::error::RegisterError::Parse) when
221 /// sequence input conversion fails,
222 /// [`RegisterError::AlreadyRegistered`](crate::error::RegisterError::AlreadyRegistered)
223 /// if the sequence is already bound, or
224 /// [`RegisterError::ManagerStopped`](crate::error::RegisterError::ManagerStopped)
225 /// if the engine has shut down.
226 pub fn register_sequence_with_options(
227 &self,
228 sequence: impl SequenceInput,
229 action: impl Into<Action>,
230 options: SequenceOptions,
231 ) -> Result<BindingGuard, crate::error::RegisterError> {
232 let sequence = sequence.into_sequence()?;
233 let action = action.into();
234 let id = self.request(|reply| Command::RegisterSequence {
235 sequence,
236 action,
237 options,
238 reply,
239 })??;
240 Ok(BindingGuard::new(id, self.commands.clone()))
241 }
242
243 /// Register a hotkey with an explicit action and binding options.
244 ///
245 /// Accepts any type implementing [`HotkeyInput`]: a [`Hotkey`](kbd::hotkey::Hotkey), a
246 /// [`Key`], or a string (`&str` / `String`).
247 ///
248 /// Use when you need metadata (description, overlay visibility) or
249 /// behavioral options beyond what [`register()`](Self::register) provides.
250 ///
251 /// # Errors
252 ///
253 /// Returns [`RegisterError::Parse`](crate::error::RegisterError::Parse) when
254 /// string input conversion fails,
255 /// [`RegisterError::AlreadyRegistered`](crate::error::RegisterError::AlreadyRegistered)
256 /// if the hotkey is already bound, or
257 /// [`RegisterError::ManagerStopped`](crate::error::RegisterError::ManagerStopped)
258 /// if the engine has shut down.
259 pub fn register_with_options(
260 &self,
261 hotkey: impl HotkeyInput,
262 action: impl Into<Action>,
263 options: BindingOptions,
264 ) -> Result<BindingGuard, crate::error::RegisterError> {
265 let id = BindingId::new();
266 let hotkey = hotkey.into_hotkey()?;
267 let binding = Binding::new(id, hotkey, action.into()).with_options(options);
268
269 self.request(|reply| Command::Register { binding, reply })??;
270 Ok(BindingGuard::new(id, self.commands.clone()))
271 }
272
273 /// Query whether a hotkey is currently registered.
274 ///
275 /// Accepts any type implementing [`HotkeyInput`]: a [`Hotkey`](kbd::hotkey::Hotkey), a
276 /// [`Key`], or a string (`&str` / `String`).
277 ///
278 /// # Errors
279 ///
280 /// Returns [`QueryError::Parse`](crate::error::QueryError::Parse) when string
281 /// input conversion fails, or
282 /// [`QueryError::ManagerStopped`](crate::error::QueryError::ManagerStopped) if
283 /// the engine has shut down.
284 pub fn is_registered(
285 &self,
286 hotkey: impl HotkeyInput,
287 ) -> Result<bool, crate::error::QueryError> {
288 let hotkey = hotkey.into_hotkey()?;
289 Ok(self.request(|reply| Command::IsRegistered { hotkey, reply })?)
290 }
291
292 /// Query whether a specific key is currently pressed on any device.
293 ///
294 /// # Errors
295 ///
296 /// Returns [`ManagerStopped`] if the engine has shut down.
297 pub fn is_key_pressed(&self, key: Key) -> Result<bool, ManagerStopped> {
298 self.request(|reply| Command::IsKeyPressed { key, reply })
299 }
300
301 /// Query the set of modifiers currently held, derived from key state.
302 ///
303 /// Left/right variants are canonicalized: if either `LeftCtrl` or `RightCtrl`
304 /// is held, `Modifier::Ctrl` is in the returned set.
305 ///
306 /// # Errors
307 ///
308 /// Returns [`ManagerStopped`] if the engine has shut down.
309 pub fn active_modifiers(&self) -> Result<ModifierSet, ManagerStopped> {
310 self.request(|reply| Command::ActiveModifiers { reply })
311 }
312
313 /// Define a named layer.
314 ///
315 /// Sends the layer definition to the engine for storage. The layer
316 /// is not active until explicitly pushed via [`push_layer()`](Self::push_layer).
317 ///
318 /// # Errors
319 ///
320 /// Returns [`LayerError::AlreadyDefined`](crate::error::LayerError::AlreadyDefined)
321 /// if a layer with the same name has already been defined, or
322 /// [`LayerError::ManagerStopped`](crate::error::LayerError::ManagerStopped)
323 /// if the engine has shut down.
324 pub fn define_layer(&self, layer: Layer) -> Result<(), crate::error::LayerError> {
325 self.request(|reply| Command::DefineLayer { layer, reply })?
326 }
327
328 /// Stop the manager and join the engine thread.
329 ///
330 /// All registered bindings are dropped. This is also called
331 /// automatically when the manager is dropped.
332 ///
333 /// # Errors
334 ///
335 /// Returns [`ShutdownError::Engine`](crate::error::ShutdownError::Engine) if
336 /// the engine thread panicked.
337 pub fn shutdown(self) -> Result<(), crate::error::ShutdownError> {
338 self.shutdown_inner()
339 }
340
341 /// Send a command that carries a reply channel and wait for the response.
342 ///
343 /// Encapsulates the channel-create → send → recv → map-error boilerplate
344 /// shared by every request/reply manager method.
345 fn request<T>(
346 &self,
347 build: impl FnOnce(mpsc::Sender<T>) -> Command,
348 ) -> Result<T, ManagerStopped> {
349 let (reply_tx, reply_rx) = mpsc::channel();
350 self.commands.send(build(reply_tx))?;
351 reply_rx.recv().map_err(|_| ManagerStopped)
352 }
353
354 fn shutdown_inner(&self) -> Result<(), crate::error::ShutdownError> {
355 let mut runtime = self
356 .runtime
357 .lock()
358 .map_err(|_| crate::error::ShutdownError::Engine)?;
359 if let Some(runtime) = runtime.take() {
360 return runtime.shutdown();
361 }
362
363 Ok(())
364 }
365
366 /// Push a named layer onto the layer stack.
367 ///
368 /// The layer must have been previously defined via [`define_layer`](Self::define_layer).
369 /// The pushed layer becomes the highest-priority layer for matching.
370 ///
371 /// # Errors
372 ///
373 /// Returns [`LayerError::NotDefined`](crate::error::LayerError::NotDefined) if
374 /// no layer with the given name exists, or
375 /// [`LayerError::ManagerStopped`](crate::error::LayerError::ManagerStopped) if
376 /// the engine has shut down.
377 pub fn push_layer(&self, name: impl Into<LayerName>) -> Result<(), crate::error::LayerError> {
378 self.request(|reply| Command::PushLayer {
379 name: name.into(),
380 reply,
381 })?
382 }
383
384 /// Pop the topmost layer from the layer stack.
385 ///
386 /// Returns the name of the popped layer.
387 ///
388 /// # Errors
389 ///
390 /// Returns [`LayerError::EmptyStack`](crate::error::LayerError::EmptyStack)
391 /// if no layers are active, or
392 /// [`LayerError::ManagerStopped`](crate::error::LayerError::ManagerStopped) if
393 /// the engine has shut down.
394 pub fn pop_layer(&self) -> Result<LayerName, crate::error::LayerError> {
395 self.request(|reply| Command::PopLayer { reply })?
396 }
397
398 /// Toggle a named layer on or off.
399 ///
400 /// If the layer is currently in the stack, it is removed.
401 /// If the layer is not in the stack, it is pushed.
402 ///
403 /// # Errors
404 ///
405 /// Returns [`LayerError::NotDefined`](crate::error::LayerError::NotDefined) if
406 /// no layer with the given name exists, or
407 /// [`LayerError::ManagerStopped`](crate::error::LayerError::ManagerStopped) if
408 /// the engine has shut down.
409 pub fn toggle_layer(&self, name: impl Into<LayerName>) -> Result<(), crate::error::LayerError> {
410 self.request(|reply| Command::ToggleLayer {
411 name: name.into(),
412 reply,
413 })?
414 }
415
416 /// List all registered bindings with current shadowed status.
417 ///
418 /// Returns global bindings and all layer bindings (active or not).
419 /// Each entry includes whether the binding is currently reachable
420 /// or shadowed by a higher-priority layer.
421 ///
422 /// # Errors
423 ///
424 /// Returns [`ManagerStopped`] if the engine has shut down.
425 pub fn list_bindings(&self) -> Result<Vec<BindingInfo>, ManagerStopped> {
426 self.request(|reply| Command::ListBindings { reply })
427 }
428
429 /// Query what would fire if the given hotkey were pressed now.
430 ///
431 /// Considers the current layer stack. Returns `None` if no binding
432 /// matches the hotkey.
433 ///
434 /// # Errors
435 ///
436 /// Returns [`QueryError::Parse`](crate::error::QueryError::Parse) when string
437 /// input conversion fails, or
438 /// [`QueryError::ManagerStopped`](crate::error::QueryError::ManagerStopped) if
439 /// the engine has shut down.
440 pub fn bindings_for_key(
441 &self,
442 hotkey: impl HotkeyInput,
443 ) -> Result<Option<BindingInfo>, crate::error::QueryError> {
444 let hotkey = hotkey.into_hotkey()?;
445 Ok(self.request(|reply| Command::BindingsForKey { hotkey, reply })?)
446 }
447
448 /// Query the current layer stack.
449 ///
450 /// Returns layers in stack order (bottom to top).
451 ///
452 /// # Errors
453 ///
454 /// Returns [`ManagerStopped`] if the engine has shut down.
455 pub fn active_layers(&self) -> Result<Vec<ActiveLayerInfo>, ManagerStopped> {
456 self.request(|reply| Command::ActiveLayers { reply })
457 }
458
459 /// Return current in-progress sequence state, if any.
460 ///
461 /// # Errors
462 ///
463 /// Returns [`ManagerStopped`] if the engine has shut down.
464 pub fn pending_sequence(&self) -> Result<Option<PendingSequenceInfo>, ManagerStopped> {
465 self.request(|reply| Command::PendingSequence { reply })
466 }
467
468 /// Register a tap-hold binding for a dual-function key.
469 ///
470 /// The `tap_action` fires when the key is pressed and released quickly
471 /// (before the threshold). The `hold_action` fires when the key is held
472 /// past the threshold or interrupted by another keypress.
473 ///
474 /// **Requires grab mode.** Tap-hold must intercept and buffer key events
475 /// before they reach other applications — without grab, the original key
476 /// event would be delivered immediately.
477 ///
478 /// # Errors
479 ///
480 /// Returns [`RegisterError::UnsupportedFeature`](crate::error::RegisterError::UnsupportedFeature)
481 /// if grab mode is not enabled,
482 /// [`RegisterError::AlreadyRegistered`](crate::error::RegisterError::AlreadyRegistered)
483 /// if the key already has a tap-hold binding, or
484 /// [`RegisterError::ManagerStopped`](crate::error::RegisterError::ManagerStopped)
485 /// if the engine has shut down.
486 pub fn register_tap_hold(
487 &self,
488 key: Key,
489 tap_action: impl Into<Action>,
490 hold_action: impl Into<Action>,
491 options: kbd::tap_hold::TapHoldOptions,
492 ) -> Result<BindingGuard, crate::error::RegisterError> {
493 let id = self.request(|reply| Command::RegisterTapHold {
494 key,
495 tap_action: tap_action.into(),
496 hold_action: hold_action.into(),
497 options,
498 reply,
499 })??;
500 Ok(BindingGuard::new(id, self.commands.clone()))
501 }
502
503 /// Find bindings that are shadowed by higher-priority layers.
504 ///
505 /// Returns conflict pairs: each entry shows the shadowed binding
506 /// and the binding that shadows it.
507 ///
508 /// # Errors
509 ///
510 /// Returns [`ManagerStopped`] if the engine has shut down.
511 pub fn conflicts(&self) -> Result<Vec<ConflictInfo>, ManagerStopped> {
512 self.request(|reply| Command::Conflicts { reply })
513 }
514}
515
516impl Drop for HotkeyManager {
517 fn drop(&mut self) {
518 let _ = self.shutdown_inner();
519 }
520}
521
522fn resolve_backend(selection: BackendSelection) -> Result<Backend, crate::error::StartupError> {
523 match selection {
524 BackendSelection::Auto => Ok(Backend::Evdev),
525 BackendSelection::Explicit(backend) => validate_explicit_backend(backend),
526 }
527}
528
529#[allow(clippy::unnecessary_wraps)]
530fn validate_explicit_backend(backend: Backend) -> Result<Backend, crate::error::StartupError> {
531 match backend {
532 Backend::Evdev => Ok(Backend::Evdev),
533 }
534}
535
536#[allow(clippy::unnecessary_wraps)]
537fn validate_grab_configuration(
538 _backend: Backend,
539 _grab: GrabConfiguration,
540) -> Result<(), crate::error::StartupError> {
541 Ok(())
542}
543
544fn internal_test_input_directory() -> Option<PathBuf> {
545 std::env::var_os("_KBD_GLOBAL_INTERNAL_TEST_INPUT_DIR").map(PathBuf::from)
546}
547
548fn create_grab_state(grab: GrabConfiguration) -> Result<GrabState, crate::error::StartupError> {
549 match grab {
550 GrabConfiguration::Disabled => Ok(GrabState::Disabled),
551 GrabConfiguration::Enabled => {
552 #[cfg(feature = "grab")]
553 {
554 let forwarder = crate::engine::forwarder::UinputForwarder::new()?;
555 Ok(GrabState::Enabled {
556 forwarder: Box::new(forwarder),
557 })
558 }
559 #[cfg(not(feature = "grab"))]
560 {
561 Err(crate::error::StartupError::UnsupportedFeature)
562 }
563 }
564 }
565}