handy-keys 0.3.2

Cross-platform global keyboard shortcuts library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Windows virtual key code conversion utilities

use crate::types::{Key, Modifiers};

/// Windows Virtual Key codes
#[allow(dead_code)]
mod vk {
    // Control keys
    pub const BACK: u16 = 0x08;
    pub const TAB: u16 = 0x09;
    pub const RETURN: u16 = 0x0D;
    pub const SHIFT: u16 = 0x10;
    pub const CONTROL: u16 = 0x11;
    pub const MENU: u16 = 0x12; // Alt
    pub const CAPITAL: u16 = 0x14; // Caps Lock
    pub const ESCAPE: u16 = 0x1B;
    pub const SPACE: u16 = 0x20;

    // Lock keys
    pub const NUMLOCK: u16 = 0x90;
    pub const SCROLL: u16 = 0x91;

    // Navigation keys
    pub const PRIOR: u16 = 0x21; // Page Up
    pub const NEXT: u16 = 0x22; // Page Down
    pub const END: u16 = 0x23;
    pub const HOME: u16 = 0x24;
    pub const LEFT: u16 = 0x25;
    pub const UP: u16 = 0x26;
    pub const RIGHT: u16 = 0x27;
    pub const DOWN: u16 = 0x28;
    pub const INSERT: u16 = 0x2D;
    pub const DELETE: u16 = 0x2E;

    // Numbers 0-9 are 0x30-0x39
    // Letters A-Z are 0x41-0x5A

    // Windows keys
    pub const LWIN: u16 = 0x5B;
    pub const RWIN: u16 = 0x5C;

    // Numpad keys
    pub const NUMPAD0: u16 = 0x60;
    pub const NUMPAD1: u16 = 0x61;
    pub const NUMPAD2: u16 = 0x62;
    pub const NUMPAD3: u16 = 0x63;
    pub const NUMPAD4: u16 = 0x64;
    pub const NUMPAD5: u16 = 0x65;
    pub const NUMPAD6: u16 = 0x66;
    pub const NUMPAD7: u16 = 0x67;
    pub const NUMPAD8: u16 = 0x68;
    pub const NUMPAD9: u16 = 0x69;
    pub const MULTIPLY: u16 = 0x6A;
    pub const ADD: u16 = 0x6B;
    pub const SUBTRACT: u16 = 0x6D;
    pub const DECIMAL: u16 = 0x6E;
    pub const DIVIDE: u16 = 0x6F;

    // Function keys
    pub const F1: u16 = 0x70;
    pub const F2: u16 = 0x71;
    pub const F3: u16 = 0x72;
    pub const F4: u16 = 0x73;
    pub const F5: u16 = 0x74;
    pub const F6: u16 = 0x75;
    pub const F7: u16 = 0x76;
    pub const F8: u16 = 0x77;
    pub const F9: u16 = 0x78;
    pub const F10: u16 = 0x79;
    pub const F11: u16 = 0x7A;
    pub const F12: u16 = 0x7B;
    pub const F13: u16 = 0x7C;
    pub const F14: u16 = 0x7D;
    pub const F15: u16 = 0x7E;
    pub const F16: u16 = 0x7F;
    pub const F17: u16 = 0x80;
    pub const F18: u16 = 0x81;
    pub const F19: u16 = 0x82;
    pub const F20: u16 = 0x83;
    pub const F21: u16 = 0x84;
    pub const F22: u16 = 0x85;
    pub const F23: u16 = 0x86;
    pub const F24: u16 = 0x87;

    // Left/Right modifier variants
    pub const LSHIFT: u16 = 0xA0;
    pub const RSHIFT: u16 = 0xA1;
    pub const LCONTROL: u16 = 0xA2;
    pub const RCONTROL: u16 = 0xA3;
    pub const LMENU: u16 = 0xA4; // Left Alt
    pub const RMENU: u16 = 0xA5; // Right Alt

    // OEM keys (punctuation - US keyboard layout)
    pub const OEM_1: u16 = 0xBA; // ;:
    pub const OEM_PLUS: u16 = 0xBB; // =+
    pub const OEM_COMMA: u16 = 0xBC; // ,<
    pub const OEM_MINUS: u16 = 0xBD; // -_
    pub const OEM_PERIOD: u16 = 0xBE; // .>
    pub const OEM_2: u16 = 0xBF; // /?
    pub const OEM_3: u16 = 0xC0; // `~
    pub const OEM_4: u16 = 0xDB; // [{
    pub const OEM_5: u16 = 0xDC; // \|
    pub const OEM_6: u16 = 0xDD; // ]}
    pub const OEM_7: u16 = 0xDE; // '"
    pub const OEM_8: u16 = 0xDF;
    pub const OEM_102: u16 = 0xE2; // ISO extra key (between Left Shift and Z)
}

