From 77965881165b78d215ceecb02170d3058e138b65 Mon Sep 17 00:00:00 2001 From: jmckiern Date: Tue, 9 May 2023 01:01:32 -0500 Subject: [PATCH 1/6] core: Add clipboard_content getter --- core/src/backend/ui.rs | 7 +++++++ desktop/src/ui.rs | 6 ++++++ web/src/ui.rs | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index 7f0d55182291..200f75497daf 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -15,6 +15,9 @@ pub trait UiBackend { /// Changes the mouse cursor image. fn set_mouse_cursor(&mut self, cursor: MouseCursor); + /// Get the clipboard content + fn clipboard_content(&mut self) -> String; + /// Sets the clipboard to the given content. fn set_clipboard_content(&mut self, content: String); @@ -145,6 +148,10 @@ impl UiBackend for NullUiBackend { fn set_mouse_cursor(&mut self, _cursor: MouseCursor) {} + fn clipboard_content(&mut self) -> String { + "".to_string() + } + fn set_clipboard_content(&mut self, _content: String) {} fn set_fullscreen(&mut self, _is_full: bool) -> Result<(), FullscreenError> { diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index e653207858ee..33221d1bfe20 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -62,6 +62,12 @@ impl UiBackend for DesktopUiBackend { self.window.set_cursor_icon(icon); } + fn clipboard_content(&mut self) -> String { + self.clipboard + .get_text() + .unwrap_or_else(|_| "".to_string()) + } + fn set_clipboard_content(&mut self, content: String) { if let Err(e) = self.clipboard.set_text(content) { error!("Couldn't set clipboard contents: {:?}", e); diff --git a/web/src/ui.rs b/web/src/ui.rs index 9d18fcb68e4e..cefd420214f5 100644 --- a/web/src/ui.rs +++ b/web/src/ui.rs @@ -65,6 +65,11 @@ impl UiBackend for WebUiBackend { self.update_mouse_cursor(); } + fn clipboard_content(&mut self) -> String { + tracing::warn!("get clipboard not implemented"); + "".to_string() + } + fn set_clipboard_content(&mut self, content: String) { // We use `document.execCommand("copy")` as `navigator.clipboard.writeText("string")` // is available only in secure contexts (HTTPS). From 3f5b2b54338f234dee65f14ca5f936a2996ea7ee Mon Sep 17 00:00:00 2001 From: nosamu <71368227+n0samu@users.noreply.github.com> Date: Tue, 9 May 2023 19:28:30 -0500 Subject: [PATCH 2/6] core: Implement handling of text control input Co-authored-by: jmckiern --- core/src/backend/ui.rs | 12 ++++- core/src/display_object/edit_text.rs | 80 +++++++++++++++++++++++++++- core/src/events.rs | 26 +++++++++ core/src/player.rs | 5 ++ desktop/src/main.rs | 30 ++++++++++- web/src/lib.rs | 30 ++++++++++- 6 files changed, 177 insertions(+), 6 deletions(-) diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index 200f75497daf..7f80a49f442c 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -1,4 +1,4 @@ -use crate::events::{KeyCode, PlayerEvent}; +use crate::events::{KeyCode, PlayerEvent, TextControlCode}; use fluent_templates::loader::langid; pub use fluent_templates::LanguageIdentifier; use std::borrow::Cow; @@ -66,6 +66,7 @@ pub struct InputManager { keys_down: HashSet, last_key: KeyCode, last_char: Option, + last_text_control: Option, } impl InputManager { @@ -74,6 +75,7 @@ impl InputManager { keys_down: HashSet::new(), last_key: KeyCode::Unknown, last_char: None, + last_text_control: None, } } @@ -100,6 +102,10 @@ impl InputManager { PlayerEvent::KeyUp { key_code, key_char } => { self.last_char = key_char; self.remove_key(key_code); + self.last_text_control = None; + } + PlayerEvent::TextControl { code } => { + self.last_text_control = Some(code); } PlayerEvent::MouseDown { button, .. } => self.add_key(button.into()), PlayerEvent::MouseUp { button, .. } => self.remove_key(button.into()), @@ -119,6 +125,10 @@ impl InputManager { self.last_char } + pub fn last_text_control(&self) -> Option { + self.last_text_control + } + pub fn is_mouse_down(&self) -> bool { self.is_key_down(KeyCode::MouseLeft) } diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index a5c0fc70a07d..f421a4a080fa 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -17,7 +17,7 @@ use crate::display_object::interactive::{ }; use crate::display_object::{DisplayObjectBase, DisplayObjectPtr, TDisplayObject}; use crate::drawing::Drawing; -use crate::events::{ButtonKeyCode, ClipEvent, ClipEventResult, KeyCode}; +use crate::events::{ButtonKeyCode, ClipEvent, ClipEventResult, KeyCode, TextControlCode}; use crate::font::{round_down_to_pixel, Glyph, TextRenderSettings}; use crate::html::{BoxBounds, FormatSpans, LayoutBox, LayoutContent, LayoutMetrics, TextFormat}; use crate::prelude::*; @@ -1174,6 +1174,84 @@ impl<'gc> EditText<'gc> { None } + pub fn text_control_input( + self, + control_code: TextControlCode, + context: &mut UpdateContext<'_, 'gc>, + ) { + if !self.is_editable() && control_code.is_edit_input() { + return; + } + + if let Some(selection) = self.selection() { + let mut changed = false; + let is_selectable = self.is_selectable(); + match control_code { + TextControlCode::SelectAll => { + if is_selectable { + self.set_selection( + Some(TextSelection::for_range(0, self.text().len())), + context.gc_context, + ); + } + } + TextControlCode::Copy => { + if !selection.is_caret() { + let text = &self.text()[selection.start()..selection.end()]; + context.ui.set_clipboard_content(text.to_string()); + } + } + TextControlCode::Paste => { + let text = &context.ui.clipboard_content(); + self.replace_text(selection.start(), selection.end(), &WString::from_utf8(text), context); + let new_start = selection.start() + text.len(); + if is_selectable { + self.set_selection( + Some(TextSelection::for_position(new_start)), + context.gc_context, + ); + } else { + self.set_selection( + Some(TextSelection::for_position(self.text().len())), + context.gc_context, + ); + } + changed = true; + } + TextControlCode::Cut => { + if !selection.is_caret() { + let text = &self.text()[selection.start()..selection.end()]; + context.ui.set_clipboard_content(text.to_string()); + + self.replace_text(selection.start(), selection.end(), WStr::empty(), context); + if is_selectable { + self.set_selection( + Some(TextSelection::for_position(selection.start())), + context.gc_context, + ); + } else { + self.set_selection( + Some(TextSelection::for_position(self.text().len())), + context.gc_context, + ); + } + changed = true; + } + } + _ => {} + } + if changed { + let mut activation = Avm1Activation::from_nothing( + context.reborrow(), + ActivationIdentifier::root("[Propagate Text Binding]"), + self.into(), + ); + self.propagate_text_binding(&mut activation); + self.on_changed(&mut activation); + } + } + } + pub fn text_input(self, character: char, context: &mut UpdateContext<'_, 'gc>) { if self.0.read().flags.contains(EditTextFlag::READ_ONLY) { return; diff --git a/core/src/events.rs b/core/src/events.rs index e49c71e53e06..c3c5f5b39c5c 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -32,6 +32,9 @@ pub enum PlayerEvent { TextInput { codepoint: char, }, + TextControl { + code: TextControlCode + }, } /// The distance scrolled by the mouse wheel. @@ -330,6 +333,29 @@ impl<'gc> ClipEvent<'gc> { } } +/// Control inputs to a text field +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TextControlCode { + // TODO: Extend this + SelectAll, + Copy, + Paste, + Cut, + Backspace, + Enter, + Delete, +} + +impl TextControlCode { + /// Indicates whether this is an event that edits the text content + pub fn is_edit_input(self) -> bool { + matches!( + self, + Self::Paste | Self::Cut | Self::Backspace | Self::Enter | Self::Delete + ) + } +} + /// Flash virtual keycode. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, FromPrimitive)] pub enum KeyCode { diff --git a/core/src/player.rs b/core/src/player.rs index 8af3e939c0e7..dec606e0979a 100644 --- a/core/src/player.rs +++ b/core/src/player.rs @@ -1066,6 +1066,11 @@ impl Player { text.text_input(codepoint, context); } } + if let PlayerEvent::TextControl { code } = event { + if let Some(text) = context.focus_tracker.get().and_then(|o| o.as_edit_text()) { + text.text_control_input(code, context); + } + } } // Propagate clip events. diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 8adf99ba48d8..0b84d25983ee 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -23,9 +23,10 @@ use rfd::FileDialog; use ruffle_core::backend::audio::AudioBackend; use ruffle_core::backend::navigator::OpenURLMode; use ruffle_core::{ - config::Letterbox, events::KeyCode, tag_utils::SwfMovie, LoadBehavior, Player, PlayerBuilder, + config::Letterbox, tag_utils::SwfMovie, LoadBehavior, Player, PlayerBuilder, PlayerEvent, StageDisplayState, StageScaleMode, StaticCallstack, ViewportDimensions, }; +use ruffle_core::events::{KeyCode, TextControlCode}; use ruffle_render::backend::RenderBackend; use ruffle_render::quality::StageQuality; use ruffle_render_wgpu::backend::WgpuRenderBackend; @@ -588,7 +589,11 @@ impl App { let key_char = winit_key_to_char(key, modifiers.shift()); let event = match input.state { ElementState::Pressed => { - PlayerEvent::KeyDown { key_code, key_char } + if let Some(control_code) = winit_to_ruffle_text_control(key, modifiers) { + PlayerEvent::TextControl { code: control_code } + } else { + PlayerEvent::KeyDown { key_code, key_char } + } } ElementState::Released => { PlayerEvent::KeyUp { key_code, key_char } @@ -906,6 +911,27 @@ fn winit_key_to_char(key_code: VirtualKeyCode, is_shift_down: bool) -> Option Option { + let ctrl_cmd = modifiers.contains(ModifiersState::CTRL) + || (modifiers.contains(ModifiersState::LOGO) && cfg!(target_os = "macos")); + if ctrl_cmd { + match key { + VirtualKeyCode::A => Some(TextControlCode::SelectAll), + VirtualKeyCode::C => Some(TextControlCode::Copy), + VirtualKeyCode::V => Some(TextControlCode::Paste), + VirtualKeyCode::X => Some(TextControlCode::Cut), + _ => None, + } + } else { + None + } +} + fn run_timedemo(opt: Opt) -> Result<(), Error> { let path = opt .input_path diff --git a/web/src/lib.rs b/web/src/lib.rs index 5b13cf1a8fa1..f304eb722e47 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -13,7 +13,7 @@ use ruffle_core::backend::navigator::OpenURLMode; use ruffle_core::compatibility_rules::CompatibilityRules; use ruffle_core::config::{Letterbox, NetworkingAccessMode}; use ruffle_core::context::UpdateContext; -use ruffle_core::events::{KeyCode, MouseButton, MouseWheelDelta}; +use ruffle_core::events::{KeyCode, MouseButton, MouseWheelDelta, TextControlCode}; use ruffle_core::external::{ ExternalInterfaceMethod, ExternalInterfaceProvider, Value as ExternalValue, Value, }; @@ -840,9 +840,12 @@ impl Ruffle { let _ = instance.with_core_mut(|core| { let key_code = web_to_ruffle_key_code(&js_event.code()); let key_char = web_key_to_codepoint(&js_event.key()); + let is_ctrl_cmd = js_event.ctrl_key() || js_event.meta_key(); core.handle_event(PlayerEvent::KeyDown { key_code, key_char }); - if let Some(codepoint) = key_char { + if let Some(control_code) = web_to_text_control(&js_event.key(), is_ctrl_cmd) { + core.handle_event(PlayerEvent::TextControl { code: control_code }) + }else if let Some(codepoint) = key_char { core.handle_event(PlayerEvent::TextInput { codepoint }); } }); @@ -1740,3 +1743,26 @@ fn web_key_to_codepoint(key: &str) -> Option { } } } + +pub fn web_to_text_control(key: &str, ctrl_key: bool) -> Option { + let mut chars = key.chars(); + let (c1, c2) = (chars.next(), chars.next()); + if c2.is_none() { + // Single character. + if ctrl_key { + match c1 { + // TODO: Extend this + Some('a') => Some(TextControlCode::SelectAll), + Some('c') => Some(TextControlCode::Copy), + Some('v') => Some(TextControlCode::Paste), + Some('x') => Some(TextControlCode::Cut), + _ => None, + } + } else { + None + } + } else { + // TODO: Check for special characters. + None + } +} \ No newline at end of file From fa41d3a9488407ebc738fe5a46a15db5a4967cbb Mon Sep 17 00:00:00 2001 From: nosamu <71368227+n0samu@users.noreply.github.com> Date: Mon, 15 May 2023 00:55:58 -0500 Subject: [PATCH 3/6] core: Move `Delete` and `Backspace` handling to `text_control_input()` Co-authored-by: jmckiern --- core/src/display_object/edit_text.rs | 108 +++++++++++++++------------ desktop/src/main.rs | 6 +- web/src/lib.rs | 7 +- 3 files changed, 69 insertions(+), 52 deletions(-) diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index f421a4a080fa..a977a8149e5f 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -1174,6 +1174,22 @@ impl<'gc> EditText<'gc> { None } + fn available_chars(self) -> usize { + let read = self.0.read(); + let max_chars = read.max_chars; + if max_chars == 0 { + usize::MAX + } else { + let text_len = read.text_spans.text().len(); + let selection_len = if let Some(selection) = self.selection() { + selection.end() - selection.start() + } else { + 0 + }; + max_chars.max(0) as usize - (text_len - selection_len) + } + } + pub fn text_control_input( self, control_code: TextControlCode, @@ -1203,20 +1219,24 @@ impl<'gc> EditText<'gc> { } TextControlCode::Paste => { let text = &context.ui.clipboard_content(); - self.replace_text(selection.start(), selection.end(), &WString::from_utf8(text), context); - let new_start = selection.start() + text.len(); - if is_selectable { - self.set_selection( - Some(TextSelection::for_position(new_start)), - context.gc_context, - ); - } else { - self.set_selection( - Some(TextSelection::for_position(self.text().len())), - context.gc_context, - ); + // TODO: To match Flash Player, we should truncate pasted text that is longer than max_chars + // instead of canceling the paste action entirely + if text.len() <= self.available_chars() { + self.replace_text(selection.start(), selection.end(), &WString::from_utf8(text), context); + let new_start = selection.start() + text.len(); + if is_selectable { + self.set_selection( + Some(TextSelection::for_position(new_start)), + context.gc_context, + ); + } else { + self.set_selection( + Some(TextSelection::for_position(self.text().len())), + context.gc_context, + ); + } + changed = true; } - changed = true; } TextControlCode::Cut => { if !selection.is_caret() { @@ -1238,29 +1258,7 @@ impl<'gc> EditText<'gc> { changed = true; } } - _ => {} - } - if changed { - let mut activation = Avm1Activation::from_nothing( - context.reborrow(), - ActivationIdentifier::root("[Propagate Text Binding]"), - self.into(), - ); - self.propagate_text_binding(&mut activation); - self.on_changed(&mut activation); - } - } - } - - pub fn text_input(self, character: char, context: &mut UpdateContext<'_, 'gc>) { - if self.0.read().flags.contains(EditTextFlag::READ_ONLY) { - return; - } - - if let Some(selection) = self.selection() { - let mut changed = false; - match character as u8 { - 8 | 127 if !selection.is_caret() => { + TextControlCode::Backspace | TextControlCode::Delete if !selection.is_caret() => { // Backspace or delete with multiple characters selected self.replace_text(selection.start(), selection.end(), WStr::empty(), context); self.set_selection( @@ -1269,7 +1267,7 @@ impl<'gc> EditText<'gc> { ); changed = true; } - 8 => { + TextControlCode::Backspace => { // Backspace with caret if selection.start() > 0 { // Delete previous character @@ -1283,7 +1281,7 @@ impl<'gc> EditText<'gc> { changed = true; } } - 127 => { + TextControlCode::Delete => { // Delete with caret if selection.end() < self.text_length() { // Delete next character @@ -1294,18 +1292,30 @@ impl<'gc> EditText<'gc> { changed = true; } } + _ => {} + } + if changed { + let mut activation = Avm1Activation::from_nothing( + context.reborrow(), + ActivationIdentifier::root("[Propagate Text Binding]"), + self.into(), + ); + self.propagate_text_binding(&mut activation); + self.on_changed(&mut activation); + } + } + } + + pub fn text_input(self, character: char, context: &mut UpdateContext<'_, 'gc>) { + if self.0.read().flags.contains(EditTextFlag::READ_ONLY) { + return; + } + + if let Some(selection) = self.selection() { + let mut changed = false; + match character as u8 { code if !(code as char).is_control() => { - let can_insert = { - let read = self.0.read(); - let max_chars = read.max_chars; - if max_chars == 0 { - true - } else { - let text_len = read.text_spans.text().len(); - text_len < max_chars.max(0) as usize - } - }; - if can_insert { + if self.available_chars() > 0 { self.replace_text( selection.start(), selection.end(), diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 0b84d25983ee..f2bcbb7b8d12 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -928,7 +928,11 @@ fn winit_to_ruffle_text_control( _ => None, } } else { - None + match key { + VirtualKeyCode::Back => Some(TextControlCode::Backspace), + VirtualKeyCode::Delete => Some(TextControlCode::Delete), + _ => None, + } } } diff --git a/web/src/lib.rs b/web/src/lib.rs index f304eb722e47..7dca979a0dad 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1762,7 +1762,10 @@ pub fn web_to_text_control(key: &str, ctrl_key: bool) -> Option None } } else { - // TODO: Check for special characters. - None + match key { + "Delete" => Some(TextControlCode::Delete), + "Backspace" => Some(TextControlCode::Backspace), + _ => None, + } } } \ No newline at end of file From ec0d9b39dae8f8cb991954ad718e7aa191a0f1ad Mon Sep 17 00:00:00 2001 From: nosamu <71368227+n0samu@users.noreply.github.com> Date: Mon, 15 May 2023 06:38:39 -0500 Subject: [PATCH 4/6] core: Move left/right arrow handling to `text_control_input` --- core/src/display_object/edit_text.rs | 121 +++++++++++++-------------- core/src/events.rs | 6 +- core/src/player.rs | 10 --- desktop/src/main.rs | 25 +++++- desktop/src/ui.rs | 4 +- web/src/lib.rs | 26 +++++- 6 files changed, 107 insertions(+), 85 deletions(-) diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index a977a8149e5f..ead01056e9bd 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -17,7 +17,7 @@ use crate::display_object::interactive::{ }; use crate::display_object::{DisplayObjectBase, DisplayObjectPtr, TDisplayObject}; use crate::drawing::Drawing; -use crate::events::{ButtonKeyCode, ClipEvent, ClipEventResult, KeyCode, TextControlCode}; +use crate::events::{ClipEvent, ClipEventResult, TextControlCode}; use crate::font::{round_down_to_pixel, Glyph, TextRenderSettings}; use crate::html::{BoxBounds, FormatSpans, LayoutBox, LayoutContent, LayoutMetrics, TextFormat}; use crate::prelude::*; @@ -1203,6 +1203,46 @@ impl<'gc> EditText<'gc> { let mut changed = false; let is_selectable = self.is_selectable(); match control_code { + TextControlCode::MoveLeft => { + let new_pos = if selection.is_caret() && selection.to > 0 { + string_utils::prev_char_boundary(&self.text(), selection.to) + } else { + selection.start() + }; + self.set_selection( + Some(TextSelection::for_position(new_pos)), + context.gc_context, + ); + } + TextControlCode::MoveRight => { + let new_pos = if selection.is_caret() && selection.to < self.text().len() { + string_utils::next_char_boundary(&self.text(), selection.to) + } else { + selection.end() + }; + self.set_selection( + Some(TextSelection::for_position(new_pos)), + context.gc_context, + ); + } + TextControlCode::SelectLeft => { + if is_selectable && selection.to > 0 { + let new_pos = string_utils::prev_char_boundary(&self.text(), selection.to); + self.set_selection( + Some(TextSelection::for_range(selection.from, new_pos)), + context.gc_context, + ); + } + } + TextControlCode::SelectRight => { + if is_selectable && selection.to < self.text().len() { + let new_pos = string_utils::next_char_boundary(&self.text(), selection.to); + self.set_selection( + Some(TextSelection::for_range(selection.from, new_pos)), + context.gc_context, + ) + } + } TextControlCode::SelectAll => { if is_selectable { self.set_selection( @@ -1222,11 +1262,16 @@ impl<'gc> EditText<'gc> { // TODO: To match Flash Player, we should truncate pasted text that is longer than max_chars // instead of canceling the paste action entirely if text.len() <= self.available_chars() { - self.replace_text(selection.start(), selection.end(), &WString::from_utf8(text), context); - let new_start = selection.start() + text.len(); + self.replace_text( + selection.start(), + selection.end(), + &WString::from_utf8(text), + context, + ); + let new_pos = selection.start() + text.len(); if is_selectable { self.set_selection( - Some(TextSelection::for_position(new_start)), + Some(TextSelection::for_position(new_pos)), context.gc_context, ); } else { @@ -1243,7 +1288,12 @@ impl<'gc> EditText<'gc> { let text = &self.text()[selection.start()..selection.end()]; context.ui.set_clipboard_content(text.to_string()); - self.replace_text(selection.start(), selection.end(), WStr::empty(), context); + self.replace_text( + selection.start(), + selection.end(), + WStr::empty(), + context, + ); if is_selectable { self.set_selection( Some(TextSelection::for_position(selection.start())), @@ -1322,9 +1372,9 @@ impl<'gc> EditText<'gc> { &WString::from_char(character), context, ); - let new_start = selection.start() + character.len_utf8(); + let new_pos = selection.start() + character.len_utf8(); self.set_selection( - Some(TextSelection::for_position(new_start)), + Some(TextSelection::for_position(new_pos)), context.gc_context, ); changed = true; @@ -1345,61 +1395,6 @@ impl<'gc> EditText<'gc> { } } - /// Listens for keyboard text control commands. - /// - /// TODO: Add explicit text control events (#4452). - pub fn handle_text_control_event( - self, - context: &mut UpdateContext<'_, 'gc>, - event: ClipEvent, - ) -> ClipEventResult { - if let ClipEvent::KeyPress { key_code } = event { - let mut edit_text = self.0.write(context.gc_context); - let selection = edit_text.selection; - if let Some(mut selection) = selection { - let text = edit_text.text_spans.text(); - let length = text.len(); - match key_code { - ButtonKeyCode::Left => { - if (context.input.is_key_down(KeyCode::Shift) || selection.is_caret()) - && selection.to > 0 - { - selection.to = string_utils::prev_char_boundary(text, selection.to); - if !context.input.is_key_down(KeyCode::Shift) { - selection.from = selection.to; - } - } else if !context.input.is_key_down(KeyCode::Shift) { - selection.to = selection.start(); - selection.from = selection.to; - } - selection.clamp(length); - edit_text.selection = Some(selection); - return ClipEventResult::Handled; - } - ButtonKeyCode::Right => { - if (context.input.is_key_down(KeyCode::Shift) || selection.is_caret()) - && selection.to < length - { - selection.to = string_utils::next_char_boundary(text, selection.to); - if !context.input.is_key_down(KeyCode::Shift) { - selection.from = selection.to; - } - } else if !context.input.is_key_down(KeyCode::Shift) { - selection.to = selection.end(); - selection.from = selection.to; - } - selection.clamp(length); - edit_text.selection = Some(selection); - return ClipEventResult::Handled; - } - _ => (), - } - } - } - - ClipEventResult::NotHandled - } - fn initialize_as_broadcaster(&self, activation: &mut Avm1Activation<'_, 'gc>) { if let Avm1Value::Object(object) = self.object() { activation.context.avm1.broadcaster_functions().initialize( @@ -2043,7 +2038,7 @@ impl TextSelection { self.from.min(self.to) } - /// The "end" part of the range is the smallest (closest to 0) part of this selection range. + /// The "end" part of the range is the largest (farthest from 0) part of this selection range. pub fn end(&self) -> usize { self.from.max(self.to) } diff --git a/core/src/events.rs b/core/src/events.rs index c3c5f5b39c5c..f6e54b3ccc68 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -33,7 +33,7 @@ pub enum PlayerEvent { codepoint: char, }, TextControl { - code: TextControlCode + code: TextControlCode, }, } @@ -337,6 +337,10 @@ impl<'gc> ClipEvent<'gc> { #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum TextControlCode { // TODO: Extend this + MoveLeft, + MoveRight, + SelectLeft, + SelectRight, SelectAll, Copy, Paste, diff --git a/core/src/player.rs b/core/src/player.rs index dec606e0979a..e35549037952 100644 --- a/core/src/player.rs +++ b/core/src/player.rs @@ -991,16 +991,6 @@ impl Player { if state == ClipEventResult::Handled { key_press_handled = true; break; - } else if let Some(text) = - context.focus_tracker.get().and_then(|o| o.as_edit_text()) - { - // Text fields listen for arrow key presses, etc. - if text.handle_text_control_event(context, button_event) - == ClipEventResult::Handled - { - key_press_handled = true; - break; - } } } } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index f2bcbb7b8d12..a3903a6877c5 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -22,11 +22,11 @@ use isahc::{config::RedirectPolicy, prelude::*, HttpClient}; use rfd::FileDialog; use ruffle_core::backend::audio::AudioBackend; use ruffle_core::backend::navigator::OpenURLMode; +use ruffle_core::events::{KeyCode, TextControlCode}; use ruffle_core::{ - config::Letterbox, tag_utils::SwfMovie, LoadBehavior, Player, PlayerBuilder, - PlayerEvent, StageDisplayState, StageScaleMode, StaticCallstack, ViewportDimensions, + config::Letterbox, tag_utils::SwfMovie, LoadBehavior, Player, PlayerBuilder, PlayerEvent, + StageDisplayState, StageScaleMode, StaticCallstack, ViewportDimensions, }; -use ruffle_core::events::{KeyCode, TextControlCode}; use ruffle_render::backend::RenderBackend; use ruffle_render::quality::StageQuality; use ruffle_render_wgpu::backend::WgpuRenderBackend; @@ -589,7 +589,9 @@ impl App { let key_char = winit_key_to_char(key, modifiers.shift()); let event = match input.state { ElementState::Pressed => { - if let Some(control_code) = winit_to_ruffle_text_control(key, modifiers) { + if let Some(control_code) = + winit_to_ruffle_text_control(key, modifiers) + { PlayerEvent::TextControl { code: control_code } } else { PlayerEvent::KeyDown { key_code, key_char } @@ -917,6 +919,7 @@ fn winit_to_ruffle_text_control( key: VirtualKeyCode, modifiers: ModifiersState, ) -> Option { + let shift = modifiers.contains(ModifiersState::SHIFT); let ctrl_cmd = modifiers.contains(ModifiersState::CTRL) || (modifiers.contains(ModifiersState::LOGO) && cfg!(target_os = "macos")); if ctrl_cmd { @@ -931,6 +934,20 @@ fn winit_to_ruffle_text_control( match key { VirtualKeyCode::Back => Some(TextControlCode::Backspace), VirtualKeyCode::Delete => Some(TextControlCode::Delete), + VirtualKeyCode::Left => { + if shift { + Some(TextControlCode::SelectLeft) + } else { + Some(TextControlCode::MoveLeft) + } + } + VirtualKeyCode::Right => { + if shift { + Some(TextControlCode::SelectRight) + } else { + Some(TextControlCode::MoveRight) + } + } _ => None, } } diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index 33221d1bfe20..d278ccdf60ac 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -63,9 +63,7 @@ impl UiBackend for DesktopUiBackend { } fn clipboard_content(&mut self) -> String { - self.clipboard - .get_text() - .unwrap_or_else(|_| "".to_string()) + self.clipboard.get_text().unwrap_or_else(|_| "".to_string()) } fn set_clipboard_content(&mut self, content: String) { diff --git a/web/src/lib.rs b/web/src/lib.rs index 7dca979a0dad..d33901372e3e 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -843,9 +843,13 @@ impl Ruffle { let is_ctrl_cmd = js_event.ctrl_key() || js_event.meta_key(); core.handle_event(PlayerEvent::KeyDown { key_code, key_char }); - if let Some(control_code) = web_to_text_control(&js_event.key(), is_ctrl_cmd) { + if let Some(control_code) = web_to_text_control( + &js_event.key(), + is_ctrl_cmd, + js_event.shift_key(), + ) { core.handle_event(PlayerEvent::TextControl { code: control_code }) - }else if let Some(codepoint) = key_char { + } else if let Some(codepoint) = key_char { core.handle_event(PlayerEvent::TextInput { codepoint }); } }); @@ -1744,7 +1748,7 @@ fn web_key_to_codepoint(key: &str) -> Option { } } -pub fn web_to_text_control(key: &str, ctrl_key: bool) -> Option { +pub fn web_to_text_control(key: &str, ctrl_key: bool, shift_key: bool) -> Option { let mut chars = key.chars(); let (c1, c2) = (chars.next(), chars.next()); if c2.is_none() { @@ -1765,7 +1769,21 @@ pub fn web_to_text_control(key: &str, ctrl_key: bool) -> Option match key { "Delete" => Some(TextControlCode::Delete), "Backspace" => Some(TextControlCode::Backspace), + "ArrowLeft" => { + if shift_key { + Some(TextControlCode::SelectLeft) + } else { + Some(TextControlCode::MoveLeft) + } + } + "ArrowRight" => { + if shift_key { + Some(TextControlCode::SelectRight) + } else { + Some(TextControlCode::MoveRight) + } + } _ => None, } } -} \ No newline at end of file +} From a36aad0e6b5e5908f73f9d35536dd43018faac60 Mon Sep 17 00:00:00 2001 From: nosamu <71368227+n0samu@users.noreply.github.com> Date: Tue, 16 May 2023 02:31:09 -0500 Subject: [PATCH 5/6] core: Fix TextField.maxChars handling and address other reviews --- core/src/display_object/edit_text.rs | 8 +++++--- core/src/events.rs | 2 +- desktop/src/main.rs | 1 + web/src/lib.rs | 14 ++++++++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/core/src/display_object/edit_text.rs b/core/src/display_object/edit_text.rs index ead01056e9bd..380db56b8be9 100644 --- a/core/src/display_object/edit_text.rs +++ b/core/src/display_object/edit_text.rs @@ -1174,19 +1174,21 @@ impl<'gc> EditText<'gc> { None } + /// The number of characters that currently can be inserted, considering `TextField.maxChars` + /// constraint, current text length, and current text selection length. fn available_chars(self) -> usize { let read = self.0.read(); let max_chars = read.max_chars; if max_chars == 0 { usize::MAX } else { - let text_len = read.text_spans.text().len(); + let text_len = read.text_spans.text().len() as i32; let selection_len = if let Some(selection) = self.selection() { - selection.end() - selection.start() + (selection.end() - selection.start()) as i32 } else { 0 }; - max_chars.max(0) as usize - (text_len - selection_len) + 0.max(max_chars.max(0) - (text_len - selection_len)) as usize } } diff --git a/core/src/events.rs b/core/src/events.rs index f6e54b3ccc68..ac0afae4ff13 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -336,7 +336,7 @@ impl<'gc> ClipEvent<'gc> { /// Control inputs to a text field #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum TextControlCode { - // TODO: Extend this + // TODO: Add control codes for Ctrl+Arrows and Home/End keys MoveLeft, MoveRight, SelectLeft, diff --git a/desktop/src/main.rs b/desktop/src/main.rs index a3903a6877c5..01ceb7b45968 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -915,6 +915,7 @@ fn winit_key_to_char(key_code: VirtualKeyCode, is_shift_down: bool) -> Option Option { } } -pub fn web_to_text_control(key: &str, ctrl_key: bool, shift_key: bool) -> Option { +/// Convert a web `KeyboardEvent.key` value to a Ruffle `TextControlCode`, +/// given the states of the modifier keys. Return `None` if there is no match. +/// TODO: Handle Ctrl+Arrows and Home/End keys +pub fn web_to_ruffle_text_control( + key: &str, + ctrl_key: bool, + shift_key: bool, +) -> Option { let mut chars = key.chars(); let (c1, c2) = (chars.next(), chars.next()); if c2.is_none() { // Single character. if ctrl_key { match c1 { - // TODO: Extend this Some('a') => Some(TextControlCode::SelectAll), Some('c') => Some(TextControlCode::Copy), Some('v') => Some(TextControlCode::Paste), From 49cf622b7c0d9239fc60eeff74110540161247e3 Mon Sep 17 00:00:00 2001 From: nosamu <71368227+n0samu@users.noreply.github.com> Date: Tue, 16 May 2023 04:27:36 -0500 Subject: [PATCH 6/6] web: Support pasting from clipboard --- .cargo/config.toml | 6 +++ core/src/backend/ui.rs | 2 +- desktop/src/ui.rs | 2 +- web/Cargo.toml | 8 ++-- web/packages/core/src/ruffle-player.ts | 3 ++ web/src/lib.rs | 57 ++++++++++++++++++++++++-- web/src/ui.rs | 13 +++++- 7 files changed, 79 insertions(+), 12 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 24a1916af449..4ef3189b2a02 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,10 +1,16 @@ [target.'cfg(all())'] # NOTE that the web build overrides this setting in package.json via the RUSTFLAGS environment variable rustflags = [ + # We need to specify this flag for all targets because Clippy checks all of our code against all targets + # and our web code does not compile without this flag + "--cfg=web_sys_unstable_apis", + # CLIPPY LINT SETTINGS # This is a workaround to configure lints for the entire workspace, pending the ability to configure this via TOML. # See: https://github.com/rust-lang/cargo/issues/5034 # https://github.com/EmbarkStudios/rust-ecosystem/issues/22#issuecomment-947011395 + # TODO: Move these to the root Cargo.toml once support is merged and stable + # See: https://github.com/rust-lang/cargo/pull/12148 # Clippy nightly often adds new/buggy lints that we want to ignore. # Don't warn about these new lints on stable. diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index 7f80a49f442c..4401a050c61f 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -159,7 +159,7 @@ impl UiBackend for NullUiBackend { fn set_mouse_cursor(&mut self, _cursor: MouseCursor) {} fn clipboard_content(&mut self) -> String { - "".to_string() + "".into() } fn set_clipboard_content(&mut self, _content: String) {} diff --git a/desktop/src/ui.rs b/desktop/src/ui.rs index d278ccdf60ac..9ff9db392f52 100644 --- a/desktop/src/ui.rs +++ b/desktop/src/ui.rs @@ -63,7 +63,7 @@ impl UiBackend for DesktopUiBackend { } fn clipboard_content(&mut self) -> String { - self.clipboard.get_text().unwrap_or_else(|_| "".to_string()) + self.clipboard.get_text().unwrap_or_default() } fn set_clipboard_content(&mut self, content: String) { diff --git a/web/Cargo.toml b/web/Cargo.toml index 101a0e9482e9..577503b6fa03 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -60,8 +60,8 @@ version = "0.3.63" features = [ "AddEventListenerOptions", "AudioBuffer", "AudioBufferSourceNode", "AudioContext", "AudioDestinationNode", "AudioNode", "AudioParam", "Blob", "BlobPropertyBag", - "ChannelMergerNode", "ChannelSplitterNode", "Element", "Event", "EventTarget", "GainNode", - "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", "HtmlInputElement", - "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", "Request", "RequestInit", - "Response", "Storage", "WheelEvent", "Window", + "ChannelMergerNode", "ChannelSplitterNode", "ClipboardEvent", "DataTransfer", "Element", "Event", + "EventTarget", "GainNode", "HtmlCanvasElement", "HtmlDocument", "HtmlElement", "HtmlFormElement", + "HtmlInputElement", "HtmlTextAreaElement", "KeyboardEvent", "Location", "PointerEvent", + "Request", "RequestInit", "Response", "Storage", "WheelEvent", "Window", ] diff --git a/web/packages/core/src/ruffle-player.ts b/web/packages/core/src/ruffle-player.ts index d9eb9af604ab..9ad2800ae66e 100644 --- a/web/packages/core/src/ruffle-player.ts +++ b/web/packages/core/src/ruffle-player.ts @@ -1183,6 +1183,9 @@ export class RufflePlayer extends HTMLElement { this.virtualKeyboard.focus({ preventScroll: true }); } } + protected isVirtualKeyboardFocused(): boolean { + return this.shadow.activeElement === this.virtualKeyboard; + } private contextMenuItems(isTouch: boolean): Array { const CHECKMARK = String.fromCharCode(0x2713); diff --git a/web/src/lib.rs b/web/src/lib.rs index 9d735e8864e3..2ed9cd18a530 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -38,8 +38,8 @@ use tracing_wasm::{WASMLayer, WASMLayerConfigBuilder}; use url::Url; use wasm_bindgen::{prelude::*, JsCast, JsValue}; use web_sys::{ - AddEventListenerOptions, Element, Event, EventTarget, HtmlCanvasElement, HtmlElement, - KeyboardEvent, PointerEvent, WheelEvent, Window, + AddEventListenerOptions, ClipboardEvent, Element, Event, EventTarget, HtmlCanvasElement, + HtmlElement, KeyboardEvent, PointerEvent, WheelEvent, Window, }; static RUFFLE_GLOBAL_PANIC: Once = Once::new(); @@ -78,6 +78,7 @@ struct RuffleInstance { mouse_wheel_callback: Option>, key_down_callback: Option>, key_up_callback: Option>, + paste_callback: Option>, unload_callback: Option>, has_focus: bool, trace_observer: Arc>, @@ -119,6 +120,9 @@ extern "C" { #[wasm_bindgen(method, js_name = "openVirtualKeyboard")] fn open_virtual_keyboard(this: &JavascriptPlayer); + + #[wasm_bindgen(method, js_name = "isVirtualKeyboardFocused")] + fn is_virtual_keyboard_focused(this: &JavascriptPlayer) -> bool; } struct JavascriptInterface { @@ -619,6 +623,7 @@ impl Ruffle { mouse_wheel_callback: None, key_down_callback: None, key_up_callback: None, + paste_callback: None, unload_callback: None, timestamp: None, has_focus: false, @@ -837,6 +842,7 @@ impl Ruffle { let key_down_callback = Closure::new(move |js_event: KeyboardEvent| { let _ = ruffle.with_instance(|instance| { if instance.has_focus { + let mut paste_event = false; let _ = instance.with_core_mut(|core| { let key_code = web_to_ruffle_key_code(&js_event.code()); let key_char = web_key_to_codepoint(&js_event.key()); @@ -848,13 +854,23 @@ impl Ruffle { is_ctrl_cmd, js_event.shift_key(), ) { - core.handle_event(PlayerEvent::TextControl { code: control_code }); + paste_event = control_code == TextControlCode::Paste; + // The JS paste event fires separately and the clipboard text is not available until then, + // so we need to wait before handling it + if !paste_event { + core.handle_event(PlayerEvent::TextControl { + code: control_code, + }); + } } else if let Some(codepoint) = key_char { core.handle_event(PlayerEvent::TextInput { codepoint }); } }); - js_event.prevent_default(); + // Don't prevent the JS paste event from firing + if !paste_event { + js_event.prevent_default(); + } } }); }); @@ -867,6 +883,31 @@ impl Ruffle { .warn_on_error(); instance.key_down_callback = Some(key_down_callback); + let paste_callback = Closure::new(move |js_event: ClipboardEvent| { + let _ = ruffle.with_instance(|instance| { + if instance.has_focus { + let _ = instance.with_core_mut(|core| { + let clipboard_content = if let Some(content) = js_event.clipboard_data() + { + content.get_data("text/plain").unwrap_or_default() + } else { + "".into() + }; + core.ui_mut().set_clipboard_content(clipboard_content); + core.handle_event(PlayerEvent::TextControl { + code: TextControlCode::Paste, + }); + }); + js_event.prevent_default(); + } + }); + }); + + window + .add_event_listener_with_callback("paste", paste_callback.as_ref().unchecked_ref()) + .warn_on_error(); + instance.paste_callback = Some(paste_callback); + // Create keyup event handler. let key_up_callback = Closure::new(move |js_event: KeyboardEvent| { let _ = ruffle.with_instance(|instance| { @@ -1258,6 +1299,14 @@ impl Drop for RuffleInstance { ) .warn_on_error(); } + if let Some(paste_callback) = self.paste_callback.take() { + self.window + .remove_event_listener_with_callback( + "paste", + paste_callback.as_ref().unchecked_ref(), + ) + .warn_on_error(); + } if let Some(key_up_callback) = self.key_up_callback.take() { self.window .remove_event_listener_with_callback( diff --git a/web/src/ui.rs b/web/src/ui.rs index cefd420214f5..81b5d345c7e4 100644 --- a/web/src/ui.rs +++ b/web/src/ui.rs @@ -14,6 +14,7 @@ pub struct WebUiBackend { cursor_visible: bool, cursor: MouseCursor, language: LanguageIdentifier, + clipboard_content: String, } impl WebUiBackend { @@ -29,6 +30,7 @@ impl WebUiBackend { cursor_visible: true, cursor: MouseCursor::Arrow, language, + clipboard_content: "".into(), } } @@ -66,11 +68,13 @@ impl UiBackend for WebUiBackend { } fn clipboard_content(&mut self) -> String { - tracing::warn!("get clipboard not implemented"); - "".to_string() + // On web, clipboard content is not directly accessible due to security restrictions, + // but pasting from the clipboard is supported via the JS `paste` event + self.clipboard_content.to_owned() } fn set_clipboard_content(&mut self, content: String) { + self.clipboard_content = content.to_owned(); // We use `document.execCommand("copy")` as `navigator.clipboard.writeText("string")` // is available only in secure contexts (HTTPS). if let Some(element) = self.canvas.parent_element() { @@ -86,6 +90,7 @@ impl UiBackend for WebUiBackend { .dyn_into() .expect("create_element(\"textarea\") didn't give us a textarea"); + let editing_text = self.js_player.is_virtual_keyboard_focused(); textarea.set_value(&content); let _ = element.append_child(&textarea); textarea.select(); @@ -102,6 +107,10 @@ impl UiBackend for WebUiBackend { } let _ = element.remove_child(&textarea); + if editing_text { + // Return focus to the text area + self.js_player.open_virtual_keyboard(); + } } }