Type: Alt + numeric-keypad codes type their character on Windows; type.insert takes a code (#5108)

This commit is contained in:
Don Omondi
2026-11-07 00:17:15 +07:00
committed by bflatastic
parent 7913c481aa
commit b8ec5f4b23
6 changed files with 304 additions and 5 deletions
+3
View File
@@ -51,6 +51,9 @@ impl eframe::App for App {
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(windows)]
windows_stylus::raw_input_hook(raw);
// The characters Alt + keypad codes typed (#5108).
#[cfg(windows)]
super::windows_alt_codes::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
// that no mouse button is down.
+8 -2
View File
@@ -56,6 +56,7 @@ mod system_fonts;
mod tablet;
mod window;
mod window_frame;
mod windows_alt_codes;
#[cfg(windows)]
mod windows_cores;
mod windows_stylus;
@@ -314,13 +315,18 @@ fn main() -> std::process::ExitCode {
};
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
// is down; the message hook notes each pointer contact and whether it starts with the barrel
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
// characters Alt + keypad codes type (`windows_alt_codes`).
#[cfg(windows)]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
let mut options = options;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_msg_hook(windows_stylus::msg_hook);
builder.with_msg_hook(|msg| {
// Alt + keypad codes (#5108), which winit drops; neither hook consumes a message.
windows_alt_codes::msg_hook(msg);
windows_stylus::msg_hook(msg)
});
}));
options
};
+225
View File
@@ -0,0 +1,225 @@
//! Alt + numeric-keypad codes on Windows (#5108): holding Alt, typing a decimal code on the
//! numeric keypad (NumLock on) and releasing Alt types that character (`Alt+0176` → `°`).
//!
//! Windows does the work: when Alt comes up after keypad digits, `TranslateMessage` posts a
//! `WM_CHAR` holding the character the code stands for (in the system's ANSI code page for a code
//! with a leading 0, else its OEM code page), the convention every Windows text field follows
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input>). winit
//! 0.30 reads a `WM_CHAR` only as part of the key press before it, so it drops this one, which
//! follows a key release, and egui never sees the text. winit's message hook ([`msg_hook`]) sees
//! the message before winit handles it: [`AltCodes`] notes keypad digits typed with Alt held, takes
//! the character that follows Alt's release, and [`raw_input_hook`] hands it to the frame as typed
//! text, so the Type tool (or a panel's text field) types it like any other character, in the
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
//! so they don't run a shortcut bound to Alt and a digit.
//!
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
//! platforms, whose systems have no such convention, can't tell them apart either.
#![cfg_attr(not(windows), allow(dead_code))]
use std::cell::RefCell;
use egui::{Event, Key};
const WM_KEYDOWN: u32 = 0x0100;
const WM_KEYUP: u32 = 0x0101;
const WM_CHAR: u32 = 0x0102;
const WM_SYSKEYDOWN: u32 = 0x0104;
const WM_SYSKEYUP: u32 = 0x0105;
const WM_SYSCHAR: u32 = 0x0106;
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
/// are Insert, End, … and type no code.
const VK_NUMPAD0: usize = 0x60;
const VK_NUMPAD9: usize = 0x69;
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
thread_local! {
/// The UI thread's sequence: [`msg_hook`] and [`raw_input_hook`] both run on it.
static CODES: RefCell<AltCodes> = RefCell::new(AltCodes::default());
}
/// Where an Alt + keypad code stands, from the keyboard messages of the thread's queue.
#[derive(Default)]
struct AltCodes {
/// Keypad digits typed while Alt has been held.
digits: usize,
/// Alt came up after keypad digits: the next character message is the code's character.
armed: bool,
/// A high surrogate waiting for its low half.
high: Option<u16>,
/// Characters the codes typed, for the next frame.
typed: String,
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
/// as Alt and a digit: taken out of the frame.
keypad_events: usize,
}
impl AltCodes {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
fn message(&mut self, message: u32, wparam: usize) {
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
match message {
// Windows sends a key pressed with Alt held as a system key.
WM_SYSKEYDOWN if keypad => {
self.digits += 1;
self.keypad_events += 1;
self.armed = false;
}
WM_SYSKEYUP if keypad => self.keypad_events += 1,
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
// Any other key ends the sequence and keeps its own meaning.
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
WM_KEYUP | WM_SYSKEYUP if ALT_KEYS.contains(&wparam) => {
self.armed = self.digits > 0;
self.digits = 0;
}
WM_CHAR | WM_SYSCHAR if self.armed => {
let unit = (wparam & 0xffff) as u16;
if (0xd800..0xdc00).contains(&unit) {
self.high = Some(unit);
return;
}
let units: Vec<u16> = self.high.take().into_iter().chain([unit]).collect();
self.typed.extend(char::decode_utf16(units).filter_map(Result::ok).filter(|c| !c.is_control()));
self.armed = false;
}
_ => {}
}
}
fn reset(&mut self) {
self.digits = 0;
self.armed = false;
self.high = None;
}
/// Hand the frame `events` the characters typed since the last frame, as typed text, and take
/// out the keypad digits typed with Alt held.
fn drain(&mut self, events: &mut Vec<Event>) {
let mut keypad = std::mem::take(&mut self.keypad_events);
if keypad > 0 {
events.retain(|e| {
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
if digit && keypad > 0 {
keypad -= 1;
return false;
}
true
});
}
if !self.typed.is_empty() {
events.push(Event::Text(std::mem::take(&mut self.typed)));
}
}
}
/// winit's message hook for the keyboard: notes Alt + keypad codes and the characters they type.
/// It never consumes the message.
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn msg_hook(msg: *const std::ffi::c_void) {
use windows::Win32::UI::WindowsAndMessaging::MSG;
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
// call; `as_ref` gives `None` for a null pointer.
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
return;
};
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
CODES.with(|c| {
if let Ok(mut c) = c.try_borrow_mut() {
c.message(msg.message, msg.wParam.0);
}
});
}
}
/// Before egui reads the main window's frame: the characters Alt + keypad codes typed, as text.
pub fn raw_input_hook(raw: &mut egui::RawInput) {
CODES.with(|c| {
if let Ok(mut c) = c.try_borrow_mut() {
c.drain(&mut raw.events);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
const ALT: usize = 0x12;
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
c.message(WM_SYSKEYDOWN, ALT);
for d in code.bytes() {
let vk = VK_NUMPAD0 + usize::from(d - b'0');
c.message(WM_SYSKEYDOWN, vk);
c.message(WM_SYSKEYUP, vk);
}
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(posted));
}
fn key(key: Key, alt: bool, pressed: bool) -> Event {
let modifiers = egui::Modifiers { alt, ..Default::default() };
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
}
#[test]
fn an_alt_code_types_its_character_when_alt_comes_up() {
let mut c = AltCodes::default();
alt_code(&mut c, "0176", 0xB0);
alt_code(&mut c, "0151", 0x2014);
let mut events = vec![key(Key::Num0, true, true), key(Key::Num0, true, false), key(Key::Num1, false, true)];
c.drain(&mut events);
// The keypad digits typed with Alt are gone (eight presses and eight releases, two here);
// the digit typed without Alt stays, and the characters follow.
assert_eq!(events, vec![key(Key::Num1, false, true), Event::Text("°—".into())]);
let mut events = vec![key(Key::Num0, true, true)];
c.drain(&mut events);
assert_eq!(events.len(), 1, "counts don't carry over to a later frame");
}
#[test]
fn a_character_without_a_code_before_it_is_left_to_winit() {
let mut c = AltCodes::default();
// A plain key press's character (winit types it), and Alt pressed and released alone.
c.message(WM_KEYDOWN, 0x41);
c.message(WM_CHAR, usize::from(b'a'));
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(b'b'));
let mut events = vec![];
c.drain(&mut events);
assert!(events.is_empty(), "{events:?}");
}
#[test]
fn another_key_while_alt_is_held_cancels_the_code() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
// Alt+F: a shortcut, not a code.
c.message(WM_SYSKEYDOWN, 0x46);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0x41);
let mut events = vec![];
c.drain(&mut events);
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
}
#[test]
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
let mut c = AltCodes::default();
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0xD83D);
c.message(WM_CHAR, 0xDE00);
let mut events = vec![];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("😀".into())]);
}
}