Skip to content

Commit b92aa87

Browse files
committed
Implement support for input method editors and screen readers for web runner
1 parent 2c6dad5 commit b92aa87

3 files changed

Lines changed: 153 additions & 11 deletions

File tree

crates/launcher/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,15 @@ web-sys = { workspace = true, features = [
114114
"BeforeUnloadEvent",
115115
"console",
116116
"ClipboardEvent",
117+
"CompositionEvent",
117118
"CssStyleDeclaration",
118119
"DedicatedWorkerGlobalScope",
119120
"Document",
120121
"Event",
121122
"HtmlElement",
122123
"HtmlCanvasElement",
123124
"HtmlImageElement",
125+
"InputEvent",
124126
"KeyboardEvent",
125127
"MediaQueryList",
126128
"MediaQueryListEvent",
@@ -130,6 +132,8 @@ web-sys = { workspace = true, features = [
130132
"MutationRecord",
131133
"OffscreenCanvas",
132134
"Performance",
135+
"SpeechSynthesis",
136+
"SpeechSynthesisUtterance",
133137
"Touch",
134138
"TouchEvent",
135139
"WheelEvent",

crates/launcher/src/entrypoint/web/runner/events.rs

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,8 @@ where
4545
E: AsRef<web_sys::Event> + wasm_bindgen::JsCast,
4646
{
4747
let closure = wasm_bindgen::closure::Closure::new(move |event: web_sys::Event| {
48-
let mut state = state_cell.borrow_mut();
4948
if !super::has_panicked() {
50-
listener(&mut state, event.unchecked_into());
49+
listener(&mut state_cell.borrow_mut(), event.unchecked_into());
5150
}
5251
});
5352

@@ -142,6 +141,7 @@ pub(super) fn register_events(
142141
output_rx: flume::Receiver<super::Output>,
143142
panic_rx: oneshot::Receiver<()>,
144143
window: web_sys::Window,
144+
document: web_sys::Document,
145145
) -> Result<(), wasm_bindgen::JsValue> {
146146
// Set up panic handler to unsubscribe all event handlers on panic
147147
wasm_bindgen_futures::spawn_local(async move {
@@ -156,10 +156,11 @@ pub(super) fn register_events(
156156
});
157157
});
158158

159-
let document = window.document().unwrap();
160-
161159
let state_cell = std::rc::Rc::new(std::cell::RefCell::new(state));
162-
let canvas = state_cell.borrow().canvas.clone();
160+
let state = state_cell.borrow();
161+
let canvas = state.canvas.clone();
162+
let input = state.input.clone();
163+
drop(state);
163164

164165
// Register event listener for screen resizing
165166
let listener = {
@@ -451,14 +452,13 @@ pub(super) fn register_events(
451452
// Register event listener for keyboard key presses and releases
452453
let listener_builder = |pressed| {
453454
move |state: &mut super::MainState, event: web_sys::KeyboardEvent| {
454-
// TODO: handle input method editors
455455
let modifiers = event.egui_modifiers();
456456
state
457457
.event_tx
458458
.send(super::Event::Modifiers(modifiers))
459459
.unwrap();
460460
let key = event.key();
461-
if pressed && !modifiers.command && key.len() == 1 {
461+
if pressed && !modifiers.command && state.ime.is_none() && key.len() == 1 {
462462
state
463463
.event_tx
464464
.send(super::Event::Egui(egui::Event::Text(key.clone())))
@@ -516,6 +516,68 @@ pub(super) fn register_events(
516516
listener_builder(false),
517517
)?;
518518

519+
// Register event listener for inputting text while the egui app wants text input
520+
let listener = move |state: &mut super::MainState, event: web_sys::InputEvent| {
521+
if state.ime.is_none() || event.is_composing() {
522+
return;
523+
}
524+
let text = state.input.value();
525+
if text.is_empty() {
526+
return;
527+
}
528+
state
529+
.event_tx
530+
.send(super::Event::Egui(egui::Event::Text(text)))
531+
.unwrap();
532+
state.input.set_value("");
533+
};
534+
add_event_listener(state_cell.clone(), &input, "input", listener)?;
535+
536+
// Register event listener for starting to compose text an input method editor while the egui
537+
// app wants text input
538+
let listener = move |state: &mut super::MainState, _event: web_sys::CompositionEvent| {
539+
state
540+
.event_tx
541+
.send(super::Event::Egui(egui::Event::Ime(
542+
egui::ImeEvent::Enabled,
543+
)))
544+
.unwrap();
545+
state.input.set_value("");
546+
};
547+
add_event_listener(state_cell.clone(), &input, "compositionstart", listener)?;
548+
549+
// Register event listener for submitting composed text from an input method editor while the
550+
// egui app wants text input
551+
let listener = move |state: &mut super::MainState, event: web_sys::CompositionEvent| {
552+
let Some(text) = event.data() else {
553+
return;
554+
};
555+
state
556+
.event_tx
557+
.send(super::Event::Egui(egui::Event::Ime(
558+
egui::ImeEvent::Commit(text),
559+
)))
560+
.unwrap();
561+
state.input.set_value("");
562+
};
563+
add_event_listener(state_cell.clone(), &input, "compositionend", listener)?;
564+
565+
// Register event listener for when the preview of the text in an input method editor updates
566+
// while the egui wapp wants text input
567+
let listener = move |state: &mut super::MainState, event: web_sys::CompositionEvent| {
568+
let Some(text) = event.data() else {
569+
return;
570+
};
571+
state
572+
.event_tx
573+
.send(super::Event::Egui(egui::Event::Ime(
574+
egui::ImeEvent::Preedit(text),
575+
)))
576+
.unwrap();
577+
state.input.set_value("");
578+
};
579+
add_event_listener(state_cell.clone(), &input, "compositionupdate", listener)?;
580+
519581
// Register event listener for text pasting
520582
let listener = move |state: &mut super::MainState, event: web_sys::ClipboardEvent| {
521583
if let Some(data) = event.clipboard_data() {
@@ -608,6 +670,7 @@ pub(super) fn register_events(
608670
zoom_factor,
609671
screen_reader_enabled,
610672
} => {
673+
// Update the cursor icon to the one specified by the app for this frame
611674
let _ = body_style.set_property(
612675
"cursor",
613676
match output.cursor_icon {
@@ -654,8 +717,47 @@ pub(super) fn register_events(
654717
},
655718
);
656719

657-
// TODO: handle input method editors and screen readers here
658-
let _ = screen_reader_enabled;
720+
// Speak the text description output by the app, if applicable
721+
if screen_reader_enabled {
722+
let text = output.events_description();
723+
if !text.is_empty() {
724+
if let (Ok(speech_synthesis), Ok(utterance)) = (
725+
window.speech_synthesis(),
726+
web_sys::SpeechSynthesisUtterance::new_with_text(&text),
727+
) {
728+
speech_synthesis.cancel();
729+
speech_synthesis.speak(&utterance);
730+
}
731+
}
732+
}
733+
734+
{
735+
let mut state = state_cell.borrow_mut();
736+
737+
// Focus the input handler if the app wants keyboard input, otherwise focus
738+
// the canvas
739+
if let Some(ime) = output.ime {
740+
if state.ime != Some(ime) {
741+
// Move the input handler to the position the app wants input method
742+
// editors to show up at
743+
let pos = ime.cursor_rect.center();
744+
let style = state.input.style();
745+
let _ = style.set_property("left", &format!("{}px", pos.x));
746+
let _ = style.set_property("top", &format!("{}px", pos.y));
747+
// Workaround for the input method editor sometimes opening in the
748+
// location the input handler was previously located at instead of
749+
// the new location we just moved it to when using Firefox
750+
let _ = state.input.focus();
751+
let _ = state.input.blur();
752+
}
753+
let _ = state.input.focus();
754+
} else {
755+
let _ = state.canvas.focus();
756+
}
757+
state.ime = output.ime;
758+
759+
state.zoom_factor = zoom_factor;
760+
}
659761

660762
if !output.copied_text.is_empty() {
661763
if let Err(e) = wasm_bindgen_futures::JsFuture::from(
@@ -684,8 +786,6 @@ pub(super) fn register_events(
684786
);
685787
}
686788
}
687-
688-
state_cell.borrow_mut().zoom_factor = zoom_factor;
689789
}
690790

691791
super::Output::StorageGet { key, oneshot_tx } => {

crates/launcher/src/entrypoint/web/runner/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
// terms of the Steamworks API by Valve Corporation, the licensors of this
2323
// Program grant you additional permission to convey the resulting work.
2424

25+
use wasm_bindgen::JsCast;
26+
2527
mod events;
2628
mod worker;
2729

@@ -148,6 +150,10 @@ struct MainState {
148150
event_tx: flume::Sender<Event>,
149151
/// The HTML canvas element that the web runner is rendering to
150152
canvas: web_sys::HtmlCanvasElement,
153+
/// The HTML input element that handles input method editors and mobile keyboards
154+
input: web_sys::HtmlInputElement,
155+
/// Value of the `ime` field of the egui platform output from the previous frame
156+
ime: Option<egui::output::IMEOutput>,
151157
/// The current egui zoom factor (`ctx.zoom_factor()`)
152158
zoom_factor: f32,
153159
/// JavaScript touch ID currently being tracked by the web runner
@@ -203,6 +209,12 @@ impl Runner {
203209
let Some(window) = web_sys::window() else {
204210
panic!("cannot create a `luminol_launcher::Runner` outside of the main thread");
205211
};
212+
let Some(document) = window.document() else {
213+
panic!("cannot create a `luminol_launcher::Runner` outside of the main thread");
214+
};
215+
let Some(body) = document.body() else {
216+
panic!("cannot create a `luminol_launcher::Runner` outside of the main thread");
217+
};
206218

207219
// Install a hook to set `HAS_PANICKED` to true when a panic occurs on any thread
208220
if !PANIC_HOOK_INSTALLED.load(portable_atomic::Ordering::Relaxed) {
@@ -211,6 +223,9 @@ impl Runner {
211223
HAS_PANICKED.store(true, portable_atomic::Ordering::Release);
212224
old_panic_hook(info);
213225
}));
226+
if let Some(input) = document.get_element_by_id("luminol-ime") {
227+
input.remove();
228+
}
214229
PANIC_HOOK_INSTALLED.store(true, portable_atomic::Ordering::Relaxed);
215230
}
216231

@@ -220,6 +235,26 @@ impl Runner {
220235
.flatten()
221236
.map(|query| query.matches());
222237

238+
// Initialize handler for input method editors
239+
let input = if let Some(input) = document.get_element_by_id("luminol-ime") {
240+
input.dyn_into()?
241+
} else {
242+
let input = document
243+
.create_element("input")?
244+
.unchecked_into::<web_sys::HtmlInputElement>();
245+
input.set_id("luminol-ime");
246+
input.set_type("text");
247+
let style = input.style();
248+
style.set_property("opacity", "0")?;
249+
style.set_property("width", "1px")?;
250+
style.set_property("height", "1px")?;
251+
style.set_property("position", "absolute")?;
252+
style.set_property("top", "0")?;
253+
style.set_property("left", "0")?;
254+
body.append_child(&input)?;
255+
input
256+
};
257+
223258
let (event_tx, event_rx) = flume::unbounded();
224259
let (output_tx, output_rx) = flume::unbounded();
225260
let (panic_tx, panic_rx) = oneshot::channel();
@@ -228,12 +263,15 @@ impl Runner {
228263
MainState {
229264
event_tx,
230265
canvas,
266+
input,
267+
ime: None,
231268
zoom_factor: 1.,
232269
touch_id: None,
233270
},
234271
output_rx,
235272
panic_rx,
236273
window,
274+
document,
237275
)?;
238276

239277
Ok(Self {

0 commit comments

Comments
 (0)