From 941ffe09b820af4375bf88998a85f9c29c7a40d8 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 | 112 +++++++++++++++++++- platform/web/display_server_web.h | 15 +++ platform/web/godot_js.h | 4 + platform/web/js/libs/library_godot_input.js | 104 +++++++++++++++++- 4 files changed, 231 insertions(+), 4 deletions(-) diff --git a/platform/web/display_server_web.cpp b/platform/web/display_server_web.cpp index 022e044185c7..eb679444fc67 100644 --- a/platform/web/display_server_web.cpp +++ b/platform/web/display_server_web.cpp @@ -193,7 +193,15 @@ void DisplayServerWeb::_key_callback(const String &p_key_event_code, const Strin ev->set_pressed(p_pressed); dom2godot_mod(ev, p_modifiers, fix_keycode(c, keycode)); - Input::get_singleton()->parse_input_event(ev); + DisplayServerWeb *ds = get_singleton(); + if (ds->ime_active) { + 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++] = ev; + } else { + Input::get_singleton()->parse_input_event(ev); + } // Make sure to flush all events so we can call restricted APIs inside the event. Input::get_singleton()->flush_buffered_events(); @@ -726,7 +734,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 +817,98 @@ 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 keydown event from query. + if (ds->key_event_buffer[i]->is_pressed()) { + 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_active = true; + } break; + case 1: { + // IME update. + if (ds->ime_active) { + 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_active = 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++) { + Ref ev; + ev.instantiate(); + ev->set_echo(false); + ev->set_keycode(Key::NONE); + ev->set_physical_keycode(Key::NONE); + ev->set_key_label(Key::NONE); + ev->set_unicode(text[i]); + ev->set_pressed(true); + + 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++] = ev; + } + } + } 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::DisplayServerWeb::ime_get_selection() const { + return ime_selection; +} + +String DisplayServerWeb::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(); @@ -1003,6 +1103,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); // JS Display interface (js/libs/library_godot_display.js) godot_js_display_fullscreen_cb(&DisplayServerWeb::fullscreen_change_callback); @@ -1030,7 +1131,7 @@ bool DisplayServerWeb::has_feature(Feature p_feature) const { switch (p_feature) { //case FEATURE_GLOBAL_MENU: //case FEATURE_HIDPI: - //case FEATURE_IME: + case FEATURE_IME: case FEATURE_ICON: case FEATURE_CLIPBOARD: case FEATURE_CURSOR_SHAPE: @@ -1263,6 +1364,11 @@ 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 Ref &ke = key_event_buffer[i]; + Input::get_singleton()->parse_input_event(ke); + } + key_event_pos = 0; } } diff --git a/platform/web/display_server_web.h b/platform/web/display_server_web.h index 51c6ab3c0acf..2a88a916bb24 100644 --- a/platform/web/display_server_web.h +++ b/platform/web/display_server_web.h @@ -82,6 +82,12 @@ class DisplayServerWeb : public DisplayServer { uint64_t last_click_ms = 0; MouseButton last_click_button_index = MouseButton::NONE; + bool ime_active = false; + String ime_text; + Vector2i ime_selection; + Vector> key_event_buffer; + int key_event_pos = 0; + bool swap_cancel_ok = false; bool tts = false; @@ -108,6 +114,8 @@ class DisplayServerWeb : public DisplayServer { 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 +170,13 @@ class DisplayServerWeb : public DisplayServer { 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 031e67e48613..2cffd82dc87d 100644 --- a/platform/web/godot_js.h +++ b/platform/web/godot_js.h @@ -64,6 +64,10 @@ 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)); + // 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 eaff40f89c25..8746090bb194 100644 --- a/platform/web/js/libs/library_godot_input.js +++ b/platform/web/js/libs/library_godot_input.js @@ -28,6 +28,86 @@ /* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /**************************************************************************/ +/* + * IME API helper. + */ + +const GodotIME = { + $GodotIME__deps: ['$GodotRuntime', '$GodotEventListeners'], + $GodotIME: { + ime: null, + + ime_active: function (active) { + function focus_timer() { + 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(); + } + } + }, + + ime_position: function (x, y) { + if (GodotIME.ime) { + GodotIME.ime.style.left = `${x}px`; + GodotIME.ime.style.top = `${y}px`; + } + }, + + init: function (callback) { + function ime_event_cb(event) { + if (GodotIME.ime) { + if (event.type === 'compositionstart') { + callback(0, null); + GodotIME.ime.innerHTML = ''; + } else if (event.type === 'compositionupdate') { + const ptr = GodotRuntime.allocString(event.data); + callback(1, ptr); + GodotRuntime.free(ptr); + } else if (event.type === 'compositionend') { + const ptr = GodotRuntime.allocString(event.data); + callback(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 = 'absolute'; + ime.style.left = '0px'; + ime.style.top = '0px'; + ime.style.width = '2px'; + ime.style.height = '2px'; + ime.style.display = 'none'; + ime.id = 'ime'; + 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); + + ime.onblur = function () { + this.style.display = 'none'; + GodotConfig.canvas.focus(); + }; + + GodotConfig.canvas.appendChild(ime); + GodotIME.ime = ime; + }, + }, +}; +mergeInto(LibraryManager.library, GodotIME); + /* * Gamepad API helper. */ @@ -338,7 +418,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 +541,28 @@ 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: 'vi', + godot_js_set_ime_cb: function (p_cb) { + const ime_cb = GodotRuntime.get_func(p_cb); + GodotIME.init(ime_cb); + }, + /* * Gamepad API */