Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

desktop: Implement basic IME support #19666

Merged
merged 5 commits into from
Mar 20, 2025
Merged
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::backend::{
};
use crate::context_menu::ContextMenuState;
use crate::display_object::{EditText, MovieClip, SoundTransform, Stage};
use crate::events::PlayerNotification;
use crate::external::ExternalInterface;
use crate::focus_tracker::FocusTracker;
use crate::frame_lifecycle::FramePhase;
Expand All @@ -41,6 +42,7 @@ use crate::system_properties::SystemProperties;
use crate::tag_utils::{SwfMovie, SwfSlice};
use crate::timer::Timers;
use crate::vminterface::Instantiator;
use async_channel::Sender;
use core::fmt;
use gc_arena::{Collect, Mutation};
use rand::rngs::SmallRng;
Expand Down Expand Up @@ -226,6 +228,8 @@ pub struct UpdateContext<'gc> {
/// Currently, this is just used for handling `Loader.loadBytes`
#[allow(clippy::type_complexity)]
pub post_frame_callbacks: &'gc mut Vec<PostFrameCallback<'gc>>,

pub notification_sender: Option<&'gc Sender<PlayerNotification>>,
}

impl<'gc> HasStringContext<'gc> for UpdateContext<'gc> {
Expand Down Expand Up @@ -445,6 +449,14 @@ impl<'gc> UpdateContext<'gc> {

self.set_root_movie(movie);
}

pub fn send_notification(&self, notification: PlayerNotification) {
if let Some(notification_sender) = self.notification_sender {
if let Err(e) = notification_sender.try_send(notification) {
tracing::error!("Failed to send player notification: {e}");
}
}
}
}

impl<'gc> UpdateContext<'gc> {
Expand Down
39 changes: 38 additions & 1 deletion core/src/display_object/edit_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ use crate::display_object::interactive::{
InteractiveObject, InteractiveObjectBase, TInteractiveObject,
};
use crate::display_object::{DisplayObjectBase, DisplayObjectPtr};
use crate::events::{ClipEvent, ClipEventResult, TextControlCode};
use crate::events::{
ClipEvent, ClipEventResult, ImeCursorArea, ImeEvent, ImeNotification, ImePurpose,
PlayerNotification, TextControlCode,
};
use crate::font::{FontType, Glyph, TextRenderSettings};
use crate::html;
use crate::html::StyleSheet;
Expand Down Expand Up @@ -1848,6 +1851,15 @@ impl<'gc> EditText<'gc> {
}
}

pub fn ime(self, event: ImeEvent, context: &mut UpdateContext<'gc>) {
match event {
ImeEvent::Preedit(_, _) => {
// TODO Add support for IME preedit
}
ImeEvent::Commit(text) => self.text_input(text, context),
};
}

/// Find the new position in the text for the given control code.
///
/// * For selection codes it will represent the "to" part of the selection.
Expand Down Expand Up @@ -2880,6 +2892,17 @@ impl<'gc> EditText<'gc> {
context.commands.draw_line_rect(border_color, text_box);
}
}

fn ime_cursor_area(self) -> ImeCursorArea {
// TODO We should be smarter here and return an area closer to the cursor.
let bounds = self.world_bounds();
ImeCursorArea {
x: bounds.x_min.to_pixels(),
y: bounds.y_min.to_pixels(),
width: bounds.width().to_pixels(),
height: bounds.height().to_pixels(),
}
}
}

impl<'gc> TInteractiveObject<'gc> for EditText<'gc> {
Expand Down Expand Up @@ -3050,6 +3073,20 @@ impl<'gc> TInteractiveObject<'gc> for EditText<'gc> {
if !focused && is_avm1 {
self.set_selection(None, context.gc());
}

// Notify about IME
context.send_notification(PlayerNotification::ImeNotification(if focused {
ImeNotification::ImeReady {
purpose: if self.is_password() {
ImePurpose::Password
} else {
ImePurpose::Standard
},
cursor_area: self.ime_cursor_area(),
}
} else {
ImeNotification::ImeNotReady
}));
}

fn is_focusable_by_mouse(&self, _context: &mut UpdateContext<'gc>) -> bool {
Expand Down
50 changes: 50 additions & 0 deletions core/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub enum PlayerEvent {
TextControl {
code: TextControlCode,
},
Ime(ImeEvent),
FocusGained,
FocusLost,
}
Expand Down Expand Up @@ -467,6 +468,25 @@ impl TextControlCode {
}
}

/// Input method allows inputting non-Latin characters on a Latin keyboard.
///
/// When IME is enabled, Ruffle will accept [`ImeEvent`]s instead of key events.
/// It allows dynamically changing the inputted text and then committing it at
/// the end.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImeEvent {
/// A new composing text should be set.
///
/// The second parameter is the position of the cursor. When it's `None`,
/// the cursor should be hidden.
///
/// An empty text indicates that preedit was cleared.
Preedit(String, Option<(usize, usize)>),