/// Scan Code Set 1 make codes for the punctuation key positions.
///
/// Scancodes identify the physical key position and never move with the
/// active layout, matching macOS virtual keycodes and evdev codes (both
/// positional). Names follow the US-layout legend for each position.
#[allow(dead_code)]
mod sc {
    pub const GRAVE: u32 = 0x29; // left of 1 (US `~, UK `¬)
    pub const MINUS: u32 = 0x0C;
    pub const EQUAL: u32 = 0x0D;
    pub const LEFT_BRACKET: u32 = 0x1A;
    pub const RIGHT_BRACKET: u32 = 0x1B;
    pub const BACKSLASH: u32 = 0x2B; // ANSI \| above Enter; ISO #~ next to Enter
    pub const SEMICOLON: u32 = 0x27;
    pub const QUOTE: u32 = 0x28; // right of ; (US '", UK '@)
    pub const COMMA: u32 = 0x33;
    pub const PERIOD: u32 = 0x34;
    pub const SLASH: u32 = 0x35;
    pub const ISO_SECTION: u32 = 0x56; // ISO extra key between Left Shift and Z
    pub const LCONTROL: u32 = 0x1D;
}

/// Scancode of the phantom Left Ctrl that Windows synthesizes around every
/// Right Alt press/release on AltGr layouts: LCtrl's make code (0x1D) with
/// bit 9 set. Captured live on UK layout — the pair arrives with identical
/// timestamps. Real Left Ctrl presses carry plain 0x1D.
pub const ALTGR_PHANTOM_CTRL_SCANCODE: u32 = 0x21D;

/// True for the synthetic Left Ctrl event injected by AltGr handling.
/// These carry no user intent — the user pressed only Right Alt — so the
/// listener drops them entirely.
pub fn is_altgr_phantom_ctrl(vk_code: u16, scan_code: u32) -> bool {
    vk_code == vk::LCONTROL && scan_code == ALTGR_PHANTOM_CTRL_SCANCODE
}

/// True for the VKs Windows assigns to layout-dependent punctuation keys.
fn is_punctuation_vk(vk_code: u16) -> bool {
    matches!(
        vk_code,
        vk::OEM_1
            | vk::OEM_PLUS
            | vk::OEM_COMMA
            | vk::OEM_MINUS
            | vk::OEM_PERIOD
            | vk::OEM_2
            | vk::OEM_3
            | vk::OEM_4
            | vk::OEM_5
            | vk::OEM_6
            | vk::OEM_7
            | vk::OEM_8
            | vk::OEM_102
    )
}

/// Map a punctuation key position (Scan Code Set 1, non-extended) to a Key.
fn punctuation_scancode_to_key(scan_code: u32) -> Option<Key> {
    match scan_code {
        sc::GRAVE => Some(Key::Grave),
        sc::MINUS => Some(Key::Minus),
        sc::EQUAL => Some(Key::Equal),
        sc::LEFT_BRACKET => Some(Key::LeftBracket),
        sc::RIGHT_BRACKET => Some(Key::RightBracket),
        // The ISO #~ key (next to Enter on UK boards) shares scancode 0x2B
        // with the ANSI \| key — they are the same logical position (macOS
        // likewise reports both as kVK_ANSI_Backslash), so both map to
        // Backslash.
        sc::BACKSLASH => Some(Key::Backslash),
        sc::SEMICOLON => Some(Key::Semicolon),
        sc::QUOTE => Some(Key::Quote),
        sc::COMMA => Some(Key::Comma),
        sc::PERIOD => Some(Key::Period),
        sc::SLASH => Some(Key::Slash),
        sc::ISO_SECTION => Some(Key::Section),
        _ => None,
    }
}

