diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 423fb53fc..5d9eb457f 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -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. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 980d3826b..0078dd4b4 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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 }; diff --git a/apps/vectorcraft/src/windows_alt_codes.rs b/apps/vectorcraft/src/windows_alt_codes.rs new file mode 100644 index 000000000..a4ef28dbb --- /dev/null +++ b/apps/vectorcraft/src/windows_alt_codes.rs @@ -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 +//! (). 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 = 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, + /// 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 = 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) { + 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::().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())]); + } +} diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 2056ddfb4..f5d36a7b2 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Insert Character", ["Type"], None, - "{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string, ids?|id?: object ids (default: the selection)} append to the selected text objects", + "{char: \"bullet\"|\"copyright\"|\"ellipsis\"|\"paragraph\"|\"registered\"|\"section\"|\"trademark\"|\"emDash\"|\"enDash\"|\"discretionaryHyphen\"|\"nonBreakingHyphen\"|\"doubleLeftQuote\"|\"doubleRightQuote\"|\"singleLeftQuote\"|\"singleRightQuote\"|\"emSpace\"|\"enSpace\"|\"hairSpace\"|\"sixthSpace\"|\"thinSpace\"|\"nonBreakingSpace\"|\"figureSpace\"|\"punctuationSpace\"|\"thirdSpace\"|\"quarterSpace\"|\"tab\"|\"forcedLineBreak\"|\"paragraphReturn\" | text: string | code: Windows Alt+0 code (0–255 in Windows-1252, a Unicode code point above: 176 → °, 151 → —) | unicode: hex code point (\"00B0\" or \"U+00B0\"), ids?|id?: object ids (default: the selection)} append to the selected text objects", has_selection, insert_char ), @@ -580,12 +580,42 @@ pub(crate) fn special_char(name: &str) -> Option<&'static str> { }) } +/// The character a Windows Alt + keypad code with a leading 0 types (`Alt+0176` → `°`), as +/// `type.insert {code}` takes it: 128–159 in the Windows-1252 code page (`0151` → `—`), the +/// Unicode code point otherwise (`8364` → `€`). Control characters type nothing. +/// +pub(crate) fn alt_code_char(code: u64) -> Option { + const CP1252: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, + 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + let cp = match code { + 128..=159 => u32::from(*CP1252.get(usize::try_from(code - 128).ok()?)?), + _ => u32::try_from(code).ok()?, + }; + char::from_u32(cp).filter(|c| !c.is_control() || *c == '\t') +} + +/// The character `type.insert {unicode}` names: its code point in hex, as `00B0`, `U+00B0` or +/// `0x00B0`. +fn unicode_char(u: &str) -> Option { + let u = u.trim(); + let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u); + char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t') +} + fn insert_char(s: &mut Session, p: &Value) -> Result { const C: &str = "type.insert"; let text = match (str_param(p, "char"), str_param(p, "text")) { (Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), (None, Some(t)) => t.to_string(), - _ => return Err(bad(C, "give `char` or `text`")), + _ => match (p.get("code"), str_param(p, "unicode")) { + (Some(code), _) => alt_code_char(code.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?) + .ok_or_else(|| bad(C, format!("no character has code {code}")))? + .to_string(), + (None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(), + _ => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")), + }, }; let ids = texts(s, p, C)?; // When `text` is empty and every object ends with a run of text, the document and its undo diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index ff7d362f3..173303e9d 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3462,6 +3462,39 @@ fn placeholder_and_insert_characters() { assert!(s.execute("type.insert", &json!({"char": "nope"})).is_err()); } +/// `type.insert` by code (#5108): `code` as a Windows Alt+0 code types (Windows-1252 for +/// 128–159, the code point otherwise), `unicode` as a hex code point. +#[test] +fn insert_a_character_by_its_code() { + let mut s = session(); + let t = text(&mut s, ""); + sel(&mut s, &[t]); + for p in [ + json!({"code": 176}), + json!({"code": 177}), + json!({"code": 151}), + json!({"code": 215}), + json!({"code": 8364}), + json!({"unicode": "00B0"}), + json!({"unicode": "U+2014"}), + ] { + s.execute("type.insert", &p).unwrap(); + } + assert_eq!(plain(&s, t), "°±—×€°—"); + for p in [ + json!({"code": 129}), + json!({"code": 7}), + json!({"code": -1}), + json!({"code": 1_114_112}), + json!({"unicode": "D800"}), + json!({"unicode": "zz"}), + json!({}), + ] { + assert!(s.execute("type.insert", &p).is_err(), "{p}"); + } + assert_eq!(plain(&s, t), "°±—×€°—"); +} + #[test] fn area_placeholder_fills_frame() { let mut s = session(); diff --git a/docs/type-parity.md b/docs/type-parity.md index aea0d5300..e5f51dd9d 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Snap to Glyph's glyph bounds give corners and turn with rotated type, #5112; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Alt + numeric-keypad codes type their character on Windows, and `type.insert` takes a code, #5108; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -49,6 +49,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | +| Alt + numeric-keypad codes while typing (Windows: `Alt+0176` → °) | partial | #5108: on Windows the character Windows posts when Alt comes up after keypad digits is typed at the caret like any other, in the typing's undo step, and the keypad digits typed with Alt run no shortcut (`windows_alt_codes` tests; not yet tried on a Windows machine). `type.insert {code}` (Windows Alt+0 code, Windows-1252 for 128–159) and `{unicode}` (hex code point) insert a character by its code (`menucmds::insert_a_character_by_its_code`). macOS and Linux have no such system convention and egui reports a keypad digit as the top-row digit, so there it does nothing; a floating panel window doesn't receive the character | | Caret: the arrows and Delete step over whole clusters (Thai consonant with its marks, combining accents, emoji); Backspace removes one character | done | in text with right-to-left characters the arrows move in visual order and stop on both sides of a character drawn with no width, such as a zero width non-joiner | | Touch Type tool | done | moves, scales and rotates one character through its kerning, baseline shift, scales and rotation (`text.touchType`); a character with a preceding logical character on the same line can move along the line in either reading direction; the first logical character moves only up and down. Positive `dx` follows reading order. These direction rules are VectorCraft's choices. | | Retype (identify fonts in outlines/images) | missing | Illustrator's uses a cloud model | @@ -77,6 +78,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | | 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. |