From f9486a2d8874c8bc78ccd3895fac4047af6fb3b5 Mon Sep 17 00:00:00 2001 From: bruvzg <7645683+bruvzg@users.noreply.github.com> Date: Tue, 11 Jul 2023 11:17:35 +0300 Subject: [PATCH] [Web] Add IME input support. --- platform/web/display_server_web.cpp | 153 ++++++++++++++++++-- platform/web/display_server_web.h | 28 ++++ platform/web/godot_js.h | 5 + platform/web/js/libs/library_godot_input.js | 135 ++++++++++++++++- 4 files changed, 307 insertions(+), 14 deletions(-) diff --git a/platform/web/display_server_web.cpp b/platform/web/display_server_web.cpp index 022e044185c..b4a190d47e2 100644 --- a/platform/web/display_server_web.cpp +++ b/platform/web/display_server_web.cpp @@ -174,6 +174,11 @@ void DisplayServerWeb::_key_callback(const String &p_key_event_code, const Strin // Resume audio context after input in case autoplay was denied. OS_Web::get_singleton()->resume_audio(); + DisplayServerWeb *ds = get_singleton(); + if (ds->ime_started) { + return; + } + char32_t c = 0x00; String unicode = p_key_event_key; if (unicode.length() == 1) { @@ -183,17 +188,21 @@ void DisplayServerWeb::_key_callback(const String &p_key_event_code, const Strin Key keycode = dom_code2godot_scancode(p_key_event_code.utf8().get_data(), p_key_event_key.utf8().get_data(), false); Key scancode = dom_code2godot_scancode(p_key_event_code.utf8().get_data(), p_key_event_key.utf8().get_data(), true); - Ref ev; - ev.instantiate(); - ev->set_echo(p_repeat); - ev->set_keycode(fix_keycode(c, keycode)); - ev->set_physical_keycode(scancode); - ev->set_key_label(fix_key_label(c, keycode)); - ev->set_unicode(fix_unicode(c)); - ev->set_pressed(p_pressed); - dom2godot_mod(ev, p_modifiers, fix_keycode(c, keycode)); + DisplayServerWeb::KeyEvent ke; - Input::get_singleton()->parse_input_event(ev); + ke.pressed = p_pressed; + ke.echo = p_repeat; + ke.raw = true; + ke.keycode = fix_keycode(c, keycode); + ke.physical_keycode = scancode; + ke.key_label = fix_key_label(c, keycode); + ke.unicode = fix_unicode(c); + ke.mod = p_modifiers; + + if (ds->key_event_pos >= ds->key_event_buffer.size()) { + ds->key_event_buffer.resize(1 + ds->key_event_pos); + } + ds->key_event_buffer.write[ds->key_event_pos++] = ke; // Make sure to flush all events so we can call restricted APIs inside the event. Input::get_singleton()->flush_buffered_events(); @@ -619,7 +628,7 @@ int DisplayServerWeb::mouse_wheel_callback(double p_delta_x, double p_delta_y) { } int DisplayServerWeb::_mouse_wheel_callback(double p_delta_x, double p_delta_y) { - if (!godot_js_display_canvas_is_focused()) { + if (!godot_js_display_canvas_is_focused() && !godot_js_is_ime_focused()) { if (get_singleton()->cursor_inside_canvas) { godot_js_display_canvas_focus(); } else { @@ -726,7 +735,7 @@ bool DisplayServerWeb::is_touchscreen_available() const { // Virtual Keyboard void DisplayServerWeb::vk_input_text_callback(const char *p_text, int p_cursor) { - String text = p_text; + String text = String::utf8(p_text); #ifdef PROXY_TO_PTHREAD_ENABLED if (!Thread::is_main_thread()) { @@ -809,6 +818,100 @@ void DisplayServerWeb::_gamepad_callback(int p_index, int p_connected, const Str } } +// IME. +void DisplayServerWeb::ime_callback(int p_type, const char *p_text) { + String text = String::utf8(p_text); + +#ifdef PROXY_TO_PTHREAD_ENABLED + if (!Thread::is_main_thread()) { + callable_mp_static(DisplayServerWeb::_ime_callback).bind(p_type, text).call_deferred(); + return; + } +#endif + + _ime_callback(p_type, text); +} + +void DisplayServerWeb::_ime_callback(int p_type, const String &p_text) { + DisplayServerWeb *ds = get_singleton(); + // Resume audio context after input in case autoplay was denied. + OS_Web::get_singleton()->resume_audio(); + + switch (p_type) { + case 0: { + // IME start. + ds->ime_text = String(); + ds->ime_selection = Vector2i(); + for (int i = ds->key_event_pos - 1; i >= 0; i--) { + // Delete last raw keydown event from query. + if (ds->key_event_buffer[i].pressed && ds->key_event_buffer[i].raw) { + ds->key_event_buffer.remove_at(i); + ds->key_event_pos--; + break; + } + } + OS::get_singleton()->get_main_loop()->notification(MainLoop::NOTIFICATION_OS_IME_UPDATE); + ds->ime_started = true; + } break; + case 1: { + // IME update. + if (ds->ime_active && ds->ime_started) { + ds->ime_text = p_text; + ds->ime_selection = Vector2i(ds->ime_text.length(), ds->ime_text.length()); + OS::get_singleton()->get_main_loop()->notification(MainLoop::NOTIFICATION_OS_IME_UPDATE); + } + } break; + case 2: { + // IME commit. + if (ds->ime_active && ds->ime_started) { + ds->ime_started = false; + + ds->ime_text = String(); + ds->ime_selection = Vector2i(); + OS::get_singleton()->get_main_loop()->notification(MainLoop::NOTIFICATION_OS_IME_UPDATE); + + String text = p_text; + for (int i = 0; i < text.length(); i++) { + DisplayServerWeb::KeyEvent ke; + + ke.pressed = true; + ke.echo = false; + ke.raw = false; + ke.keycode = Key::NONE; + ke.physical_keycode = Key::NONE; + ke.key_label = Key::NONE; + ke.unicode = text[i]; + ke.mod = 0; + + if (ds->key_event_pos >= ds->key_event_buffer.size()) { + ds->key_event_buffer.resize(1 + ds->key_event_pos); + } + ds->key_event_buffer.write[ds->key_event_pos++] = ke; + } + } + } break; + default: + break; + } +} + +void DisplayServerWeb::window_set_ime_active(const bool p_active, WindowID p_window) { + ime_active = p_active; + godot_js_set_ime_active(p_active); +} + +void DisplayServerWeb::window_set_ime_position(const Point2i &p_pos, WindowID p_window) { + godot_js_set_ime_position(p_pos.x, p_pos.y); +} + +Point2i DisplayServerWeb::ime_get_selection() const { + return ime_selection; +} + +String DisplayServerWeb::ime_get_text() const { + return ime_text; +} + void DisplayServerWeb::process_joypads() { Input *input = Input::get_singleton(); int32_t pads = godot_js_input_gamepad_sample_count(); @@ -893,6 +996,9 @@ void DisplayServerWeb::_send_window_event_callback(int p_notification) { if (p_notification == DisplayServer::WINDOW_EVENT_MOUSE_ENTER || p_notification == DisplayServer::WINDOW_EVENT_MOUSE_EXIT) { ds->cursor_inside_canvas = p_notification == DisplayServer::WINDOW_EVENT_MOUSE_ENTER; } + if (godot_js_is_ime_focused() && (p_notification == DisplayServer::WINDOW_EVENT_FOCUS_IN || p_notification == DisplayServer::WINDOW_EVENT_FOCUS_OUT)) { + return; + } if (!ds->window_event_callback.is_null()) { Variant event = int(p_notification); ds->window_event_callback.call(event); @@ -1003,6 +1109,7 @@ DisplayServerWeb::DisplayServerWeb(const String &p_rendering_driver, WindowMode godot_js_input_paste_cb(&DisplayServerWeb::update_clipboard_callback); godot_js_input_drop_files_cb(&DisplayServerWeb::drop_files_js_callback); godot_js_input_gamepad_cb(&DisplayServerWeb::gamepad_callback); + godot_js_set_ime_cb(&DisplayServerWeb::ime_callback, &DisplayServerWeb::key_callback, key_event.code, key_event.key); // JS Display interface (js/libs/library_godot_display.js) godot_js_display_fullscreen_cb(&DisplayServerWeb::fullscreen_change_callback); @@ -1030,7 +1137,6 @@ bool DisplayServerWeb::has_feature(Feature p_feature) const { switch (p_feature) { //case FEATURE_GLOBAL_MENU: //case FEATURE_HIDPI: - //case FEATURE_IME: case FEATURE_ICON: case FEATURE_CLIPBOARD: case FEATURE_CURSOR_SHAPE: @@ -1044,6 +1150,9 @@ bool DisplayServerWeb::has_feature(Feature p_feature) const { //case FEATURE_WINDOW_TRANSPARENCY: //case FEATURE_KEEP_SCREEN_ON: //case FEATURE_ORIENTATION: + case FEATURE_IME: + // IME does not work with experimental VK support. + return godot_js_display_vk_available() == 0; case FEATURE_VIRTUAL_KEYBOARD: return godot_js_display_vk_available() != 0; case FEATURE_TEXT_TO_SPEECH: @@ -1263,6 +1372,24 @@ void DisplayServerWeb::process_events() { Input::get_singleton()->flush_buffered_events(); if (godot_js_input_gamepad_sample() == OK) { process_joypads(); + for (int i = 0; i < key_event_pos; i++) { + const DisplayServerWeb::KeyEvent &ke = key_event_buffer[i]; + + Ref ev; + ev.instantiate(); + ev->set_pressed(ke.pressed); + ev->set_echo(ke.echo); + ev->set_keycode(ke.keycode); + ev->set_physical_keycode(ke.physical_keycode); + ev->set_key_label(ke.key_label); + ev->set_unicode(ke.unicode); + if (ke.raw) { + dom2godot_mod(ev, ke.mod, ke.keycode); + } + + Input::get_singleton()->parse_input_event(ev); + } + key_event_pos = 0; } } diff --git a/platform/web/display_server_web.h b/platform/web/display_server_web.h index 51c6ab3c0ac..140aef952b1 100644 --- a/platform/web/display_server_web.h +++ b/platform/web/display_server_web.h @@ -82,6 +82,25 @@ private: uint64_t last_click_ms = 0; MouseButton last_click_button_index = MouseButton::NONE; + bool ime_active = false; + bool ime_started = false; + String ime_text; + Vector2i ime_selection; + + struct KeyEvent { + bool pressed = false; + bool echo = false; + bool raw = false; + Key keycode = Key::NONE; + Key physical_keycode = Key::NONE; + Key key_label = Key::NONE; + uint32_t unicode = 0; + int mod = 0; + }; + + Vector key_event_buffer; + int key_event_pos = 0; + bool swap_cancel_ok = false; bool tts = false; @@ -108,6 +127,8 @@ private: static void _gamepad_callback(int p_index, int p_connected, const String &p_id, const String &p_guid); WASM_EXPORT static void js_utterance_callback(int p_event, int p_id, int p_pos); static void _js_utterance_callback(int p_event, int p_id, int p_pos); + WASM_EXPORT static void ime_callback(int p_type, const char *p_text); + static void _ime_callback(int p_type, const String &p_text); WASM_EXPORT static void request_quit_callback(); static void _request_quit_callback(); WASM_EXPORT static void window_blur_callback(); @@ -162,6 +183,13 @@ public: virtual MouseMode mouse_get_mode() const override; virtual Point2i mouse_get_position() const override; + // ime + virtual void window_set_ime_active(const bool p_active, WindowID p_window = MAIN_WINDOW_ID) override; + virtual void window_set_ime_position(const Point2i &p_pos, WindowID p_window = MAIN_WINDOW_ID) override; + + virtual Point2i ime_get_selection() const override; + virtual String ime_get_text() const override; + // touch virtual bool is_touchscreen_available() const override; diff --git a/platform/web/godot_js.h b/platform/web/godot_js.h index 031e67e4861..a3d2632f17f 100644 --- a/platform/web/godot_js.h +++ b/platform/web/godot_js.h @@ -64,6 +64,11 @@ extern void godot_js_input_touch_cb(void (*p_callback)(int p_type, int p_count), extern void godot_js_input_key_cb(void (*p_callback)(int p_type, int p_repeat, int p_modifiers), char r_code[32], char r_key[32]); extern void godot_js_input_vibrate_handheld(int p_duration_ms); +extern void godot_js_set_ime_active(int p_active); +extern void godot_js_set_ime_position(int p_x, int p_y); +extern void godot_js_set_ime_cb(void (*p_input)(int p_type, const char *p_text), void (*p_callback)(int p_type, int p_repeat, int p_modifiers), char r_code[32], char r_key[32]); +extern int godot_js_is_ime_focused(); + // Input gamepad extern void godot_js_input_gamepad_cb(void (*p_on_change)(int p_index, int p_connected, const char *p_id, const char *p_guid)); extern int godot_js_input_gamepad_sample(); diff --git a/platform/web/js/libs/library_godot_input.js b/platform/web/js/libs/library_godot_input.js index eaff40f89c2..1292c468f58 100644 --- a/platform/web/js/libs/library_godot_input.js +++ b/platform/web/js/libs/library_godot_input.js @@ -28,6 +28,110 @@ /* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /**************************************************************************/ +/* + * IME API helper. + */ + +const GodotIME = { + $GodotIME__deps: ['$GodotRuntime', '$GodotEventListeners'], + $GodotIME__postset: 'GodotOS.atexit(function(resolve, reject) { GodotIME.clear(); resolve(); });', + $GodotIME: { + ime: null, + active: false, + + getModifiers: function (evt) { + return (evt.shiftKey + 0) + ((evt.altKey + 0) << 1) + ((evt.ctrlKey + 0) << 2) + ((evt.metaKey + 0) << 3); + }, + + ime_active: function (active) { + function focus_timer() { + GodotIME.active = true; + GodotIME.ime.focus(); + } + + if (GodotIME.ime) { + if (active) { + GodotIME.ime.style.display = 'block'; + setInterval(focus_timer, 100); + } else { + GodotIME.ime.style.display = 'none'; + GodotConfig.canvas.focus(); + GodotIME.active = false; + } + } + }, + + ime_position: function (x, y) { + if (GodotIME.ime) { + GodotIME.ime.style.left = `${x}px`; + GodotIME.ime.style.top = `${y}px`; + } + }, + + init: function (ime_cb, key_cb, code, key) { + function key_event_cb(pressed, evt) { + const modifiers = GodotIME.getModifiers(evt); + GodotRuntime.stringToHeap(evt.code, code, 32); + GodotRuntime.stringToHeap(evt.key, key, 32); + key_cb(pressed, evt.repeat, modifiers); + evt.preventDefault(); + } + function ime_event_cb(event) { + if (GodotIME.ime) { + if (event.type === 'compositionstart') { + ime_cb(0, null); + GodotIME.ime.innerHTML = ''; + } else if (event.type === 'compositionupdate') { + const ptr = GodotRuntime.allocString(event.data); + ime_cb(1, ptr); + GodotRuntime.free(ptr); + } else if (event.type === 'compositionend') { + const ptr = GodotRuntime.allocString(event.data); + ime_cb(2, ptr); + GodotRuntime.free(ptr); + GodotIME.ime.innerHTML = ''; + } + } + } + + const ime = document.createElement('div'); + ime.className = 'ime'; + ime.style.background = 'none'; + ime.style.opacity = 0.0; + ime.style.position = 'fixed'; + ime.style.left = '0px'; + ime.style.top = '0px'; + ime.style.width = '2px'; + ime.style.height = '2px'; + ime.style.display = 'none'; + ime.contentEditable = 'true'; + + GodotEventListeners.add(ime, 'compositionstart', ime_event_cb, false); + GodotEventListeners.add(ime, 'compositionupdate', ime_event_cb, false); + GodotEventListeners.add(ime, 'compositionend', ime_event_cb, false); + GodotEventListeners.add(ime, 'keydown', key_event_cb.bind(null, 1), false); + GodotEventListeners.add(ime, 'keyup', key_event_cb.bind(null, 0), false); + + ime.onblur = function () { + this.style.display = 'none'; + GodotConfig.canvas.focus(); + GodotIME.active = false; + }; + + GodotConfig.canvas.parentElement.appendChild(ime); + GodotIME.ime = ime; + }, + + clear: function () { + if (GodotIME.ime) { + GodotIME.ime.remove(); + GodotIME.ime = null; + } + }, + }, +}; +mergeInto(LibraryManager.library, GodotIME); + /* * Gamepad API helper. */ @@ -338,7 +442,7 @@ mergeInto(LibraryManager.library, GodotInputDragDrop); * Godot exposed input functions. */ const GodotInput = { - $GodotInput__deps: ['$GodotRuntime', '$GodotConfig', '$GodotEventListeners', '$GodotInputGamepads', '$GodotInputDragDrop'], + $GodotInput__deps: ['$GodotRuntime', '$GodotConfig', '$GodotEventListeners', '$GodotInputGamepads', '$GodotInputDragDrop', '$GodotIME'], $GodotInput: { getModifiers: function (evt) { return (evt.shiftKey + 0) + ((evt.altKey + 0) << 1) + ((evt.ctrlKey + 0) << 2) + ((evt.metaKey + 0) << 3); @@ -461,6 +565,35 @@ const GodotInput = { GodotEventListeners.add(GodotConfig.canvas, 'keyup', key_cb.bind(null, 0), false); }, + /* + * IME API + */ + godot_js_set_ime_active__proxy: 'sync', + godot_js_set_ime_active__sig: 'vi', + godot_js_set_ime_active: function (p_active) { + GodotIME.ime_active(p_active); + }, + + godot_js_set_ime_position__proxy: 'sync', + godot_js_set_ime_position__sig: 'vii', + godot_js_set_ime_position: function (p_x, p_y) { + GodotIME.ime_position(p_x, p_y); + }, + + godot_js_set_ime_cb__proxy: 'sync', + godot_js_set_ime_cb__sig: 'viiii', + godot_js_set_ime_cb: function (p_ime_cb, p_key_cb, code, key) { + const ime_cb = GodotRuntime.get_func(p_ime_cb); + const key_cb = GodotRuntime.get_func(p_key_cb); + GodotIME.init(ime_cb, key_cb, code, key); + }, + + godot_js_is_ime_focused__proxy: 'sync', + godot_js_is_ime_focused__sig: 'i', + godot_js_is_ime_focused: function () { + return GodotIME.active; + }, + /* * Gamepad API */