/// Map a Windows keyboard event to a Key.
///
/// Punctuation keys are mapped from their scancode (physical position):
/// their virtual keycodes (VK_OEM_*) move with the active layout, so e.g.
/// on UK both the `¬ key (VK_OEM_8) and the '@ key (VK_OEM_3) would report
/// Grave under a VK mapping, and Quote would be unreachable from the
/// apostrophe key. Positional mapping matches what macOS virtual keycodes
/// and evdev codes already give the other backends.
///
/// Everything else — letters, digits, F-keys, nav, numpad — stays on the VK
/// mapping: those VKs are stable across layouts, and moving letters to
/// positional mapping (e.g. AZERTY physical-Q reporting A) is a bigger
/// behavioral decision deliberately deferred.
///
/// The scancode path is gated on the VK being an OEM punctuation key so a
/// layout that places a letter on one of these positions (e.g. AZERTY M on
/// the semicolon position) keeps reporting the letter.
pub fn map_key(vk_code: u16, scan_code: u32, is_extended: bool) -> Option<Key> {
    // Extended scancodes are a separate namespace (e.g. E0 35 is numpad
    // divide, not the slash position), so only non-extended events take the
    // positional path.
    if !is_extended && is_punctuation_vk(vk_code) {
        if let Some(key) = punctuation_scancode_to_key(scan_code) {
            return Some(key);
        }
        // OEM VK on a position we don't know (layout put punctuation on an
        // unusual key): fall back to the VK mapping as a best effort.
    }
    vk_to_key(vk_code, is_extended)
}

/// Convert Windows virtual key code to Key.
///
/// The `is_extended` flag distinguishes keys like numpad Enter from main Enter.
///
/// Punctuation arms here are the US-layout interpretation, reached only as
/// `map_key`'s fallback when the scancode position is unknown.
fn vk_to_key(vk_code: u16, is_extended: bool) -> Option<Key> {
    match vk_code {
        // Letters A-Z (0x41-0x5A)
        0x41 => Some(Key::A),
        0x42 => Some(Key::B),
        0x43 => Some(Key::C),
        0x44 => Some(Key::D),
        0x45 => Some(Key::E),
        0x46 => Some(Key::F),
        0x47 => Some(Key::G),
        0x48 => Some(Key::H),
        0x49 => Some(Key::I),
        0x4A => Some(Key::J),
        0x4B => Some(Key::K),
        0x4C => Some(Key::L),
        0x4D => Some(Key::M),
        0x4E => Some(Key::N),
        0x4F => Some(Key::O),
        0x50 => Some(Key::P),
        0x51 => Some(Key::Q),
        0x52 => Some(Key::R),
        0x53 => Some(Key::S),
        0x54 => Some(Key::T),
        0x55 => Some(Key::U),
        0x56 => Some(Key::V),
        0x57 => Some(Key::W),
        0x58 => Some(Key::X),
        0x59 => Some(Key::Y),
        0x5A => Some(Key::Z),

        // Numbers 0-9 (0x30-0x39)
        0x30 => Some(Key::Num0),
        0x31 => Some(Key::Num1),
        0x32 => Some(Key::Num2),
        0x33 => Some(Key::Num3),
        0x34 => Some(Key::Num4),
        0x35 => Some(Key::Num5),
        0x36 => Some(Key::Num6),
        0x37 => Some(Key::Num7),
        0x38 => Some(Key::Num8),
        0x39 => Some(Key::Num9),

        // Media keys
        0xB3 => Some(Key::PlayPause),
        0xB2 => Some(Key::Stop),
        0xB1 => Some(Key::PrevTrack),
        0xB0 => Some(Key::NextTrack),

        // Numpad keys - these are always distinct from main keys
        vk::NUMPAD0 => Some(Key::Keypad0),
        vk::NUMPAD1 => Some(Key::Keypad1),
        vk::NUMPAD2 => Some(Key::Keypad2),
        vk::NUMPAD3 => Some(Key::Keypad3),
        vk::NUMPAD4 => Some(Key::Keypad4),
        vk::NUMPAD5 => Some(Key::Keypad5),
        vk::NUMPAD6 => Some(Key::Keypad6),
        vk::NUMPAD7 => Some(Key::Keypad7),
        vk::NUMPAD8 => Some(Key::Keypad8),
        vk::NUMPAD9 => Some(Key::Keypad9),
        vk::MULTIPLY => Some(Key::KeypadMultiply),
        vk::ADD => Some(Key::KeypadPlus),
        vk::SUBTRACT => Some(Key::KeypadMinus),
        vk::DECIMAL => Some(Key::KeypadDecimal),
        vk::DIVIDE => Some(Key::KeypadDivide),

        // Return - extended flag means numpad enter
        vk::RETURN if is_extended => Some(Key::KeypadEnter),
        vk::RETURN => Some(Key::Return),

        // Function keys
        vk::F1 => Some(Key::F1),
        vk::F2 => Some(Key::F2),
        vk::F3 => Some(Key::F3),
        vk::F4 => Some(Key::F4),
        vk::F5 => Some(Key::F5),
        vk::F6 => Some(Key::F6),
        vk::F7 => Some(Key::F7),
        vk::F8 => Some(Key::F8),
        vk::F9 => Some(Key::F9),
        vk::F10 => Some(Key::F10),
        vk::F11 => Some(Key::F11),
        vk::F12 => Some(Key::F12),
        vk::F13 => Some(Key::F13),
        vk::F14 => Some(Key::F14),
        vk::F15 => Some(Key::F15),
        vk::F16 => Some(Key::F16),
        vk::F17 => Some(Key::F17),
        vk::F18 => Some(Key::F18),
        vk::F19 => Some(Key::F19),
        vk::F20 => Some(Key::F20),
        vk::F21 => Some(Key::F21),
        vk::F22 => Some(Key::F22),
        vk::F23 => Some(Key::F23),
        vk::F24 => Some(Key::F24),

        // Special keys
        vk::BACK => Some(Key::Delete), // Backspace
        vk::DELETE => Some(Key::ForwardDelete),
        vk::INSERT => Some(Key::Insert),
        vk::TAB => Some(Key::Tab),
        vk::ESCAPE => Some(Key::Escape),
        vk::SPACE => Some(Key::Space),
        vk::PRIOR => Some(Key::PageUp),
        vk::NEXT => Some(Key::PageDown),
        vk::END => Some(Key::End),
        vk::HOME => Some(Key::Home),
        vk::LEFT => Some(Key::LeftArrow),
        vk::UP => Some(Key::UpArrow),
        vk::RIGHT => Some(Key::RightArrow),
        vk::DOWN => Some(Key::DownArrow),

        // Punctuation (OEM keys - US layout interpretation; normally
        // superseded by the positional mapping in `map_key`)
        vk::OEM_1 => Some(Key::Semicolon),
        vk::OEM_PLUS => Some(Key::Equal),
        vk::OEM_COMMA => Some(Key::Comma),
        vk::OEM_MINUS => Some(Key::Minus),
        vk::OEM_PERIOD => Some(Key::Period),
        vk::OEM_2 => Some(Key::Slash),
        vk::OEM_3 => Some(Key::Grave),
        vk::OEM_4 => Some(Key::LeftBracket),
        vk::OEM_5 => Some(Key::Backslash),
        vk::OEM_6 => Some(Key::RightBracket),
        vk::OEM_7 => Some(Key::Quote),
        vk::OEM_8 => Some(Key::Grave),
        vk::OEM_102 => Some(Key::Section), // ISO extra key

        // Lock keys
        vk::CAPITAL => Some(Key::CapsLock),
        vk::NUMLOCK => Some(Key::NumLock),
        vk::SCROLL => Some(Key::ScrollLock),

        _ => None,
    }
}

