diff --git a/packages/blitz-dom/src/events/keyboard.rs b/packages/blitz-dom/src/events/keyboard.rs index d22a57996e..a61be263eb 100644 --- a/packages/blitz-dom/src/events/keyboard.rs +++ b/packages/blitz-dom/src/events/keyboard.rs @@ -1,4 +1,8 @@ -use crate::{BaseDocument, node::GeneratedTextInputEvent, util::ACTION_MOD}; +use crate::{ + BaseDocument, + node::GeneratedTextInputEvent, + util::{ACTION_MOD, action_key}, +}; use blitz_traits::node_id::NodeId; use blitz_traits::{ SmolStr, @@ -31,22 +35,18 @@ pub(crate) fn handle_key_or_input_event( // Handle copy (Ctrl+C/Cmd+C) for text selection when no text input is focused if event.state.is_pressed() { let action_mod = event.modifiers.contains(ACTION_MOD); - if action_mod { - if let Key::Character(c) = &event.key { - if c.to_lowercase() == "c" { - // Check if we have a text selection (and no focused text input) - let has_focused_text_input = doc.focus_node_id.is_some_and(|id| { - doc.get_node(id) - .and_then(|n| n.element_data()) - .is_some_and(|e| e.text_input_data().is_some()) - }); + if action_mod && action_key(event) == Some('c') { + // Check if we have a text selection (and no focused text input) + let has_focused_text_input = doc.focus_node_id.is_some_and(|id| { + doc.get_node(id) + .and_then(|n| n.element_data()) + .is_some_and(|e| e.text_input_data().is_some()) + }); - if !has_focused_text_input { - if let Some(text) = doc.get_selected_text() { - let _ = doc.shell_provider.set_clipboard_text(text); - return; - } - } + if !has_focused_text_input { + if let Some(text) = doc.get_selected_text() { + let _ = doc.shell_provider.set_clipboard_text(text); + return; } } } diff --git a/packages/blitz-dom/src/node/text.rs b/packages/blitz-dom/src/node/text.rs index 288f74aae7..00cc9a733d 100644 --- a/packages/blitz-dom/src/node/text.rs +++ b/packages/blitz-dom/src/node/text.rs @@ -6,7 +6,7 @@ use blitz_traits::{ use keyboard_types::{Key, Modifiers}; use parley::{ContentWidths, FontContext, LayoutContext}; -use crate::util::ACTION_MOD; +use crate::util::{ACTION_MOD, action_key}; #[derive(Debug, Clone, Copy, Default, PartialEq)] /// Parley Brush type for Blitz which contains the Blitz node id @@ -211,128 +211,195 @@ impl TextInputData { let is_multiline = self.is_multiline; let editor = &mut self.editor; let mut driver = editor.driver(font_ctx, layout_ctx); - match event.key { - Key::Character(c) if action_mod && matches!(c.as_str(), "c" | "x" | "v") => { - match c.to_lowercase().as_str() { - "c" => { - if let Some(text) = driver.editor.selected_text() { - let _ = shell_provider.set_clipboard_text(text.to_owned()); - } - } - "x" => { - if let Some(text) = driver.editor.selected_text() { - let _ = shell_provider.set_clipboard_text(text.to_owned()); - driver.delete_selection() - } + + if action_mod { + match action_key(&event) { + Some('c') => { + if let Some(text) = driver.editor.selected_text() { + let _ = shell_provider.set_clipboard_text(text.to_owned()); } - "v" => { - let text = shell_provider.get_clipboard_text().unwrap_or_default(); - driver.insert_or_replace_selection(&text) + return None; + } + Some('x') => { + if let Some(text) = driver.editor.selected_text() { + let _ = shell_provider.set_clipboard_text(text.to_owned()); + driver.delete_selection(); + return Some(GeneratedTextInputEvent::Input); } - _ => unreachable!(), + return None; } - - return Some(GeneratedTextInputEvent::Input); - } - Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => { - if shift { - driver.collapse_selection() - } else { - driver.select_all() + Some('v') => { + let text = shell_provider.get_clipboard_text().unwrap_or_default(); + driver.insert_or_replace_selection(&text); + return Some(GeneratedTextInputEvent::Input); } - return Some(GeneratedTextInputEvent::Select); + Some('a') => { + if shift { + driver.collapse_selection(); + } else { + driver.select_all(); + } + return Some(GeneratedTextInputEvent::Select); + } + _ => {} } + } + + match event.key { Key::ArrowLeft => { + #[cfg(target_os = "macos")] if action_mod { if shift { - driver.select_word_left() + driver.select_to_line_start(); } else { - driver.move_word_left() + driver.move_to_line_start(); } - } else if shift { - driver.select_left() - } else { - driver.move_left() + return Some(GeneratedTextInputEvent::Select); + } + + #[cfg(not(target_os = "macos"))] + { + if action_mod { + if shift { + driver.select_word_left(); + } else { + driver.move_word_left(); + } + } else if shift { + driver.select_left(); + } else { + driver.move_left(); + } + return Some(GeneratedTextInputEvent::Select); } - return Some(GeneratedTextInputEvent::Select); } Key::ArrowRight => { + #[cfg(target_os = "macos")] if action_mod { if shift { - driver.select_word_right() + driver.select_to_line_end(); } else { - driver.move_word_right() + driver.move_to_line_end(); } - } else if shift { - driver.select_right() - } else { - driver.move_right() + return Some(GeneratedTextInputEvent::Select); + } + + #[cfg(not(target_os = "macos"))] + { + if action_mod { + if shift { + driver.select_word_right(); + } else { + driver.move_word_right(); + } + } else if shift { + driver.select_right(); + } else { + driver.move_right(); + } + return Some(GeneratedTextInputEvent::Select); } - return Some(GeneratedTextInputEvent::Select); } Key::ArrowUp => { - if shift { - driver.select_up() - } else { - driver.move_up() + #[cfg(target_os = "macos")] + if action_mod { + if shift { + driver.select_to_text_start(); + } else { + driver.move_to_text_start(); + } + return Some(GeneratedTextInputEvent::Select); + } + + #[cfg(not(target_os = "macos"))] + { + if shift { + driver.select_up(); + } else { + driver.move_up(); + } + return Some(GeneratedTextInputEvent::Select); } - return Some(GeneratedTextInputEvent::Select); } Key::ArrowDown => { - if shift { - driver.select_down() + #[cfg(target_os = "macos")] + if action_mod { + if shift { + driver.select_to_text_end(); + } else { + driver.move_to_text_end(); + } + return Some(GeneratedTextInputEvent::Select); + } + + #[cfg(not(target_os = "macos"))] + { + if shift { + driver.select_down(); + } else { + driver.move_down(); + } + return Some(GeneratedTextInputEvent::Select); + } + } + Key::Backspace => { + #[cfg(target_os = "macos")] + if action_mod { + if driver.editor.raw_selection().is_collapsed() { + driver.select_to_line_start(); + } + driver.delete_selection(); + return Some(GeneratedTextInputEvent::Input); + } + + #[cfg(not(target_os = "macos"))] + { + if action_mod { + driver.backdelete_word(); + } else { + driver.backdelete(); + } + return Some(GeneratedTextInputEvent::Input); + } + } + // On macOS this is handled by the apple standard keybindings + #[cfg(not(target_os = "macos"))] + Key::Delete => { + if action_mod { + driver.delete_word(); } else { - driver.move_down() + driver.delete(); } - return Some(GeneratedTextInputEvent::Select); + return Some(GeneratedTextInputEvent::Input); } Key::Home => { if action_mod { if shift { - driver.select_to_text_start() + driver.select_to_text_start(); } else { - driver.move_to_text_start() + driver.move_to_text_start(); } } else if shift { - driver.select_to_line_start() + driver.select_to_line_start(); } else { - driver.move_to_line_start() + driver.move_to_line_start(); } return Some(GeneratedTextInputEvent::Select); } Key::End => { if action_mod { if shift { - driver.select_to_text_end() + driver.select_to_text_end(); } else { - driver.move_to_text_end() + driver.move_to_text_end(); } } else if shift { - driver.select_to_line_end() + driver.select_to_line_end(); } else { - driver.move_to_line_end() + driver.move_to_line_end(); } return Some(GeneratedTextInputEvent::Select); } - Key::Delete => { - if action_mod { - driver.delete_word() - } else { - driver.delete() - } - return Some(GeneratedTextInputEvent::Input); - } - - // On macOS this is handled by the apple standard keybindings - #[cfg(not(target_os = "macos"))] - Key::Backspace => { - if action_mod { - driver.backdelete_word() - } else { - driver.backdelete() - } - return Some(GeneratedTextInputEvent::Input); - } Key::Character(c) if c == "\n" => { if is_multiline { @@ -482,6 +549,25 @@ impl TextInputData { return Some(GeneratedTextInputEvent::Input); } } + "copy:" => { + if let Some(text) = driver.editor.selected_text() { + let _ = shell_provider.set_clipboard_text(text.to_owned()); + } + return None; + } + "cut:" => { + if let Some(text) = driver.editor.selected_text() { + let _ = shell_provider.set_clipboard_text(text.to_owned()); + driver.delete_selection(); + return Some(GeneratedTextInputEvent::Input); + } + return None; + } + "paste:" => { + let text = shell_provider.get_clipboard_text().unwrap_or_default(); + driver.insert_or_replace_selection(&text); + return Some(GeneratedTextInputEvent::Input); + } // Moving the Insertion Pointer @@ -584,7 +670,7 @@ impl TextInputData { return Some(GeneratedTextInputEvent::Select); } "moveToEndOfDocumentAndModifySelection:" => { - driver.move_to_text_end(); + driver.select_to_text_end(); return Some(GeneratedTextInputEvent::Select); } diff --git a/packages/blitz-dom/src/util.rs b/packages/blitz-dom/src/util.rs index dcad882a64..b5b6e21a80 100644 --- a/packages/blitz-dom/src/util.rs +++ b/packages/blitz-dom/src/util.rs @@ -9,6 +9,32 @@ pub(crate) const ACTION_MOD: Modifiers = Modifiers::SUPER; #[cfg(not(target_os = "macos"))] pub(crate) const ACTION_MOD: Modifiers = Modifiers::CONTROL; +/// Normalizes a key event triggered with an action modifier (Cmd on macOS, Ctrl on others) +/// to a lowercase ASCII character, prioritizing physical key code (Code) over layout-specific +/// logical characters (Key). +pub(crate) fn action_key(event: &blitz_traits::events::BlitzKeyEvent) -> Option { + use keyboard_types::{Code, Key}; + + match event.code { + Code::KeyA => Some('a'), + Code::KeyC => Some('c'), + Code::KeyV => Some('v'), + Code::KeyX => Some('x'), + Code::KeyZ => Some('z'), + _ => match &event.key { + Key::Character(c) if c.len() == 1 => { + let ch = c.chars().next()?; + if ch.is_ascii_alphabetic() { + Some(ch.to_ascii_lowercase()) + } else { + None + } + } + _ => None, + }, + } +} + pub type Color = AlphaColor; /// Decode raw font bytes, decompressing WOFF/WOFF2 if the `woff` feature is enabled.