Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches:
- main
- 'release/**'
- 'feat/computer-key-chords'
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
merge_group:
Expand Down
4 changes: 2 additions & 2 deletions crates/webcodex-computer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2996,12 +2996,12 @@ mod key_input_runtime_tests {
.iter()
.map(|modifier| modifier.to_string())
.collect::<Vec<_>>();
for key in COMPUTER_KEY_INPUT_KEYS {
for key in webcodex_core::computer_input_contract::computer_key_input_keys() {
assert!(validate_key_input(key, &[]).is_ok());
assert!(validate_key_input(key, &modifiers).is_ok());
}
assert!(validate_key_input("tab", &["shift".to_string()]).is_ok());
assert!(validate_key_input("a", &[]).is_err());
assert!(validate_key_input("unknown", &[]).is_err());
assert!(validate_key_input("enter", &["shift".to_string(), "shift".to_string()]).is_err());
}
}
Expand Down
4 changes: 2 additions & 2 deletions crates/webcodex-computer/src/platform/macos/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1350,9 +1350,9 @@ pub(crate) fn key_input(
modifiers: &[String],
) -> Result<Value, String> {
validate_key_input(key, modifiers)?;
if webcodex_core::computer_input_contract::is_computer_letter_shortcut(key) {
if !webcodex_core::computer_input_contract::COMPUTER_KEY_INPUT_KEYS.contains(&key) {
return Err(
"invalid_request: computer letter shortcuts are supported only on Windows".into(),
"invalid_request: extended computer key chords are supported only on Windows".into(),
);
}
if !unsafe { AXIsProcessTrusted() } {
Expand Down
52 changes: 48 additions & 4 deletions crates/webcodex-computer/src/platform/windows/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,51 @@ fn windows_key_mapping(key: &str) -> Result<(VIRTUAL_KEY, bool), String> {
"page_down" => Ok((VK_NEXT, true)),
"home" => Ok((VK_HOME, true)),
"end" => Ok((VK_END, true)),
"j" => Ok((VIRTUAL_KEY(0x4A), false)),
"p" => Ok((VIRTUAL_KEY(0x50), false)),
"space" => Ok((VK_SPACE, false)),
"backspace" => Ok((VK_BACK, false)),
"delete" => Ok((VK_DELETE, true)),
"insert" => Ok((VK_INSERT, true)),
"semicolon" => Ok((VK_OEM_1, false)),
"equal" => Ok((VK_OEM_PLUS, false)),
"comma" => Ok((VK_OEM_COMMA, false)),
"minus" => Ok((VK_OEM_MINUS, false)),
"period" => Ok((VK_OEM_PERIOD, false)),
"slash" => Ok((VK_OEM_2, false)),
"backquote" => Ok((VK_OEM_3, false)),
"left_bracket" => Ok((VK_OEM_4, false)),
"backslash" => Ok((VK_OEM_5, false)),
"right_bracket" => Ok((VK_OEM_6, false)),
"quote" => Ok((VK_OEM_7, false)),
"numpad_add" => Ok((VK_ADD, false)),
"numpad_subtract" => Ok((VK_SUBTRACT, false)),
"numpad_multiply" => Ok((VK_MULTIPLY, false)),
"numpad_divide" => Ok((VK_DIVIDE, true)),
"numpad_decimal" => Ok((VK_DECIMAL, false)),
"numpad_enter" => Ok((VK_RETURN, true)),
_ if key.len() == 1 && key.as_bytes()[0].is_ascii_lowercase() => Ok((
VIRTUAL_KEY(u16::from(key.as_bytes()[0].to_ascii_uppercase())),
false,
)),
_ if key.len() == 1 && key.as_bytes()[0].is_ascii_digit() => {
Ok((VIRTUAL_KEY(u16::from(key.as_bytes()[0])), false))
}
_ if key
.strip_prefix('f')
.and_then(|n| n.parse::<u16>().ok())
.is_some_and(|n| (1..=24).contains(&n)) =>
{
let number = key[1..].parse::<u16>().expect("function key range checked");
Ok((VIRTUAL_KEY(VK_F1.0 + number - 1), false))
}
_ if key
.strip_prefix("numpad_")
.is_some_and(|n| n.len() == 1 && n.as_bytes()[0].is_ascii_digit()) =>
{
Ok((
VIRTUAL_KEY(VK_NUMPAD0.0 + u16::from(key.as_bytes()[7] - b'0')),
false,
))
}
_ => Err("invalid_request: computer key is outside the closed vocabulary".to_string()),
}
}
Expand All @@ -357,8 +400,9 @@ fn windows_modifier_key(modifier: &str) -> Result<VIRTUAL_KEY, String> {
fn validate_windows_key_input_chord(key: &str, modifiers: &[String]) -> Result<(), String> {
let has_option = modifiers.iter().any(|modifier| modifier == "option");
let has_control = modifiers.iter().any(|modifier| modifier == "control");
let escapes_exact_surface =
(has_option && matches!(key, "tab" | "escape")) || (has_control && key == "escape");
let escapes_exact_surface = (has_option && matches!(key, "tab" | "escape"))
|| (has_control && key == "escape")
|| (has_control && has_option && key == "delete");
if escapes_exact_surface {
return Err(
"key_input_failed: Windows system-level key chord is outside the exact-surface input contract"
Expand Down
11 changes: 7 additions & 4 deletions crates/webcodex-computer/src/platform/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,13 @@ use windows::{
GetAsyncKeyState, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT,
KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, MOUSEEVENTF_ABSOLUTE,
MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_VIRTUALDESK,
MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY, VK_CONTROL, VK_DOWN, VK_END, VK_ESCAPE,
VK_HOME, VK_LBUTTON, VK_LCONTROL, VK_LEFT, VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MBUTTON,
VK_MENU, VK_NEXT, VK_PRIOR, VK_RBUTTON, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU,
VK_RSHIFT, VK_RWIN, VK_SHIFT, VK_TAB, VK_UP, VK_XBUTTON1, VK_XBUTTON2,
MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY, VK_ADD, VK_BACK, VK_CONTROL, VK_DECIMAL,
VK_DELETE, VK_DIVIDE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT,
VK_LBUTTON, VK_LCONTROL, VK_LEFT, VK_LMENU, VK_LSHIFT, VK_LWIN, VK_MBUTTON, VK_MENU,
VK_MULTIPLY, VK_NEXT, VK_NUMPAD0, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5,
VK_OEM_6, VK_OEM_7, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_PRIOR,
VK_RBUTTON, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SHIFT,
VK_SPACE, VK_SUBTRACT, VK_TAB, VK_UP, VK_XBUTTON1, VK_XBUTTON2,
},
UI::Shell::{
Common::ITEMIDLIST, FOLDERID_AppsFolder, IEnumIDList, ILCombine, ILGetSize,
Expand Down
88 changes: 86 additions & 2 deletions crates/webcodex-computer/src/windows_uia_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ fn computer_windows_key_input_native_plan_is_closed_and_releases_modifiers_in_re
"{key}"
);
}
assert!(platform::test_windows_key_input_plan("a", &[]).is_err());
assert!(platform::test_windows_key_input_plan("unknown", &[]).is_err());

let option = platform::test_windows_key_input_plan("arrow_left", &["option".to_string()])
.expect("Windows option modifier must map to Alt for exact-surface chords");
Expand Down Expand Up @@ -791,7 +791,12 @@ fn computer_windows_key_input_letter_chords_have_complete_bounded_sequences() {
vec!["control".into(), "shift".into()],
vec!["control".into(), "option".into()],
] {
assert!(platform::test_windows_key_input_plan(key, &modifiers).is_err());
assert_eq!(
platform::test_windows_key_input_plan(key, &modifiers).is_ok(),
!modifiers
.iter()
.any(|modifier: &String| modifier == "command")
);
}
for held in [vk, 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0x5B, 0x5C] {
assert!(platform::test_windows_keyboard_state_guard(key, Some(held)).is_err());
Expand All @@ -807,6 +812,85 @@ fn computer_windows_key_input_letter_chords_have_complete_bounded_sequences() {
}
}

#[test]
fn computer_windows_general_chords_have_bounded_balanced_native_plans() {
for key in webcodex_core::computer_input_contract::computer_key_input_keys() {
for mask in 0..8 {
let modifiers: Vec<_> = ["shift", "control", "option"]
.iter()
.enumerate()
.filter(|(index, _)| mask & (1 << index) != 0)
.map(|(_, name)| (*name).to_string())
.collect();
let blocked = (modifiers.iter().any(|m| m == "option")
&& matches!(key, "tab" | "escape"))
|| (modifiers.iter().any(|m| m == "control") && key == "escape")
|| (key == "delete"
&& modifiers.iter().any(|m| m == "control")
&& modifiers.iter().any(|m| m == "option"));
let result = platform::test_windows_key_input_plan(key, &modifiers);
if blocked {
assert!(result.unwrap_err().starts_with("key_input_failed:"));
continue;
}
let plan = result.unwrap_or_else(|error| panic!("{key} {modifiers:?}: {error}"));
assert_eq!(plan.len(), 2 * modifiers.len() + 2);
assert!(plan.len() <= 8);
for index in 0..plan.len() / 2 {
let down = plan[index];
let up = plan[plan.len() - 1 - index];
assert!(!down.1);
assert!(up.1);
assert_eq!((down.0, down.2), (up.0, up.2), "{key} {modifiers:?}");
}
}
let native_key = platform::test_windows_key_input_plan(key, &[]).unwrap()[0].0;
assert!(platform::test_windows_keyboard_state_guard(key, Some(native_key)).is_err());
}
for (key, vk, extended) in [
("a", 0x41, false),
("z", 0x5A, false),
("0", 0x30, false),
("9", 0x39, false),
("f1", 0x70, false),
("f24", 0x87, false),
("space", 0x20, false),
("backspace", 0x08, false),
("delete", 0x2E, true),
("insert", 0x2D, true),
("semicolon", 0xBA, false),
("equal", 0xBB, false),
("comma", 0xBC, false),
("minus", 0xBD, false),
("period", 0xBE, false),
("slash", 0xBF, false),
("backquote", 0xC0, false),
("left_bracket", 0xDB, false),
("backslash", 0xDC, false),
("right_bracket", 0xDD, false),
("quote", 0xDE, false),
("numpad_0", 0x60, false),
("numpad_9", 0x69, false),
("numpad_add", 0x6B, false),
("numpad_subtract", 0x6D, false),
("numpad_multiply", 0x6A, false),
("numpad_divide", 0x6F, true),
("numpad_decimal", 0x6E, false),
("numpad_enter", 0x0D, true),
] {
assert_eq!(
platform::test_windows_key_input_plan(key, &[]).unwrap(),
vec![(vk, false, extended), (vk, true, extended)],
"{key}"
);
}
for inserted in 1..8 {
assert!(platform::test_windows_send_input_count(inserted, 8)
.unwrap_err()
.starts_with("outcome_unknown:"));
}
}

#[test]
fn computer_windows_key_input_zero_is_deterministic_and_partial_injection_is_unknown() {
assert!(platform::test_windows_send_input_count(2, 2).is_ok());
Expand Down
Loading
Loading