//! The system-wide shortcut that raises QuickSearch and focuses the search //! box. //! //! It must be registered with the operating system — an egui shortcut gets //! no key events while the window is minimised, behind something else, or //! unfocused. Two ways, 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`]. //! //! Held in a thread-local global rather than a field: the registration is //! process-wide, `GlobalHotKeyEvent::set_event_handler` is a set-once //! global, and on Windows `GlobalHotKeyManager` owns a hidden message //! window, so it is not `Send` and must stay on the winit event-loop thread. //! //! 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> = const { RefCell::new(None) }; } /// What the Options window says about the shortcut. #[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, }, #[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) { // Press only: the crate reports the release as a second event, and // acting on both means every press 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), }; // Logged because a shortcut that quietly does nothing is impossible // to tell apart from one that was never asked for. match (®istry.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: without the repaint an idle or minimised /// window would leave the flag 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) -> 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; worth reporting, 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: a test holds `Binding`'s tokens to // being parseable. 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. 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 { 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 { grab_backend() } fn grab_backend() -> Result { 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(()) ); } }