/// Convert Windows virtual key code to side-specific Modifier
///
/// AltGr phantom Ctrl events must be filtered with `is_altgr_phantom_ctrl`
/// before this is consulted; by VK alone they look like real Left Ctrl.
pub fn vk_to_modifier(vk_code: u16) -> Option<Modifiers> {
    match vk_code {
        vk::LSHIFT => Some(Modifiers::SHIFT_LEFT),
        vk::RSHIFT => Some(Modifiers::SHIFT_RIGHT),
        vk::SHIFT => Some(Modifiers::SHIFT_LEFT), // generic falls back to left
        vk::LCONTROL => Some(Modifiers::CTRL_LEFT),
        vk::RCONTROL => Some(Modifiers::CTRL_RIGHT),
        vk::CONTROL => Some(Modifiers::CTRL_LEFT),
        vk::LMENU => Some(Modifiers::OPT_LEFT),
        vk::RMENU => Some(Modifiers::OPT_RIGHT),
        vk::MENU => Some(Modifiers::OPT_LEFT),
        vk::LWIN => Some(Modifiers::CMD_LEFT),
        vk::RWIN => Some(Modifiers::CMD_RIGHT),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Punctuation keys map by physical position, whatever OEM VK the active
    // layout assigns them. VK/scancode pairs below are as captured live
    // (diagnostic-session-2026-07-06.md) or per the US/UK layout tables.

    #[test]
    fn grave_position_maps_to_grave_on_us_and_uk() {
        // US `~ key: VK_OEM_3. UK `¬ key: VK_OEM_8. Same position.
        assert_eq!(map_key(vk::OEM_3, sc::GRAVE, false), Some(Key::Grave));
        assert_eq!(map_key(vk::OEM_8, sc::GRAVE, false), Some(Key::Grave));
    }

    #[test]
    fn quote_position_maps_to_quote_on_us_and_uk() {
        // US '" key: VK_OEM_7. UK '@ key: VK_OEM_3 — the VK that made both
        // UK keys report Grave before positional mapping (cjpais/Handy#1516).
        assert_eq!(map_key(vk::OEM_7, sc::QUOTE, false), Some(Key::Quote));
        assert_eq!(map_key(vk::OEM_3, sc::QUOTE, false), Some(Key::Quote));
    }

    #[test]
    fn iso_hash_key_maps_to_backslash() {
        // UK #~ key (VK_OEM_7, next to Enter) shares position 0x2B with the
        // ANSI \| key.
        assert_eq!(
            map_key(vk::OEM_7, sc::BACKSLASH, false),
            Some(Key::Backslash)
        );
        assert_eq!(
            map_key(vk::OEM_5, sc::BACKSLASH, false),
            Some(Key::Backslash)
        );
    }

    #[test]
    fn remaining_punctuation_positions_map_positionally() {
        assert_eq!(map_key(vk::OEM_MINUS, sc::MINUS, false), Some(Key::Minus));
        assert_eq!(map_key(vk::OEM_PLUS, sc::EQUAL, false), Some(Key::Equal));
        assert_eq!(
            map_key(vk::OEM_4, sc::LEFT_BRACKET, false),
            Some(Key::LeftBracket)
        );
        assert_eq!(
            map_key(vk::OEM_6, sc::RIGHT_BRACKET, false),
            Some(Key::RightBracket)
        );
        assert_eq!(
            map_key(vk::OEM_1, sc::SEMICOLON, false),
            Some(Key::Semicolon)
        );
        assert_eq!(map_key(vk::OEM_COMMA, sc::COMMA, false), Some(Key::Comma));
        assert_eq!(
            map_key(vk::OEM_PERIOD, sc::PERIOD, false),
            Some(Key::Period)
        );
        assert_eq!(map_key(vk::OEM_2, sc::SLASH, false), Some(Key::Slash));
        assert_eq!(
            map_key(vk::OEM_102, sc::ISO_SECTION, false),
            Some(Key::Section)
        );
    }

    #[test]
    fn letters_stay_on_vk_mapping_even_on_punctuation_positions() {
        // AZERTY puts M on the semicolon position: it must stay M.
        assert_eq!(map_key(0x4D, sc::SEMICOLON, false), Some(Key::M));
        // German QWERTZ puts Z on the US Y position; letter VKs win.
        assert_eq!(map_key(0x5A, 0x15, false), Some(Key::Z));
    }

    #[test]
    fn oem_vk_on_unknown_position_falls_back_to_vk_mapping() {
        // A layout placing punctuation on a position outside the table (e.g.
        // Hungarian ö on the 0 key, scancode 0x0B) uses the US VK reading.
        assert_eq!(map_key(vk::OEM_1, 0x0B, false), Some(Key::Semicolon));
    }

    #[test]
    fn extended_events_skip_the_positional_table() {
        // E0-prefixed scancodes are a different namespace; an extended event
        // must not be misread as a punctuation position.
        assert_eq!(map_key(vk::OEM_2, sc::SLASH, true), Some(Key::Slash));
        // Numpad divide (VK_DIVIDE, E0 35) is not an OEM VK at all.
        assert_eq!(
            map_key(vk::DIVIDE, sc::SLASH, true),
            Some(Key::KeypadDivide)
        );
    }

    #[test]
    fn f21_to_f24_map() {
        assert_eq!(map_key(vk::F21, 0, false), Some(Key::F21));
        assert_eq!(map_key(vk::F22, 0, false), Some(Key::F22));
        assert_eq!(map_key(vk::F23, 0, false), Some(Key::F23));
        assert_eq!(map_key(vk::F24, 0, false), Some(Key::F24));
    }

    #[test]
    fn altgr_phantom_ctrl_is_detected() {
        assert!(is_altgr_phantom_ctrl(
            vk::LCONTROL,
            ALTGR_PHANTOM_CTRL_SCANCODE
        ));
    }

    #[test]
    fn real_ctrl_events_are_not_phantom() {
        // Real Left Ctrl: plain make code, no bit 9.
        assert!(!is_altgr_phantom_ctrl(vk::LCONTROL, sc::LCONTROL));
        // Right Ctrl never participates in AltGr synthesis.
        assert!(!is_altgr_phantom_ctrl(
            vk::RCONTROL,
            ALTGR_PHANTOM_CTRL_SCANCODE
        ));
    }
}