quick_search/crates/quicksearch-gui/src/hotkey/binding.rs
Jeremy Karst 22d52d5ac0
Some checks failed
CI / linux (push) Failing after 9m45s
CI / windows-cross (push) Successful in 8m3s
CI / release (push) Has been skipped
Fixed a Windows CI issue.
2026-09-05 03:09:12 -04:00

535 lines
18 KiB
Rust

//! The one representation of a shortcut and its three spellings: the config
//! text (which is also the `global-hotkey` token), and the xkbcommon keysym
//! the XDG shortcuts spec wants for the Wayland portal. All three come out
//! of [`KEYS`], so a key cannot be spelled correctly for one backend and
//! wrongly for the other.
use std::fmt;
use std::str::FromStr;
use egui::Key;
/// One row per bindable key: egui key, config/`global-hotkey` token,
/// xkbcommon keysym (`XKB_KEY_` stripped). Keys egui synthesises from a
/// character (`Plus`, `Colon`, …) are left out: they are the shifted face of
/// a key that already has a row, and binding both would register the same
/// physical press under two names.
const KEYS: &[(Key, &str, &str)] = &[
(Key::A, "A", "a"),
(Key::B, "B", "b"),
(Key::C, "C", "c"),
(Key::D, "D", "d"),
(Key::E, "E", "e"),
(Key::F, "F", "f"),
(Key::G, "G", "g"),
(Key::H, "H", "h"),
(Key::I, "I", "i"),
(Key::J, "J", "j"),
(Key::K, "K", "k"),
(Key::L, "L", "l"),
(Key::M, "M", "m"),
(Key::N, "N", "n"),
(Key::O, "O", "o"),
(Key::P, "P", "p"),
(Key::Q, "Q", "q"),
(Key::R, "R", "r"),
(Key::S, "S", "s"),
(Key::T, "T", "t"),
(Key::U, "U", "u"),
(Key::V, "V", "v"),
(Key::W, "W", "w"),
(Key::X, "X", "x"),
(Key::Y, "Y", "y"),
(Key::Z, "Z", "z"),
(Key::Num0, "0", "0"),
(Key::Num1, "1", "1"),
(Key::Num2, "2", "2"),
(Key::Num3, "3", "3"),
(Key::Num4, "4", "4"),
(Key::Num5, "5", "5"),
(Key::Num6, "6", "6"),
(Key::Num7, "7", "7"),
(Key::Num8, "8", "8"),
(Key::Num9, "9", "9"),
(Key::F1, "F1", "F1"),
(Key::F2, "F2", "F2"),
(Key::F3, "F3", "F3"),
(Key::F4, "F4", "F4"),
(Key::F5, "F5", "F5"),
(Key::F6, "F6", "F6"),
(Key::F7, "F7", "F7"),
(Key::F8, "F8", "F8"),
(Key::F9, "F9", "F9"),
(Key::F10, "F10", "F10"),
(Key::F11, "F11", "F11"),
(Key::F12, "F12", "F12"),
(Key::Space, "Space", "space"),
(Key::Enter, "Enter", "Return"),
(Key::Tab, "Tab", "Tab"),
(Key::Backspace, "Backspace", "BackSpace"),
(Key::Delete, "Delete", "Delete"),
(Key::Insert, "Insert", "Insert"),
(Key::Home, "Home", "Home"),
(Key::End, "End", "End"),
(Key::PageUp, "PageUp", "Prior"),
(Key::PageDown, "PageDown", "Next"),
(Key::ArrowUp, "Up", "Up"),
(Key::ArrowDown, "Down", "Down"),
(Key::ArrowLeft, "Left", "Left"),
(Key::ArrowRight, "Right", "Right"),
(Key::Comma, "Comma", "comma"),
(Key::Period, "Period", "period"),
(Key::Slash, "Slash", "slash"),
(Key::Backslash, "Backslash", "backslash"),
(Key::Semicolon, "Semicolon", "semicolon"),
(Key::Quote, "Quote", "apostrophe"),
(Key::Backtick, "Backquote", "grave"),
(Key::Minus, "Minus", "minus"),
(Key::Equals, "Equal", "equal"),
(Key::OpenBracket, "BracketLeft", "bracketleft"),
(Key::CloseBracket, "BracketRight", "bracketright"),
];
/// Escape is reserved: it cancels the Settings tab's capture.
const RESERVED: &[Key] = &[Key::Escape];
/// A shortcut the user can press from anywhere. Super/Meta is absent
/// because `egui::Modifiers` has no field for it, so a Super combo could
/// never be captured in the Settings tab.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Binding {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
key: Key,
}
/// Why a string or key press is not a usable shortcut; shown in Settings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindingError {
Empty,
NoModifier,
NoKey,
UnknownToken(String),
/// More than one non-modifier token, as in `Ctrl+A+B`.
TwoKeys,
}
impl fmt::Display for BindingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BindingError::Empty => write!(f, "no shortcut"),
BindingError::NoModifier => {
write!(f, "needs at least one of Ctrl, Alt or Shift")
}
BindingError::NoKey => write!(f, "needs a key, not just modifiers"),
BindingError::UnknownToken(t) => write!(f, "{:?} is not a key name", t),
BindingError::TwoKeys => write!(f, "only one key, plus modifiers"),
}
}
}
impl Binding {
/// Build from a key press egui reported. `None` for a press that cannot
/// be a shortcut: no row in [`KEYS`], reserved, or no modifier held.
pub fn from_egui(key: Key, modifiers: &egui::Modifiers) -> Option<Binding> {
if RESERVED.contains(&key) || !KEYS.iter().any(|(k, _, _)| *k == key) {
return None;
}
let binding = Binding {
ctrl: modifiers.ctrl,
alt: modifiers.alt,
shift: modifiers.shift,
key,
};
binding.has_modifier().then_some(binding)
}
fn has_modifier(&self) -> bool {
self.ctrl || self.alt || self.shift
}
fn row(&self) -> (&'static str, &'static str) {
KEYS.iter()
.find(|(k, _, _)| *k == self.key)
.map(|(_, token, keysym)| (*token, *keysym))
.expect("every Binding key comes from KEYS")
}
/// The key combination as the integer `Qt::Key | Qt::Modifier` value
/// KGlobalAccel's DBus `setShortcut` takes. Covered like [`Self::row`]:
/// `tests::every_key_has_a_qt_code` proves the mapping total over
/// [`KEYS`], so a new row cannot reach KDE as a panic.
///
/// This and the two spellings below exist for the desktops
/// `crate::shortcut_setup` and [`super::portal`] write to, all of which
/// are unix; off unix nothing calls them and the lint would say so.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub fn qt_key_code(&self) -> u32 {
let mut code = qt_key(self.key);
for (held, bit) in [
(self.shift, 0x0200_0000),
(self.ctrl, 0x0400_0000),
(self.alt, 0x0800_0000),
] {
if held {
code |= bit;
}
}
code
}
/// The accelerator in GTK's syntax — `<Ctrl><Shift>f` — which is what a
/// GNOME custom keybinding's `binding` key stores. GTK keyval names are
/// the X11 keysym names, so the keysym column serves both spellings.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub fn gtk_accelerator(&self) -> String {
let mut out = String::new();
for (held, name) in [
(self.ctrl, "<Ctrl>"),
(self.alt, "<Alt>"),
(self.shift, "<Shift>"),
] {
if held {
out.push_str(name);
}
}
out.push_str(self.row().1);
out
}
/// The trigger in the XDG shortcuts spec's syntax: uppercase modifiers
/// and an xkbcommon keysym, joined with `+`.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
pub fn portal_trigger(&self) -> String {
let mut out = String::new();
for (held, name) in [
(self.ctrl, "CTRL"),
(self.alt, "ALT"),
(self.shift, "SHIFT"),
] {
if held {
out.push_str(name);
out.push('+');
}
}
out.push_str(self.row().1);
out
}
}
impl fmt::Display for Binding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (held, name) in [
(self.ctrl, "Ctrl"),
(self.alt, "Alt"),
(self.shift, "Shift"),
] {
if held {
write!(f, "{}+", name)?;
}
}
f.write_str(self.row().0)
}
}
impl FromStr for Binding {
type Err = BindingError;
fn from_str(s: &str) -> Result<Binding, BindingError> {
if s.trim().is_empty() {
return Err(BindingError::Empty);
}
let mut binding = Binding {
ctrl: false,
alt: false,
shift: false,
key: Key::A,
};
let mut key = None;
for raw in s.split('+') {
let token = raw.trim();
match token.to_ascii_uppercase().as_str() {
"" => return Err(BindingError::UnknownToken(token.to_string())),
"CTRL" | "CONTROL" => binding.ctrl = true,
"ALT" => binding.alt = true,
"SHIFT" => binding.shift = true,
upper => {
if key.is_some() {
return Err(BindingError::TwoKeys);
}
key = Some(
KEYS.iter()
.find(|(_, t, _)| t.eq_ignore_ascii_case(upper))
.map(|(k, _, _)| *k)
.ok_or_else(|| BindingError::UnknownToken(token.to_string()))?,
);
}
}
}
binding.key = key.ok_or(BindingError::NoKey)?;
if !binding.has_modifier() {
return Err(BindingError::NoModifier);
}
Ok(binding)
}
}
/// The `Qt::Key` value for a bindable key. Printable keys are their ASCII
/// uppercase; the named keys are Qt's `0x0100_00xx` block. A fourth [`KEYS`]
/// column in all but layout: kept as a match so the table stays readable,
/// with `tests::every_key_has_a_qt_code` holding the two together.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
fn qt_key(key: Key) -> u32 {
match key {
Key::A => 0x41,
Key::B => 0x42,
Key::C => 0x43,
Key::D => 0x44,
Key::E => 0x45,
Key::F => 0x46,
Key::G => 0x47,
Key::H => 0x48,
Key::I => 0x49,
Key::J => 0x4A,
Key::K => 0x4B,
Key::L => 0x4C,
Key::M => 0x4D,
Key::N => 0x4E,
Key::O => 0x4F,
Key::P => 0x50,
Key::Q => 0x51,
Key::R => 0x52,
Key::S => 0x53,
Key::T => 0x54,
Key::U => 0x55,
Key::V => 0x56,
Key::W => 0x57,
Key::X => 0x58,
Key::Y => 0x59,
Key::Z => 0x5A,
Key::Num0 => 0x30,
Key::Num1 => 0x31,
Key::Num2 => 0x32,
Key::Num3 => 0x33,
Key::Num4 => 0x34,
Key::Num5 => 0x35,
Key::Num6 => 0x36,
Key::Num7 => 0x37,
Key::Num8 => 0x38,
Key::Num9 => 0x39,
Key::F1 => 0x0100_0030,
Key::F2 => 0x0100_0031,
Key::F3 => 0x0100_0032,
Key::F4 => 0x0100_0033,
Key::F5 => 0x0100_0034,
Key::F6 => 0x0100_0035,
Key::F7 => 0x0100_0036,
Key::F8 => 0x0100_0037,
Key::F9 => 0x0100_0038,
Key::F10 => 0x0100_0039,
Key::F11 => 0x0100_003A,
Key::F12 => 0x0100_003B,
Key::Space => 0x20,
Key::Enter => 0x0100_0004,
Key::Tab => 0x0100_0001,
Key::Backspace => 0x0100_0003,
Key::Delete => 0x0100_0007,
Key::Insert => 0x0100_0006,
Key::Home => 0x0100_0010,
Key::End => 0x0100_0011,
Key::PageUp => 0x0100_0016,
Key::PageDown => 0x0100_0017,
Key::ArrowUp => 0x0100_0013,
Key::ArrowDown => 0x0100_0015,
Key::ArrowLeft => 0x0100_0012,
Key::ArrowRight => 0x0100_0014,
Key::Comma => 0x2C,
Key::Period => 0x2E,
Key::Slash => 0x2F,
Key::Backslash => 0x5C,
Key::Semicolon => 0x3B,
Key::Quote => 0x27,
Key::Backtick => 0x60,
Key::Minus => 0x2D,
Key::Equals => 0x3D,
Key::OpenBracket => 0x5B,
Key::CloseBracket => 0x5D,
other => unreachable!("{other:?} is not in KEYS; see every_key_has_a_qt_code"),
}
}
/// Empty means "no shortcut" rather than an error.
pub fn parse_setting(setting: &str) -> Result<Option<Binding>, BindingError> {
if setting.trim().is_empty() {
return Ok(None);
}
setting.parse().map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_setting_parses() {
let cfg = quicksearch_core::config::UiConfig::default();
let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid");
assert_eq!(binding.to_string(), "Ctrl+Shift+F");
assert_eq!(binding.portal_trigger(), "CTRL+SHIFT+f");
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Shift>f");
}
/// `qt_key`'s `unreachable!` is only sound while every row of [`KEYS`]
/// has an arm; this is what holds the match and the table together.
#[test]
fn every_key_has_a_qt_code() {
for (key, token, _) in KEYS {
let code = qt_key(*key);
assert_ne!(code, 0, "{token} has no Qt key code");
}
}
/// The values KGlobalAccel actually receives, spot-checked against
/// `Qt::Key`: the default binding (verified live against Plasma 6.6),
/// a named key, and a punctuation key.
#[test]
fn qt_key_codes_match_qt() {
let binding: Binding = "Ctrl+Shift+F".parse().unwrap();
assert_eq!(binding.qt_key_code(), 0x0600_0046);
let binding: Binding = "Alt+PageUp".parse().unwrap();
assert_eq!(binding.qt_key_code(), 0x0900_0016);
let binding: Binding = "Ctrl+Comma".parse().unwrap();
assert_eq!(binding.qt_key_code(), 0x0400_002C);
}
/// The keysym column doubles as the GTK keyval, so a named key must come
/// out under GTK's name for it, not egui's.
#[test]
fn gtk_accelerators_use_keysym_names() {
let binding: Binding = "Ctrl+Alt+PageUp".parse().unwrap();
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Alt>Prior");
let binding: Binding = "Shift+Enter".parse().unwrap();
assert_eq!(binding.gtk_accelerator(), "<Shift>Return");
}
#[test]
fn every_key_round_trips() {
for (key, token, _) in KEYS {
let binding = Binding {
ctrl: true,
alt: false,
shift: false,
key: *key,
};
let text = binding.to_string();
assert_eq!(text, format!("Ctrl+{}", token));
assert_eq!(text.parse::<Binding>(), Ok(binding), "{text} did not parse");
}
}
/// Stops a typo in [`KEYS`] reaching `RegisterHotKey` as a silent failure.
#[test]
fn tokens_are_parseable_by_global_hotkey() {
for (_, token, _) in KEYS {
let text = format!("Ctrl+{}", token);
assert!(
text.parse::<global_hotkey::hotkey::HotKey>().is_ok(),
"global-hotkey rejected {text:?}"
);
}
}
/// Two keys sharing a keysym would silently bind the wrong one on Wayland.
#[test]
fn rows_are_unique() {
for (i, (key, token, keysym)) in KEYS.iter().enumerate() {
for (other_key, other_token, other_keysym) in &KEYS[i + 1..] {
assert_ne!(key, other_key, "{token} and {other_token} share a key");
assert_ne!(token, other_token, "duplicate token {token}");
assert_ne!(keysym, other_keysym, "duplicate keysym {keysym}");
}
}
}
#[test]
fn modifiers_are_ordered_and_case_insensitive() {
let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap();
assert_eq!(binding.to_string(), "Ctrl+Alt+Shift+F");
assert_eq!(binding.portal_trigger(), "CTRL+ALT+SHIFT+f");
assert_eq!(
" Ctrl + Shift + F ".parse::<Binding>(),
Ok(binding_of("Ctrl+Shift+F"))
);
}
fn binding_of(s: &str) -> Binding {
s.parse().unwrap()
}
#[test]
fn bad_settings_are_rejected() {
assert_eq!("F".parse::<Binding>(), Err(BindingError::NoModifier));
assert_eq!("Ctrl".parse::<Binding>(), Err(BindingError::NoKey));
assert_eq!("Ctrl+Shift".parse::<Binding>(), Err(BindingError::NoKey));
assert_eq!("Ctrl+A+B".parse::<Binding>(), Err(BindingError::TwoKeys));
assert_eq!(
"Ctrl+".parse::<Binding>(),
Err(BindingError::UnknownToken(String::new()))
);
assert_eq!(
"Ctrl+Nope".parse::<Binding>(),
Err(BindingError::UnknownToken("Nope".to_string()))
);
assert_eq!("".parse::<Binding>(), Err(BindingError::Empty));
assert_eq!(
"Ctrl+Escape".parse::<Binding>(),
Err(BindingError::UnknownToken("Escape".to_string()))
);
}
#[test]
fn an_empty_setting_is_no_shortcut_not_an_error() {
assert_eq!(parse_setting(""), Ok(None));
assert_eq!(parse_setting(" "), Ok(None));
assert_eq!(
parse_setting("Ctrl+Shift+F"),
Ok(Some(binding_of("Ctrl+Shift+F")))
);
assert!(parse_setting("Ctrl+Nope").is_err());
}
#[test]
fn capture_needs_a_modifier_and_a_known_key() {
let ctrl = egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
};
assert_eq!(
Binding::from_egui(Key::F, &ctrl),
Some(binding_of("Ctrl+F"))
);
assert_eq!(
Binding::from_egui(Key::F, &egui::Modifiers::default()),
None
);
assert_eq!(Binding::from_egui(Key::Escape, &ctrl), None);
assert_eq!(Binding::from_egui(Key::Plus, &ctrl), None);
}
/// egui sets `command` alongside `ctrl` off Mac; must not double up.
#[test]
fn the_egui_command_alias_is_ignored() {
let modifiers = egui::Modifiers {
ctrl: true,
command: true,
shift: true,
..Default::default()
};
assert_eq!(
Binding::from_egui(Key::F, &modifiers).map(|b| b.to_string()),
Some("Ctrl+Shift+F".to_string())
);
}
}