/// Composition is finished, and the text should be inserted.
Commit(String),
}

/// Flash virtual keycode.
///
/// See <https://docs.ruffle.rs/en_US/FlashPlatform/reference/actionscript/3/flash/ui/Keyboard.html#summaryTableConstant>
Expand Down Expand Up @@ -1130,3 +1150,33 @@ pub enum KeyLocation {
Right = 2,
Numpad = 3,
}

#[derive(Debug, Clone)]
pub enum PlayerNotification {
ImeNotification(ImeNotification),
}

#[derive(Debug, Clone)]
pub enum ImeNotification {
ImeReady {
purpose: ImePurpose,
cursor_area: ImeCursorArea,
},
ImePurposeUpdated(ImePurpose),
ImeCursorAreaUpdated(ImeCursorArea),
ImeNotReady,
}

#[derive(Debug, Clone)]
pub enum ImePurpose {
Standard,
Password,
}

#[derive(Debug, Clone)]
pub struct ImeCursorArea {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
6 changes: 4 additions & 2 deletions core/src/input.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::events::{
GamepadButton, KeyCode, KeyDescriptor, KeyLocation, LogicalKey, MouseButton, MouseWheelDelta,
NamedKey, PhysicalKey, PlayerEvent, TextControlCode,
GamepadButton, ImeEvent, KeyCode, KeyDescriptor, KeyLocation, LogicalKey, MouseButton,
MouseWheelDelta, NamedKey, PhysicalKey, PlayerEvent, TextControlCode,
};
use chrono::{DateTime, TimeDelta, Utc};
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -53,6 +53,7 @@ pub enum InputEvent {
TextControl {
code: TextControlCode,
},
Ime(ImeEvent),
}

struct ClickEventData {
Expand Down Expand Up @@ -190,6 +191,7 @@ impl InputManager {

PlayerEvent::TextInput { codepoint } => InputEvent::TextInput { codepoint },
PlayerEvent::TextControl { code } => InputEvent::TextControl { code },
PlayerEvent::Ime(ime) => InputEvent::Ime(ime),

// The following are not input events.
PlayerEvent::FocusGained | PlayerEvent::FocusLost => return None,
Expand Down
23 changes: 23 additions & 0 deletions core/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::display_object::{
TInteractiveObject, WindowMode,
};
use crate::events::GamepadButton;
use crate::events::PlayerNotification;
use crate::events::{ButtonKeyCode, ClipEvent, ClipEventResult, KeyCode, MouseButton, PlayerEvent};
use crate::external::{ExternalInterface, ExternalInterfaceProvider, NullFsCommandProvider};
use crate::external::{FsCommandProvider, Value as ExternalValue};
Expand All @@ -49,6 +50,7 @@ use crate::tag_utils::SwfMovie;
use crate::timer::Timers;
use crate::vminterface::Instantiator;
use crate::DefaultFont;
use async_channel::Sender;
use gc_arena::lock::GcRefLock;
use gc_arena::{Collect, DynamicRootSet, Mutation, Rootable};
use rand::{rngs::SmallRng, SeedableRng};
Expand Down Expand Up @@ -382,6 +384,9 @@ pub struct Player {
/// Any compatibility rules to apply for this movie.
compatibility_rules: CompatibilityRules,

/// Sends notifications back from the core player to the frontend.
notification_sender: Option<Sender<PlayerNotification>>,

/// Debug UI windows
#[cfg(feature = "egui")]
debug_ui: Rc<RefCell<crate::debug_ui::DebugUi>>,
Expand Down Expand Up @@ -1011,6 +1016,7 @@ impl Player {
| PlayerEvent::MouseWheel { .. }
| PlayerEvent::GamepadButtonDown { .. }
| PlayerEvent::GamepadButtonUp { .. }
| PlayerEvent::Ime { .. }
| PlayerEvent::TextInput { .. }
| PlayerEvent::TextControl { .. } => self.handle_input_event(event),
}
Expand Down Expand Up @@ -1311,6 +1317,9 @@ impl Player {
if let InputEvent::TextControl { code } = &event {
text.text_control_input(*code, context);
}
if let InputEvent::Ime(ime) = &event {
text.ime(ime.clone(), context);
}
}
}

Expand Down Expand Up @@ -2243,6 +2252,7 @@ impl Player {
local_connections,
dynamic_root,
post_frame_callbacks,
notification_sender: this.notification_sender.as_ref(),
};

let prev_frame_rate = *update_context.frame_rate;
Expand Down Expand Up @@ -2449,6 +2459,9 @@ pub struct PlayerBuilder {
ui: Option<Ui>,
video: Option<Video>,

// Notifications
notification_sender: Option<Sender<PlayerNotification>>,

// Misc. player configuration
autoplay: bool,
align: StageAlign,
Expand Down Expand Up @@ -2496,6 +2509,8 @@ impl PlayerBuilder {
ui: None,
video: None,

notification_sender: None,

autoplay: false,
align: StageAlign::default(),
forced_align: false,
Expand Down Expand Up @@ -2600,6 +2615,13 @@ impl PlayerBuilder {
self
}

/// Sets the channel for player notifications.
#[inline]
pub fn with_notification_sender(mut self, sender: Sender<PlayerNotification>) -> Self {
self.notification_sender = Some(sender);
self
}

/// Sets the stage scale mode and optionally prevents movies from changing it.
#[inline]
pub fn with_align(mut self, align: StageAlign, force: bool) -> Self {
Expand Down Expand Up @@ -2885,6 +2907,7 @@ impl PlayerBuilder {
spoofed_url: self.spoofed_url.clone(),
compatibility_rules: self.compatibility_rules.clone(),
stub_tracker: StubCollection::new(),
notification_sender: self.notification_sender,
#[cfg(feature = "egui")]
debug_ui: Default::default(),

Expand Down
1 change: 1 addition & 0 deletions desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs"] }
tracing-tracy = { version = "0.11.3", optional = true, features = ["demangle"] }
rand = "0.8.5"
thiserror.workspace = true
async-channel.workspace = true

[target.'cfg(target_os = "linux")'.dependencies]
ashpd = "0.10.2"
Expand Down
8 changes: 8 additions & 0 deletions desktop/assets/texts/en-US/preferences_dialog.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@ gamemode-tooltip =
Ruffle requests GameMode only when a movie is being played.
gamemode-default = Default
gamemode-default-tooltip = GameMode will be enabled only when power preference is set to high.

# See for context https://wiki.archlinux.org/title/Input_method
ime-enabled = Input Method
ime-enabled-experimental = (experimental)
ime-enabled-tooltip = An input method allows inputting characters that are not available on the kaybord, for instance Chinese, Japanese, or Korean characters.
ime-enabled-default = Default
ime-enabled-on = On
ime-enabled-off = Off
42 changes: 41 additions & 1 deletion desktop/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::util::{
};
use anyhow::Error;
use gilrs::{Event, EventType, Gilrs};
use ruffle_core::events::{ImeEvent, ImeNotification, PlayerNotification};
use ruffle_core::swf::HeaderExt;
use ruffle_core::PlayerEvent;
use ruffle_render::backend::ViewportDimensions;
Expand All @@ -16,7 +17,7 @@ use std::time::Instant;
use url::Url;
use winit::application::ApplicationHandler;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize, Size};
use winit::event::{ElementState, KeyEvent, Modifiers, StartCause, WindowEvent};
use winit::event::{ElementState, Ime, KeyEvent, Modifiers, StartCause, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
use winit::keyboard::{Key, NamedKey};
use winit::window::{Fullscreen, Icon, WindowAttributes, WindowId};
Expand Down Expand Up @@ -227,6 +228,18 @@ impl MainWindow {
};
self.check_redraw();
}
WindowEvent::Ime(ime) => match ime {
Ime::Enabled => {}
Ime::Preedit(text, cursor) => {
self.player
.handle_event(PlayerEvent::Ime(ImeEvent::Preedit(text, cursor)));
}
Ime::Commit(text) => {
self.player
.handle_event(PlayerEvent::Ime(ImeEvent::Commit(text)));
}
Ime::Disabled => {}
},
_ => (),
}
}
Expand Down Expand Up @@ -580,6 +593,33 @@ impl ApplicationHandler<RuffleEvent> for App {
}
}

(Some(main_window), RuffleEvent::PlayerNotification(notification)) => {
match notification {
PlayerNotification::ImeNotification(ImeNotification::ImeReady {
purpose,
cursor_area,
}) => {
let ime_enabled = main_window.preferences.ime_enabled().unwrap_or(false);
main_window.gui.set_ime_allowed(ime_enabled);
main_window.gui.set_ime_purpose(purpose);
main_window.gui.set_ime_cursor_area(cursor_area);
}
PlayerNotification::ImeNotification(ImeNotification::ImePurposeUpdated(
purpose,
)) => {
main_window.gui.set_ime_purpose(purpose);
}
PlayerNotification::ImeNotification(ImeNotification::ImeCursorAreaUpdated(
cursor_area,
)) => {
main_window.gui.set_ime_cursor_area(cursor_area);
}
PlayerNotification::ImeNotification(ImeNotification::ImeNotReady) => {
main_window.gui.set_ime_allowed(false);
}
}
}

(_, RuffleEvent::ExitRequested) => {
event_loop.exit();
}
Expand Down
Loading