Skip to content

Commit 456d249

Browse files
committed
winit-web: Add IME support with EditContext API
Implement Input Method Editor support for web platform to handle text composition for non-Latin scripts (CJK, etc.): - Add EditContext API integration with fallback detection - Handle compositionstart/compositionend events for IME lifecycle - Process textupdate events for composition text updates - Implement Window::request_ime_update() with Enable/Update/Disable - Emit WindowEvent::Ime(Preedit/Commit) to application Note: Preedit events currently don't include cursor range information. Fixes #4424
1 parent de78ffd commit 456d249

File tree

4 files changed

+197
-7
lines changed

4 files changed

+197
-7
lines changed

winit-web/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ web_sys = { workspace = true, features = [
3939
"Blob",
4040
"BlobPropertyBag",
4141
"console",
42+
"CompositionEvent",
4243
"CssStyleDeclaration",
4344
"Document",
4445
"DomException",

winit-web/src/event_loop/window_target.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use web_sys::Element;
88
use winit_core::application::ApplicationHandler;
99
use winit_core::cursor::{CustomCursor as CoreCustomCursor, CustomCursorSource};
1010
use winit_core::error::{NotSupportedError, RequestError};
11-
use winit_core::event::{ElementState, KeyEvent, TouchPhase, WindowEvent};
11+
use winit_core::event::{ElementState, Ime, KeyEvent, TouchPhase, WindowEvent};
1212
use winit_core::event_loop::{
1313
ActiveEventLoop as RootActiveEventLoop, ControlFlow, DeviceEvents,
1414
EventLoopProxy as RootEventLoopProxy, OwnedDisplayHandle as CoreOwnedDisplayHandle,
@@ -439,6 +439,42 @@ impl ActiveEventLoop {
439439
canvas.on_animation_frame(move || runner.request_redraw(window_id));
440440

441441
canvas.on_context_menu();
442+
443+
canvas.on_composition_start({
444+
let runner = self.runner.clone();
445+
move |data, position| {
446+
if let Some(data) = data {
447+
runner.send_event(Event::WindowEvent {
448+
window_id,
449+
event: WindowEvent::Ime(Ime::Preedit(data, position)),
450+
});
451+
}
452+
}
453+
});
454+
455+
canvas.on_composition_end({
456+
let runner = self.runner.clone();
457+
move |data| {
458+
if let Some(data) = data {
459+
runner.send_event(Event::WindowEvent {
460+
window_id,
461+
event: WindowEvent::Ime(Ime::Commit(data)),
462+
});
463+
}
464+
}
465+
});
466+
467+
canvas.on_text_update({
468+
let runner = self.runner.clone();
469+
move |data, position| {
470+
if let Some(data) = data {
471+
runner.send_event(Event::WindowEvent {
472+
window_id,
473+
event: WindowEvent::Ime(Ime::Preedit(data, position)),
474+
});
475+
}
476+
}
477+
});
442478
}
443479

444480
pub(crate) fn set_poll_strategy(&self, strategy: PollStrategy) {

winit-web/src/web_sys/canvas.rs

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ use std::rc::Rc;
44
use std::sync::{Arc, Mutex};
55

66
use dpi::{LogicalPosition, PhysicalPosition, PhysicalSize};
7+
use js_sys::{Array, Function, Reflect};
78
use smol_str::SmolStr;
8-
use wasm_bindgen::JsCast;
99
use wasm_bindgen::closure::Closure;
10+
use wasm_bindgen::{JsCast, JsValue};
1011
use web_sys::{
11-
CssStyleDeclaration, Document, Event, FocusEvent, HtmlCanvasElement, KeyboardEvent, Navigator,
12-
PointerEvent, WheelEvent,
12+
CompositionEvent, CssStyleDeclaration, Document, Event, EventTarget, FocusEvent,
13+
HtmlCanvasElement, KeyboardEvent, Navigator, PointerEvent, WheelEvent,
1314
};
1415
use winit_core::error::RequestError;
1516
use winit_core::event::{
@@ -57,6 +58,9 @@ struct Handlers {
5758
on_intersect: Option<IntersectionObserverHandle>,
5859
on_touch_end: Option<EventListenerHandle<dyn FnMut(Event)>>,
5960
on_context_menu: Option<EventListenerHandle<dyn FnMut(PointerEvent)>>,
61+
on_composition_start: Option<EventListenerHandle<dyn FnMut(CompositionEvent)>>,
62+
on_composition_end: Option<EventListenerHandle<dyn FnMut(CompositionEvent)>>,
63+
on_text_update: Option<EventListenerHandle<dyn FnMut(Event)>>,
6064
}
6165

6266
pub struct Common {
@@ -67,6 +71,7 @@ pub struct Common {
6771
/// the DPI factor is maintained. Note: this is read-only because we use a pointer to this
6872
/// for [`WindowHandle`][rwh_06::WindowHandle].
6973
raw: Rc<HtmlCanvasElement>,
74+
raw_edit_context: Option<Rc<JsValue>>,
7075
style: Style,
7176
old_size: Rc<Cell<PhysicalSize<u32>>>,
7277
current_size: Rc<Cell<PhysicalSize<u32>>>,
@@ -102,6 +107,15 @@ impl Canvas {
102107
.unchecked_into(),
103108
};
104109

110+
let edit_context = if let Ok(edit_context_ctor) =
111+
Reflect::get(&window, &JsValue::from_str("EditContext"))
112+
{
113+
let edit_context_ctor = JsCast::unchecked_ref::<js_sys::Function>(&edit_context_ctor);
114+
Reflect::construct(&edit_context_ctor, &Array::new()).ok()
115+
} else {
116+
None
117+
};
118+
105119
if web_attributes.append && !document.contains(Some(&canvas)) {
106120
document
107121
.body()
@@ -130,6 +144,7 @@ impl Canvas {
130144
document: document.clone(),
131145
navigator,
132146
raw: Rc::new(canvas.clone()),
147+
raw_edit_context: edit_context.map(Rc::new),
133148
style,
134149
old_size: Rc::default(),
135150
current_size: Rc::default(),
@@ -185,6 +200,9 @@ impl Canvas {
185200
on_intersect: None,
186201
on_touch_end: None,
187202
on_context_menu: None,
203+
on_composition_start: None,
204+
on_composition_end: None,
205+
on_text_update: None,
188206
}),
189207
})
190208
}
@@ -464,6 +482,89 @@ impl Canvas {
464482
}));
465483
}
466484

485+
pub(crate) fn on_composition_start<F>(&self, mut handler: F)
486+
where
487+
F: 'static + FnMut(Option<String>, Option<(usize, usize)>),
488+
{
489+
let prevent_default = Rc::clone(&self.prevent_default);
490+
self.handlers.borrow_mut().on_composition_start =
491+
self.common.add_ime_event("compositionstart", move |event: CompositionEvent| {
492+
if prevent_default.get() {
493+
event.prevent_default();
494+
}
495+
handler(event.data(), None);
496+
});
497+
}
498+
499+
pub(crate) fn on_composition_end<F>(&self, mut handler: F)
500+
where
501+
F: 'static + FnMut(Option<String>),
502+
{
503+
let prevent_default = Rc::clone(&self.prevent_default);
504+
self.handlers.borrow_mut().on_composition_end =
505+
self.common.add_ime_event("compositionend", move |event: CompositionEvent| {
506+
if prevent_default.get() {
507+
event.prevent_default();
508+
}
509+
handler(event.data());
510+
});
511+
}
512+
513+
pub(crate) fn on_text_update<F>(&self, mut handler: F)
514+
where
515+
F: 'static + FnMut(Option<String>, Option<(usize, usize)>),
516+
{
517+
let prevent_default = Rc::clone(&self.prevent_default);
518+
self.handlers.borrow_mut().on_text_update =
519+
self.common.add_ime_event("textupdate", move |event: Event| {
520+
if prevent_default.get() {
521+
event.prevent_default();
522+
}
523+
let edit_context = JsValue::from(event.target());
524+
let text_update_event = JsValue::from(event);
525+
if let (Ok(text), Ok(update_range_end)) = (
526+
Reflect::get(&text_update_event, &JsValue::from_str("text")),
527+
Reflect::get(&text_update_event, &JsValue::from_str("updateRangeEnd")),
528+
) {
529+
handler(text.as_string(), None);
530+
531+
// Clear the text update to avoid repeated updates.
532+
let Ok(func) = Reflect::get(&edit_context, &JsValue::from_str("updateText"))
533+
else {
534+
return;
535+
};
536+
let func = JsCast::unchecked_ref::<Function>(&func);
537+
let _ = func.call3(
538+
&edit_context,
539+
&JsValue::from_f64(0.0),
540+
&JsValue::from_f64(update_range_end.as_f64().unwrap_or(0.0)),
541+
&JsValue::from_str(""),
542+
);
543+
}
544+
});
545+
}
546+
547+
pub(crate) fn is_support_edit_context(&self) -> bool {
548+
self.common.raw_edit_context.is_some()
549+
}
550+
551+
pub(crate) fn enable_edit_context(&self) {
552+
if let Some(raw_edit_context) = &self.common.raw_edit_context {
553+
let canvas_js = JsValue::from(self.raw());
554+
Reflect::set(&canvas_js, &JsValue::from_str("editContext"), raw_edit_context)
555+
.expect("Failed to set editContext on canvas");
556+
}
557+
}
558+
559+
pub(crate) fn disable_edit_context(&self) {
560+
if self.common.raw_edit_context.is_none() {
561+
return;
562+
}
563+
let canvas_js = JsValue::from(self.raw());
564+
Reflect::set(&canvas_js, &JsValue::from_str("editContext"), &JsValue::NULL)
565+
.expect("Failed to unset editContext on canvas");
566+
}
567+
467568
pub(crate) fn request_fullscreen(&self, fullscreen: Fullscreen) {
468569
fullscreen::request_fullscreen(
469570
self.main_thread,
@@ -559,6 +660,25 @@ impl Common {
559660
EventListenerHandle::new(self.raw.deref().clone(), event_name, Closure::new(handler))
560661
}
561662

663+
pub fn add_ime_event<E, F>(
664+
&self,
665+
event_name: &'static str,
666+
handler: F,
667+
) -> Option<EventListenerHandle<dyn FnMut(E)>>
668+
where
669+
E: 'static + AsRef<web_sys::Event> + wasm_bindgen::convert::FromWasmAbi,
670+
F: 'static + FnMut(E),
671+
{
672+
if let Some(edit_context) =
673+
self.raw_edit_context.as_ref().map(|edit_context| edit_context.deref().clone())
674+
{
675+
let edit_context = JsCast::unchecked_into::<EventTarget>(edit_context);
676+
Some(EventListenerHandle::new(edit_context, event_name, Closure::new(handler)))
677+
} else {
678+
None
679+
}
680+
}
681+
562682
pub fn raw(&self) -> &HtmlCanvasElement {
563683
&self.raw
564684
}

winit-web/src/window.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use winit_core::error::{NotSupportedError, RequestError};
1212
use winit_core::icon::Icon;
1313
use winit_core::monitor::{Fullscreen, MonitorHandle as CoremMonitorHandle};
1414
use winit_core::window::{
15-
CursorGrabMode, ImeRequestError, ResizeDirection, Theme, UserAttentionType,
15+
CursorGrabMode, ImeRequest, ImeRequestError, ResizeDirection, Theme, UserAttentionType,
1616
Window as RootWindow, WindowAttributes, WindowButtons, WindowId, WindowLevel,
1717
};
1818

@@ -41,6 +41,20 @@ pub struct Inner {
4141
destroy_fn: Option<Box<dyn FnOnce()>>,
4242
}
4343

44+
impl Inner {
45+
pub(crate) fn is_support_edit_context(&self) -> bool {
46+
self.canvas.is_support_edit_context()
47+
}
48+
49+
pub(crate) fn enable_edit_context(&self) {
50+
self.canvas.enable_edit_context()
51+
}
52+
53+
pub(crate) fn disable_edit_context(&self) {
54+
self.canvas.disable_edit_context()
55+
}
56+
}
57+
4458
impl Window {
4559
pub(crate) fn new(
4660
target: &ActiveEventLoop,
@@ -308,8 +322,27 @@ impl RootWindow for Window {
308322
None
309323
}
310324

311-
fn request_ime_update(&self, _: winit_core::window::ImeRequest) -> Result<(), ImeRequestError> {
312-
Err(ImeRequestError::NotSupported)
325+
fn request_ime_update(
326+
&self,
327+
request: winit_core::window::ImeRequest,
328+
) -> Result<(), ImeRequestError> {
329+
let support_edit_context = self.inner.queue(|inner| inner.is_support_edit_context());
330+
if support_edit_context {
331+
match request {
332+
ImeRequest::Enable(_ime_enable_request) => self.inner.dispatch(move |inner| {
333+
inner.enable_edit_context();
334+
}),
335+
ImeRequest::Update(_ime_request_data) => {
336+
// Currently an intentional no-op
337+
},
338+
ImeRequest::Disable => {
339+
self.inner.dispatch(move |inner| inner.disable_edit_context())
340+
},
341+
}
342+
Ok(())
343+
} else {
344+
Err(ImeRequestError::NotSupported)
345+
}
313346
}
314347

315348
fn focus_window(&self) {

0 commit comments

Comments
 (0)