From 0cc7c6e7015062957bdc5e824915bdf8670f48c2 Mon Sep 17 00:00:00 2001 From: Cyannide Date: Thu, 13 Aug 2026 12:05:24 -0500 Subject: [PATCH] Resolve the text input shortcut layout at runtime on wasm COMMAND and WORD were compile-time consts keyed on cfg!(target_os) -- correct on native, wrong on wasm32, where target_os is "unknown" and one binary serves every host OS. macOS browser users got the Ctrl layout: Cmd-C/X/V/A matched no arm and were silently dropped, word navigation wanted Ctrl instead of Option, and the macOS-only arms (Cmd-arrow line start/end, Cmd-up/down text start/end) were cfg'd out entirely. mac_host() keeps the cfg! answer off-wasm and asks the browser once (navigator.platform, UA fallback, cached in a OnceLock). The layout consts become locals and the affected patterns become guards; arm order and native behavior are unchanged, and wasm now picks the same arms a native build of the host OS would. Fixes #25340 (the copy/cut/paste half of it; the Chrome permission prompt on paste is a separate issue in bevy_clipboard/winit). --- crates/bevy_ui_widgets/Cargo.toml | 6 ++ crates/bevy_ui_widgets/src/text_input.rs | 118 ++++++++++++++--------- 2 files changed, 81 insertions(+), 43 deletions(-) diff --git a/crates/bevy_ui_widgets/Cargo.toml b/crates/bevy_ui_widgets/Cargo.toml index b48c86b8e6132..057f1d707547a 100644 --- a/crates/bevy_ui_widgets/Cargo.toml +++ b/crates/bevy_ui_widgets/Cargo.toml @@ -21,6 +21,7 @@ bevy_input_focus = { path = "../bevy_input_focus", version = "0.20.0-dev", featu bevy_log = { path = "../bevy_log", version = "0.20.0-dev" } bevy_math = { path = "../bevy_math", version = "0.20.0-dev" } bevy_picking = { path = "../bevy_picking", version = "0.20.0-dev" } +bevy_platform = { path = "../bevy_platform", version = "0.20.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.20.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.20.0-dev" } bevy_text = { path = "../bevy_text", version = "0.20.0-dev" } @@ -32,6 +33,11 @@ accesskit = "0.24" parley = { version = "0.11.0", default-features = false } smol_str = "0.2" +# The shortcut layout (Cmd vs Ctrl) depends on the HOST os, which on wasm is +# only knowable at runtime -- see `mac_host` in text_input. +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["Window", "Navigator"] } + [features] default = [] diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index ae59d3bec7c75..6ab0c4699c0f1 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -37,20 +37,35 @@ const SUPER: u8 = 1; const CTRL: u8 = 2; const ALT: u8 = 4; const SHIFT: u8 = 8; -const COMMAND: u8 = if cfg!(target_os = "macos") { - SUPER -} else { - CTRL -}; -// Modifier key for word-level navigation and selection. Alt on macOS, Control otherwise. -const WORD: u8 = if cfg!(target_os = "macos") { ALT } else { CTRL }; -const SHIFT_WORD: u8 = SHIFT | WORD; -#[cfg(target_os = "macos")] const SHIFT_SUPER: u8 = SHIFT | SUPER; -const SHIFT_COMMAND: u8 = SHIFT | COMMAND; -#[cfg(not(target_os = "macos"))] const SHIFT_ALT: u8 = SHIFT | ALT; +/// Whether shortcuts follow the macOS layout (Cmd as the command key, Option +/// for word navigation). Compile-time everywhere except wasm32, where one +/// binary serves every host OS and the browser has to be asked at runtime. +fn mac_host() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + cfg!(target_os = "macos") + } + #[cfg(target_arch = "wasm32")] + { + use bevy_platform::sync::OnceLock; + static MAC: OnceLock = OnceLock::new(); + *MAC.get_or_init(|| { + web_sys::window().is_some_and(|w| { + let nav = w.navigator(); + // platform() is deprecated but universally shipped; fall back + // to the UA string. "iP" covers iPhone/iPad with keyboards. + let platform = nav.platform().unwrap_or_default(); + platform.starts_with("Mac") + || platform.starts_with("iP") + || (platform.is_empty() && nav.user_agent().unwrap_or_default().contains("Mac")) + }) + }) + } +} + /// Editable text widget. #[derive(Component, Clone, Default, Reflect)] #[require(EditableText, AccessibilityNode(accesskit::Node::new(Role::TextInput)))] @@ -134,6 +149,16 @@ fn on_focused_keyboard_input( let shift_pressed = (mod_flags & SHIFT) != 0; + // Runtime values rather than consts (and guards rather than patterns + // below): on wasm the layout depends on the HOST os, not the compile + // target. See `mac_host`. + let mac = mac_host(); + let command = if mac { SUPER } else { CTRL }; + // Modifier key for word-level navigation and selection. Alt on macOS, Control otherwise. + let word = if mac { ALT } else { CTRL }; + let shift_command = SHIFT | command; + let shift_word = SHIFT | word; + let mut should_propagate = true; let mut queue_edit = |edit: TextEdit| { @@ -150,54 +175,61 @@ fn on_focused_keyboard_input( (NONE, Key::Copy) => queue_edit(TextEdit::Copy), (NONE, Key::Cut) => queue_edit(TextEdit::Cut), (NONE, Key::Paste) => queue_edit(TextEdit::Paste), - (COMMAND, Key::Character(_)) - if matches_edit_shortcut(&keyboard_input.input, "a", KeyCode::KeyA) => + (m, Key::Character(_)) + if m == command && matches_edit_shortcut(&keyboard_input.input, "a", KeyCode::KeyA) => { queue_edit(TextEdit::SelectAll); } - (COMMAND, Key::Character(_)) - if matches_edit_shortcut(&keyboard_input.input, "c", KeyCode::KeyC) => + (m, Key::Character(_)) + if m == command && matches_edit_shortcut(&keyboard_input.input, "c", KeyCode::KeyC) => { queue_edit(TextEdit::Copy); } - (COMMAND, Key::Character(_)) - if matches_edit_shortcut(&keyboard_input.input, "x", KeyCode::KeyX) => + (m, Key::Character(_)) + if m == command && matches_edit_shortcut(&keyboard_input.input, "x", KeyCode::KeyX) => { queue_edit(TextEdit::Cut); } - (COMMAND, Key::Character(_)) - if matches_edit_shortcut(&keyboard_input.input, "v", KeyCode::KeyV) => + (m, Key::Character(_)) + if m == command && matches_edit_shortcut(&keyboard_input.input, "v", KeyCode::KeyV) => { queue_edit(TextEdit::Paste); } - #[cfg(not(target_os = "macos"))] - (SHIFT, Key::Delete) => queue_edit(TextEdit::Cut), - (WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord), - (WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord), - #[cfg(target_os = "macos")] - (SUPER | SHIFT_SUPER, Key::ArrowLeft) => queue_edit(TextEdit::HardLineStart(shift_pressed)), - #[cfg(target_os = "macos")] - (SUPER | SHIFT_SUPER, Key::ArrowRight) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), - #[cfg(not(target_os = "macos"))] - (ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed)), - #[cfg(not(target_os = "macos"))] - (ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed)), - (WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed)), - (WORD | SHIFT_WORD, Key::ArrowRight) => queue_edit(TextEdit::WordRight(shift_pressed)), + (SHIFT, Key::Delete) if !mac => queue_edit(TextEdit::Cut), + (m, Key::Backspace) if m == word => queue_edit(TextEdit::BackspaceWord), + (m, Key::Delete) if m == word => queue_edit(TextEdit::DeleteWord), + (SUPER | SHIFT_SUPER, Key::ArrowLeft) if mac => { + queue_edit(TextEdit::HardLineStart(shift_pressed)); + } + (SUPER | SHIFT_SUPER, Key::ArrowRight) if mac => { + queue_edit(TextEdit::HardLineEnd(shift_pressed)); + } + (ALT | SHIFT_ALT, Key::Home) if !mac => queue_edit(TextEdit::HardLineStart(shift_pressed)), + (ALT | SHIFT_ALT, Key::End) if !mac => queue_edit(TextEdit::HardLineEnd(shift_pressed)), + (m, Key::ArrowLeft) if m == word || m == shift_word => { + queue_edit(TextEdit::WordLeft(shift_pressed)); + } + (m, Key::ArrowRight) if m == word || m == shift_word => { + queue_edit(TextEdit::WordRight(shift_pressed)); + } (NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed)), (NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed)), - #[cfg(target_os = "macos")] - (COMMAND | SHIFT_COMMAND, Key::ArrowUp) => queue_edit(TextEdit::TextStart(shift_pressed)), - #[cfg(target_os = "macos")] - (COMMAND | SHIFT_COMMAND, Key::ArrowDown) => queue_edit(TextEdit::TextEnd(shift_pressed)), + (m, Key::ArrowUp) if mac && (m == command || m == shift_command) => { + queue_edit(TextEdit::TextStart(shift_pressed)); + } + (m, Key::ArrowDown) if mac && (m == command || m == shift_command) => { + queue_edit(TextEdit::TextEnd(shift_pressed)); + } (NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed)), (NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed)), - #[cfg(not(target_os = "macos"))] - (CTRL, Key::ArrowUp) => queue_edit(TextEdit::ScrollByLines(-1.0)), - #[cfg(not(target_os = "macos"))] - (CTRL, Key::ArrowDown) => queue_edit(TextEdit::ScrollByLines(1.0)), - (COMMAND | SHIFT_COMMAND, Key::Home) => queue_edit(TextEdit::TextStart(shift_pressed)), - (COMMAND | SHIFT_COMMAND, Key::End) => queue_edit(TextEdit::TextEnd(shift_pressed)), + (CTRL, Key::ArrowUp) if !mac => queue_edit(TextEdit::ScrollByLines(-1.0)), + (CTRL, Key::ArrowDown) if !mac => queue_edit(TextEdit::ScrollByLines(1.0)), + (m, Key::Home) if m == command || m == shift_command => { + queue_edit(TextEdit::TextStart(shift_pressed)); + } + (m, Key::End) if m == command || m == shift_command => { + queue_edit(TextEdit::TextEnd(shift_pressed)); + } (NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed)), (NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed)), (NONE, Key::Backspace) => queue_edit(TextEdit::Backspace),