#![cfg(target_os = "windows")]
use std::collections::HashMap;
use std::sync::Mutex;
use winapi::shared::minwindef::WORD;
use winapi::shared::windef::HACCEL;
use winapi::um::winuser::{
CreateAcceleratorTableW, DestroyAcceleratorTable, ACCEL, FALT, FCONTROL, FSHIFT, FVIRTKEY,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Win32Accelerator {
pub(crate) vk: u16,
pub(crate) modifiers: u8,
}
impl Win32Accelerator {
fn to_accel(self, command_id: u32) -> ACCEL {
ACCEL { fVirt: self.modifiers, key: self.vk as WORD, cmd: command_id as WORD }
}
}
fn accel_tables() -> &'static Mutex<HashMap<u64, usize>> {
static TABLES: std::sync::OnceLock<Mutex<HashMap<u64, usize>>> = std::sync::OnceLock::new();
TABLES.get_or_init(|| Mutex::new(HashMap::new()))
}
type AccelEntry = (Win32Accelerator, u32);
type AccelEntries = HashMap<u64, Vec<AccelEntry>>;
fn accel_entries() -> &'static Mutex<AccelEntries> {
static ENTRIES: std::sync::OnceLock<Mutex<AccelEntries>> = std::sync::OnceLock::new();
ENTRIES.get_or_init(|| Mutex::new(HashMap::new()))
}
fn accel_shortcuts() -> &'static Mutex<HashMap<u32, String>> {
static SHORTCUTS: std::sync::OnceLock<Mutex<HashMap<u32, String>>> = std::sync::OnceLock::new();
SHORTCUTS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn record_shortcut_text(command_id: u32, text: &str) {
match accel_shortcuts().lock() {
Ok(mut shortcuts) => {
shortcuts.insert(command_id, text.to_string());
}
Err(_) => log::error!("[windows] record_shortcut_text: shortcut registry mutex poisoned"),
}
}
pub(crate) fn shortcut_text_for(command_id: u32) -> Option<String> {
let shortcuts = accel_shortcuts().lock().ok()?;
shortcuts.get(&command_id).cloned()
}
pub(crate) fn install_accelerator(accel: Win32Accelerator, command_id: u32, window_id: u64) {
let Ok(mut entries) = accel_entries().lock() else {
log::error!("[windows] install_accelerator: entry registry mutex poisoned");
return;
};
let list = entries.entry(window_id).or_default();
list.retain(|(_, id)| *id != command_id);
list.push((accel, command_id));
let mut raw: Vec<ACCEL> = list.iter().map(|(a, id)| a.to_accel(*id)).collect();
let handle = unsafe { CreateAcceleratorTableW(raw.as_mut_ptr(), raw.len() as i32) };
if handle.is_null() {
let last_error = unsafe { winapi::um::errhandlingapi::GetLastError() };
log::error!(
"[windows] install_accelerator: CreateAcceleratorTableW failed \
(GetLastError={last_error})"
);
return;
}
if let Ok(mut tables) = accel_tables().lock() {
if let Some(previous) = tables.insert(window_id, handle as usize) {
unsafe {
DestroyAcceleratorTable(previous as HACCEL);
}
}
} else {
log::error!("[windows] install_accelerator: table registry mutex poisoned");
unsafe {
DestroyAcceleratorTable(handle);
}
}
}
pub(crate) fn accel_table_for(window_id: u64) -> Option<HACCEL> {
let tables = accel_tables().lock().ok()?;
tables.get(&window_id).map(|handle| *handle as HACCEL)
}
pub(crate) fn release_accelerator_table(window_id: u64) {
if let Ok(mut entries) = accel_entries().lock() {
entries.remove(&window_id);
}
if let Ok(mut tables) = accel_tables().lock() {
if let Some(handle) = tables.remove(&window_id) {
unsafe {
DestroyAcceleratorTable(handle as HACCEL);
}
}
}
}
pub(crate) fn parse_accelerator(shortcut: Option<&str>) -> Option<Win32Accelerator> {
let raw = shortcut.map(|s| s.trim()).filter(|s| !s.is_empty())?;
let mut modifiers = FVIRTKEY;
let mut vk: Option<u16> = None;
for ch in raw.chars() {
match ch {
'⌘' | '⌃' | '^' => modifiers |= FCONTROL,
'⌥' => modifiers |= FALT,
'⇧' => modifiers |= FSHIFT,
_ => {}
}
}
for part in raw.split('+') {
let token = part.trim().to_lowercase();
let token = token.trim_start_matches(['⌘', '⌃', '^', '⌥', '⇧']).to_string();
match token.as_str() {
"primary" | "cmdorctrl" | "cmd" | "command" | "ctrl" | "control" => {
modifiers |= FCONTROL
}
"alt" | "option" => modifiers |= FALT,
"shift" => modifiers |= FSHIFT,
"⌘" | "⌃" | "⌥" | "⇧" => {}
other => {
if let Some(code) = virtual_key_for_token(other) {
vk = Some(code);
}
}
}
}
vk.map(|vk| Win32Accelerator { vk, modifiers })
}
fn virtual_key_for_token(token: &str) -> Option<u16> {
use winapi::um::winuser::{
VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT,
VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP,
};
let named = match token {
"backspace" | "back" => Some(VK_BACK as u16),
"delete" | "del" => Some(VK_DELETE as u16),
"esc" | "escape" => Some(VK_ESCAPE as u16),
"enter" | "return" => Some(VK_RETURN as u16),
"space" => Some(VK_SPACE as u16),
"tab" => Some(VK_TAB as u16),
"insert" | "ins" => Some(VK_INSERT as u16),
"home" => Some(VK_HOME as u16),
"end" => Some(VK_END as u16),
"pageup" | "pgup" => Some(VK_PRIOR as u16),
"pagedown" | "pgdn" => Some(VK_NEXT as u16),
"left" => Some(VK_LEFT as u16),
"right" => Some(VK_RIGHT as u16),
"up" => Some(VK_UP as u16),
"down" => Some(VK_DOWN as u16),
_ => None,
};
if named.is_some() {
return named;
}
if let Some(rest) = token.strip_prefix('f') {
if let Ok(number) = rest.parse::<u16>() {
if (1..=12).contains(&number) {
return Some(VK_F1 as u16 + (number - 1));
}
}
}
let mut chars = token.chars();
let first = chars.next()?;
if chars.next().is_some() {
return None;
}
if first.is_ascii_alphabetic() {
return Some(first.to_ascii_uppercase() as u16);
}
if first.is_ascii_digit() {
return Some(first as u16);
}
match first {
'/' | '.' | ',' | '-' | '=' | ';' | '\'' | '[' | ']' | '\\' => Some(first as u16),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use winapi::um::winuser::{
VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F12, VK_HOME, VK_LEFT, VK_NEXT,
VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP,
};
#[test]
fn backspace_and_delete_are_distinct() {
let backspace = parse_accelerator(Some("Ctrl+Backspace")).expect("Backspace must bind");
let delete = parse_accelerator(Some("Ctrl+Delete")).expect("Delete must bind");
assert_eq!(backspace.vk, VK_BACK as u16);
assert_eq!(delete.vk, VK_DELETE as u16);
assert_ne!(backspace.vk, delete.vk);
}
#[test]
fn blank_input_produces_none() {
assert!(parse_accelerator(None).is_none());
assert!(parse_accelerator(Some("")).is_none());
assert!(parse_accelerator(Some(" ")).is_none());
}
#[test]
fn ctrl_letter_binds_control() {
let accel = parse_accelerator(Some("Ctrl+S")).expect("Ctrl+S must bind");
assert_eq!(accel.vk, 'S' as u16);
assert_eq!(accel.modifiers, FVIRTKEY | FCONTROL);
}
#[test]
fn primary_spelling_binds_control() {
let primary = parse_accelerator(Some("Primary+S")).expect("Primary+S must bind");
assert_eq!(primary, parse_accelerator(Some("Ctrl+S")).expect("Ctrl+S must bind"));
}
#[test]
fn mac_glyph_form_binds_control() {
let accel = parse_accelerator(Some("⌘S")).expect("glyph form must bind");
assert_eq!(accel.vk, 'S' as u16);
assert_eq!(accel.modifiers, FVIRTKEY | FCONTROL);
}
#[test]
fn shift_control_is_distinct() {
let accel = parse_accelerator(Some("Ctrl+Shift+Z")).expect("chord must bind");
assert_eq!(accel.vk, 'Z' as u16);
assert_eq!(accel.modifiers, FVIRTKEY | FCONTROL | FSHIFT);
assert_ne!(accel, parse_accelerator(Some("Ctrl+Z")).expect("Ctrl+Z must bind"));
}
#[test]
fn alt_binds_alt_flag() {
let accel = parse_accelerator(Some("Alt+F4")).expect("Alt+F4 must bind");
assert_eq!(accel.vk, VK_F1 as u16 + 3);
assert_eq!(accel.modifiers, FVIRTKEY | FALT);
}
#[test]
fn navigation_keys_map_to_virtual_codes() {
let cases = [
("Ctrl+Home", VK_HOME),
("Ctrl+End", VK_END),
("Ctrl+PageUp", VK_PRIOR),
("Ctrl+PageDown", VK_NEXT),
("Ctrl+Left", VK_LEFT),
("Ctrl+Right", VK_RIGHT),
("Ctrl+Up", VK_UP),
("Ctrl+Down", VK_DOWN),
("Ctrl+Enter", VK_RETURN),
("Ctrl+Tab", VK_TAB),
("Ctrl+Escape", VK_ESCAPE),
("Ctrl+Space", VK_SPACE),
("Ctrl+Backspace", VK_BACK),
("Ctrl+Delete", VK_DELETE),
];
for (text, expected) in cases {
let accel = parse_accelerator(Some(text)).unwrap_or_else(|| panic!("{text} must bind"));
assert_eq!(accel.vk, expected as u16, "{text} mapped to the wrong key");
}
}
#[test]
fn function_keys_map_into_range() {
assert_eq!(parse_accelerator(Some("F1")).expect("F1 must bind").vk, VK_F1 as u16);
assert_eq!(parse_accelerator(Some("F12")).expect("F12 must bind").vk, VK_F12 as u16);
assert!(parse_accelerator(Some("F13")).is_none());
}
#[test]
fn unknown_key_is_refused() {
assert!(parse_accelerator(Some("Ctrl+NotAKey")).is_none());
assert!(parse_accelerator(Some("Ctrl+")).is_none());
}
}