slint/internal/backends/gl/wasm_input_helper.rs
Olivier Goffart 9ec6ed0406 wasm: fix tab and back tab
The situation differs depending if the widget show the virtual keyboard or not:
For widget that don't show the virtual keyboard, we rely on the winit events,
but for some reason we don't recieve WindowEvent::ModifiersChanged events with
wasm. Since the event handling in winit is about to be rewriten, I did not
bother reporting the bug upstream, but just work around by using the deprecated
API. So that way shift + tab will no longer be understood as just tab.

For widget using the virtual keyboard, then the event handling is in
wasm_input_helper.rs.  There is a couple of issues:

 - We must map shift+tab to backtab.
 - We need to prevent the default events to trigger, so that tab and other
   shortcuts don't take effect on the browser. Winit already inhibit these
   events so we must do the same otherwise tab and shift+tab would change the
   html focus.
 - By luck, tab used to give the focus back to the canvas before (see previous
   point) and that's why it worked. But now that we don't do that anymore,
   hiding the virtual keyboard should actually re-focus the canvas
 - That will cause the focus event to be intercepted by winit, and will cause
   recursions and borrow error, so we make sure that we do not recurse when
   getting the focus event
2022-04-22 13:58:37 +02:00

284 lines
11 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint-ui.com>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-commercial
//! Helper for wasm that adds a hidden `<input>` and process its events
//!
//! Without it, the key event are sent to the canvas and processed by winit.
//! But this winit handling doesn't show the keyboard on mobile devices, and
//! also has bugs as the modifiers are not reported the same way and we don't
//! record them.
//!
//! This just interpret the keyup and keydown events. But this is not working
//! on mobile either as we only get these for a bunch of non-printable key
//! that do not interact with the composing input. For anything else we
//! check that we get input event when no normal key are pressed, and we send
//! that as text.
//! Since the slint core lib doesn't support composition yet, when we get
//! composition event, we just send that as key, and if the composition changes,
//! we just simulate a few backspaces.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
use i_slint_core::input::{KeyEvent, KeyEventType, KeyboardModifiers};
use i_slint_core::SharedString;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::convert::FromWasmAbi;
use wasm_bindgen::JsCast;
pub struct WasmInputHelper {
input: web_sys::HtmlInputElement,
canvas: web_sys::HtmlCanvasElement,
}
#[derive(Default)]
struct WasmInputState {
/// If there was a "keydown" event recieved not part of a composition
has_key_down: bool,
/// The current composing text
composition: String,
}
impl WasmInputState {
/// Update the composition text and return the number of character to rollback and the string to add
fn text_from_compose(&mut self, data: String, is_end: bool) -> (SharedString, usize) {
let mut data_iter = data.char_indices().peekable();
let mut composition_iter = self.composition.chars().peekable();
// Skip common prefix
while let (Some(c), Some((_, d))) = (composition_iter.peek(), data_iter.peek()) {
if c != d {
break;
}
composition_iter.next();
data_iter.next();
}
let to_delete = composition_iter.count();
let result = if let Some((idx, _)) = data_iter.next() {
SharedString::from(&data[idx..])
} else {
SharedString::default()
};
self.composition = if is_end { String::new() } else { data };
(result, to_delete)
}
}
impl WasmInputHelper {
#[allow(unused)]
pub fn new(
window: Weak<i_slint_core::window::Window>,
canvas: web_sys::HtmlCanvasElement,
) -> Self {
let input = web_sys::window()
.unwrap()
.document()
.unwrap()
.create_element("input")
.unwrap()
.dyn_into::<web_sys::HtmlInputElement>()
.unwrap();
let style = input.style();
style.set_property("z-index", "-1").unwrap();
style.set_property("position", "absolute").unwrap();
style.set_property("left", &format!("{}px", canvas.offset_left())).unwrap();
style.set_property("top", &format!("{}px", canvas.offset_top())).unwrap();
style.set_property("width", &format!("{}px", canvas.offset_width())).unwrap();
style.set_property("height", &format!("{}px", canvas.offset_height())).unwrap();
style.set_property("opacity", "0").unwrap(); // Hide the cursor on mobile Safari
input.set_attribute("autocapitalize", "none").unwrap(); // Otherwise everything would be capitalized as we need to clear the input
canvas.before_with_node_1(&input).unwrap();
let mut h = Self { input, canvas: canvas.clone() };
let shared_state = Rc::new(RefCell::new(WasmInputState::default()));
let win = window.clone();
h.add_event_listener("blur", move |_: web_sys::Event| {
// Make sure that the window gets marked as unfocused when the focus leaves the input
if let Some(window) = win.upgrade() {
if !canvas.matches(":focus").unwrap_or(false) {
window.set_active(false);
window.set_focus(false);
}
}
});
let win = window.clone();
let shared_state2 = shared_state.clone();
h.add_event_listener("keydown", move |e: web_sys::KeyboardEvent| {
if let (Some(window), Some(text)) = (win.upgrade(), event_text(&e)) {
e.prevent_default();
shared_state2.borrow_mut().has_key_down = true;
window.process_key_input(&KeyEvent {
modifiers: modifiers(&e),
text,
event_type: KeyEventType::KeyPressed,
});
}
});
let win = window.clone();
let shared_state2 = shared_state.clone();
h.add_event_listener("keyup", move |e: web_sys::KeyboardEvent| {
if let (Some(window), Some(text)) = (win.upgrade(), event_text(&e)) {
e.prevent_default();
shared_state2.borrow_mut().has_key_down = false;
window.process_key_input(&KeyEvent {
modifiers: modifiers(&e),
text,
event_type: KeyEventType::KeyReleased,
});
}
});
let win = window.clone();
let shared_state2 = shared_state.clone();
let input = h.input.clone();
h.add_event_listener("input", move |e: web_sys::InputEvent| {
if let (Some(window), Some(data)) = (win.upgrade(), e.data()) {
if !e.is_composing() && e.input_type() != "insertCompositionText" {
if !shared_state2.borrow_mut().has_key_down {
let text = SharedString::from(data.as_str());
window.clone().process_key_input(&KeyEvent {
modifiers: Default::default(),
text: text.clone(),
event_type: KeyEventType::KeyPressed,
});
window.process_key_input(&KeyEvent {
modifiers: Default::default(),
text,
event_type: KeyEventType::KeyReleased,
});
shared_state2.borrow_mut().has_key_down = false;
}
input.set_value("");
}
}
});
for event in ["compositionend", "compositionupdate"] {
let win = window.clone();
let shared_state2 = shared_state.clone();
let input = h.input.clone();
h.add_event_listener(event, move |e: web_sys::CompositionEvent| {
if let (Some(window), Some(data)) = (win.upgrade(), e.data()) {
let is_end = event == "compositionend";
let (text, to_delete) =
shared_state2.borrow_mut().text_from_compose(data, is_end);
if to_delete > 0 {
let mut buffer = [0; 6];
let backspace = SharedString::from(
i_slint_core::input::key_codes::Backspace.encode_utf8(&mut buffer)
as &str,
);
for _ in 0..to_delete {
window.clone().process_key_input(&KeyEvent {
modifiers: Default::default(),
text: backspace.clone(),
event_type: KeyEventType::KeyPressed,
});
}
}
window.clone().process_key_input(&KeyEvent {
modifiers: Default::default(),
text: text.clone(),
event_type: KeyEventType::KeyPressed,
});
window.process_key_input(&KeyEvent {
modifiers: Default::default(),
text,
event_type: KeyEventType::KeyReleased,
});
if is_end {
input.set_value("");
}
}
});
}
h
}
/// Returns wether the fake input element has focus
pub fn has_focus(&self) -> bool {
self.input.matches(":focus").unwrap_or(false)
}
pub fn show(&self) {
self.input.style().set_property("visibility", "visible").unwrap();
self.input.focus().unwrap();
}
pub fn hide(&self) {
if self.has_focus() {
self.canvas.focus().unwrap()
}
self.input.style().set_property("visibility", "hidden").unwrap();
}
fn add_event_listener<Arg: FromWasmAbi + 'static>(
&mut self,
event: &str,
closure: impl Fn(Arg) + 'static,
) {
let closure = move |arg: Arg| {
closure(arg);
crate::event_loop::GLOBAL_PROXY.with(|global_proxy| {
if let Ok(mut x) = global_proxy.try_borrow_mut() {
if let Some(proxy) = &mut *x {
proxy.send_event(crate::event_loop::CustomEvent::WakeEventLoopWorkaround)
}
}
});
};
let closure = Closure::wrap(Box::new(closure) as Box<dyn Fn(_)>);
self.input
.add_event_listener_with_callback(event, closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}
}
fn event_text(e: &web_sys::KeyboardEvent) -> Option<SharedString> {
if e.is_composing() {
return None;
}
let key = e.key();
let convert = |char: char| {
let mut buffer = [0; 6];
Some(SharedString::from(char.encode_utf8(&mut buffer) as &str))
};
macro_rules! check_non_printable_code {
($($char:literal # $name:ident # $($_qt:ident)|* # $($_winit:ident)|* ;)*) => {
match key.as_str() {
"Tab" if e.shift_key() => return convert(i_slint_core::input::key_codes::Backtab),
$(stringify!($name) => {
return convert($char);
})*
// Why did we diverge from DOM there?
"ArrowLeft" => return convert(i_slint_core::input::key_codes::LeftArrow),
"ArrowUp" => return convert(i_slint_core::input::key_codes::UpArrow),
"ArrowRight" => return convert(i_slint_core::input::key_codes::RightArrow),
"ArrowDown" => return convert(i_slint_core::input::key_codes::DownArrow),
"Enter" => return convert(i_slint_core::input::key_codes::Return),
_ => (),
}
};
}
i_slint_common::for_each_special_keys!(check_non_printable_code);
if key.chars().count() == 1 {
Some(key.as_str().into())
} else {
None
}
}
fn modifiers(e: &web_sys::KeyboardEvent) -> KeyboardModifiers {
KeyboardModifiers {
alt: e.alt_key(),
control: e.ctrl_key(),
meta: e.meta_key(),
shift: e.shift_key(),
}
}