quick_search/crates/quicksearch-gui/src/hotkey/mod.rs

306 lines
12 KiB
Rust

//! The system-wide shortcut that raises QuickSearch and focuses the search
//! box.
//!
//! It has to be registered with the operating system rather than handled as
//! an egui shortcut, because the whole point is that it works when the window
//! is minimised, behind something else, or not focused — none of which
//! deliver key events to the app. There are two ways to get one, chosen by
//! what the session is:
//!
//! * **Windows and X11** let an application claim a key for itself
//! (`RegisterHotKey`, `XGrabKey`), which `global-hotkey` wraps. The key is
//! exactly the one that was asked for, or the registration fails.
//! * **Wayland** does not, on purpose, so the shortcut goes through the XDG
//! desktop portal instead and the *desktop* owns the binding. See
//! [`portal`].
//!
//! Both are driven from here, through one interface, so the rest of the app
//! only ever deals with "did the shortcut fire" and "what should the Options
//! window say about it".
//!
//! # Why this is a global rather than a field
//!
//! The registration is process-wide however it is made, and
//! `GlobalHotKeyEvent::set_event_handler` is itself a set-once global. On
//! Windows the manager owns a hidden message window, so it is not `Send` and
//! has to stay on the thread that runs the winit event loop — the same thread
//! every caller below is already on. The alternative, threading a handle from
//! [`crate::main`] through [`crate::unlock::Gate`] into
//! [`crate::app::QuickSearchApp`], has to survive the app being *built
//! mid-session* when a password unlocks the index, and buys nothing for it.
//!
//! Every entry point is inert until [`init`] runs, so the headless UI tests
//! never touch an OS registration.
mod binding;
#[cfg(all(unix, not(target_os = "macos")))]
mod portal;
mod raise;
pub use binding::{parse_setting, Binding};
pub use raise::raise;
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use global_hotkey::hotkey::HotKey;
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
/// Set from whichever thread the shortcut arrives on, consumed by the UI
/// thread in [`take_fired`]. A flag rather than a queue: two presses before
/// the app can redraw mean the same thing as one.
static FIRED: AtomicBool = AtomicBool::new(false);
thread_local! {
/// UI-thread only. See the module docs for why it is not a field.
static REGISTRY: RefCell<Option<Registry>> = const { RefCell::new(None) };
}
/// What the Options window says about the shortcut. Every variant is
/// something the user can act on, which is why "registered but the desktop
/// picked the key" is not folded into [`Status::Active`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
/// The setting is empty: no shortcut, by choice.
Disabled,
/// Registered with the display server, exactly as asked.
Active,
/// Asked for, and the desktop has not answered yet.
Pending,
/// Wayland: registered, described in the desktop's own words because the
/// desktop, not the setting, decides the key.
PortalBound(String),
/// It is not going to work, and this says why.
Error(String),
}
struct Registry {
backend: Backend,
/// The status of everything except the portal, which reports its own
/// asynchronously; see [`status`].
status: Status,
}
enum Backend {
/// Nothing registered: no shortcut set, or the backend never started.
Idle,
/// Windows and X11.
Grab {
manager: GlobalHotKeyManager,
/// The registration currently held, to be released before the next.
registered: Option<HotKey>,
},
#[cfg(all(unix, not(target_os = "macos")))]
Portal(portal::Portal),
}
/// Start the shortcut and register `setting`.
///
/// Must be called on the thread running the event loop, and only from there:
/// on Windows `GlobalHotKeyManager` creates a hidden window whose messages
/// that loop is what dispatches. In practice that means eframe's app-creation
/// closure, which runs on the main thread with the loop already going.
pub fn init(ctx: &egui::Context, setting: &str) {
// Set once for the process, so it goes here rather than next to the
// manager, which comes and goes with the backend.
// Press only: the crate reports the release as a second event, and
// acting on both means every press of the shortcut does its work twice.
let repaint = ctx.clone();
GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| {
if event.state == HotKeyState::Pressed {
fire(&repaint);
}
}));
let backend = match choose_backend(ctx) {
Ok(backend) => backend,
Err(message) => {
quicksearch_core::log_warn!("global shortcut: {}", message);
REGISTRY.with_borrow_mut(|slot| {
*slot = Some(Registry {
backend: Backend::Idle,
status: Status::Error(message),
});
});
return;
}
};
REGISTRY.with_borrow_mut(|slot| {
*slot = Some(Registry {
backend,
status: Status::Disabled,
})
});
apply(setting);
}
/// Register `setting`, releasing whatever was registered before. Empty means
/// no shortcut. An unparseable or refused shortcut is reported through
/// [`status`], never by failing: a shortcut is not worth blocking a config
/// the user has already applied.
pub fn apply(setting: &str) {
REGISTRY.with_borrow_mut(|slot| {
let Some(registry) = slot.as_mut() else {
return;
};
let wanted = match parse_setting(setting) {
Ok(binding) => binding,
Err(e) => {
registry.status = Status::Error(format!("{:?} is not a shortcut: {}", setting, e));
// Releasing cannot fail in a way worth a second message.
let _ = registry.backend.register(None);
return;
}
};
registry.status = match registry.backend.register(wanted) {
Ok(()) if wanted.is_some() => Status::Active,
Ok(()) => Status::Disabled,
Err(e) => Status::Error(e),
};
// In the Logs tab, because a shortcut that quietly does nothing is
// otherwise impossible to tell apart from one that was never asked
// for. The Options window says the same thing, but only while it is
// open, and only about the state it left behind.
match (&registry.status, wanted) {
(Status::Active, Some(binding)) => {
quicksearch_core::log_info!("global shortcut: {} registered", binding)
}
(Status::Error(why), _) => quicksearch_core::log_warn!("global shortcut: {}", why),
_ => {}
}
});
}
/// Whether the shortcut was pressed since this was last asked, clearing it.
pub fn take_fired() -> bool {
FIRED.swap(false, Ordering::SeqCst)
}
/// What to tell the user about the shortcut right now.
pub fn status() -> Status {
REGISTRY.with_borrow(|slot| match slot.as_ref() {
None => Status::Disabled,
// The portal answers on its own schedule, so it keeps its own status
// and this one is stale the moment a bind is sent.
#[cfg(all(unix, not(target_os = "macos")))]
Some(Registry {
backend: Backend::Portal(portal),
..
}) => portal.status(),
Some(registry) => registry.status.clone(),
})
}
/// Record a press and wake the UI. The repaint is the load-bearing half:
/// with nothing happening on screen the app is idle, and a minimised window
/// is not drawing at all, so without it the flag would sit unread until
/// something else asked for a frame.
fn fire(ctx: &egui::Context) {
FIRED.store(true, Ordering::SeqCst);
ctx.request_repaint();
}
impl Backend {
/// Hold `wanted` and nothing else. `None` releases without registering.
fn register(&mut self, wanted: Option<Binding>) -> Result<(), String> {
match self {
Backend::Idle => Ok(()),
Backend::Grab {
manager,
registered,
} => {
if let Some(old) = registered.take() {
// A failed unregister leaves a key claimed that nothing
// listens for any more. Worth reporting, but not worth
// refusing the new binding over.
if let Err(e) = manager.unregister(old) {
quicksearch_core::log_warn!("releasing the old global shortcut: {}", e);
}
}
let Some(binding) = wanted else {
return Ok(());
};
// Infallible in practice: `Binding`'s tokens are held to
// being parseable by a test, precisely so this cannot be a
// silent runtime failure.
let hotkey: HotKey = binding
.to_string()
.parse()
.map_err(|e| format!("{} is not a usable shortcut: {}", binding, e))?;
manager.register(hotkey).map_err(|e| match e {
global_hotkey::Error::AlreadyRegistered(_) => {
format!("another application is already using {}", binding)
}
other => format!("{} could not be registered: {}", binding, other),
})?;
*registered = Some(hotkey);
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
Backend::Portal(portal) => {
portal.bind(wanted.map(|b| b.portal_trigger()));
Ok(())
}
}
}
}
/// Wayland refuses key grabs by design, so a session with a Wayland display
/// gets the portal and everything else gets a grab. There is deliberately no
/// falling back from one to the other: an X11 grab made from inside a Wayland
/// session succeeds and then only ever fires while an XWayland window has
/// focus, which looks like a broken shortcut rather than an unavailable one.
#[cfg(all(unix, not(target_os = "macos")))]
fn choose_backend(ctx: &egui::Context) -> Result<Backend, String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
return Ok(Backend::Portal(portal::Portal::new(ctx)));
}
grab_backend()
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn choose_backend(_ctx: &egui::Context) -> Result<Backend, String> {
grab_backend()
}
fn grab_backend() -> Result<Backend, String> {
GlobalHotKeyManager::new()
.map(|manager| Backend::Grab {
manager,
registered: None,
})
.map_err(|e| format!("global shortcuts are unavailable: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
/// Nothing may touch an OS registration before `init`, so that the
/// headless UI tests can render the Options row.
#[test]
fn an_uninitialised_registry_is_inert() {
apply("Ctrl+Shift+F");
assert_eq!(status(), Status::Disabled);
assert!(!take_fired());
}
#[test]
fn a_press_is_reported_once() {
FIRED.store(true, Ordering::SeqCst);
assert!(take_fired());
assert!(!take_fired(), "the flag is consumed");
}
/// `Idle` stands in for a backend that never started; it must accept
/// every call rather than panic, since `apply` runs on every config save.
#[test]
fn an_idle_backend_accepts_everything() {
let mut backend = Backend::Idle;
assert_eq!(backend.register(None), Ok(()));
assert_eq!(
backend.register(Some("Ctrl+Shift+F".parse().unwrap())),
Ok(())
);
}
}