Skip to content

Commit

Permalink
Add Input Method Editor (IME) support (#541)
Browse files Browse the repository at this point in the history
Adds `TextInputHandler` abstract class which users can implement to integrate IME support. Users can override the abstract methods and install it with `Rml::SetTextInputHandler` or pass it during context construction `Rml::CreateContext`.

The text input handler is notified when a text field is activated, and receives a pointer to a `TextInputContext` interface. The library implements this class for its text input elements. Through this class, the user can query the text field for information such as selection and bounding box, and apply the composition text directly directly into the text field.

IME support is currently implemented in the Win32 backend which uses the system IME in Windows.

New sample `rmlui_sample_ime` added for IME testing. Loads system fonts to support a wide selection of languages and color emojis.

---------

Co-authored-by: Michael Ragazzon <[email protected]>
  • Loading branch information
ShawnCZek and mikke89 authored Jul 2, 2024
1 parent e02ff48 commit d2ce132
Show file tree
Hide file tree
Showing 36 changed files with 1,212 additions and 64 deletions.
12 changes: 10 additions & 2 deletions Backends/RmlUi_Backend_Win32_GL2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ static void DetachFromNative(HWND window_handle, HDC device_context, HGLRC rende
struct BackendData {
SystemInterface_Win32 system_interface;
RenderInterface_GL2 render_interface;
TextInputMethodEditor_Win32 text_input_method_editor;

HINSTANCE instance_handle = nullptr;
std::wstring instance_name;
Expand Down Expand Up @@ -158,13 +159,20 @@ bool Backend::Initialize(const char* window_name, int width, int height, bool al
::SetForegroundWindow(window_handle);
::SetFocus(window_handle);

// Provide a backend-specific text input handler to manage the IME.
Rml::SetTextInputHandler(&data->text_input_method_editor);

return true;
}

void Backend::Shutdown()
{
RMLUI_ASSERT(data);

// As we forcefully override the global text input handler, we must reset it before the data is destroyed to avoid any potential use-after-free.
if (Rml::GetTextInputHandler() == &data->text_input_method_editor)
Rml::SetTextInputHandler(nullptr);

DetachFromNative(data->window_handle, data->device_context, data->render_context);

::DestroyWindow(data->window_handle);
Expand Down Expand Up @@ -307,7 +315,7 @@ static LRESULT CALLBACK WindowProcedureHandler(HWND window_handle, UINT message,
if (key_down_callback && !key_down_callback(context, rml_key, rml_modifier, native_dp_ratio, true))
return 0;
// Otherwise, hand the event over to the context by calling the input handler as normal.
if (!RmlWin32::WindowProcedure(context, window_handle, message, w_param, l_param))
if (!RmlWin32::WindowProcedure(context, data->text_input_method_editor, window_handle, message, w_param, l_param))
return 0;
// The key was not consumed by the context either, try keyboard shortcuts of lower priority.
if (key_down_callback && !key_down_callback(context, rml_key, rml_modifier, native_dp_ratio, false))
Expand All @@ -318,7 +326,7 @@ static LRESULT CALLBACK WindowProcedureHandler(HWND window_handle, UINT message,
default:
{
// Submit it to the platform handler for default input handling.
if (!RmlWin32::WindowProcedure(data->context, window_handle, message, w_param, l_param))
if (!RmlWin32::WindowProcedure(data->context, data->text_input_method_editor, window_handle, message, w_param, l_param))
return 0;
}
break;
Expand Down
12 changes: 10 additions & 2 deletions Backends/RmlUi_Backend_Win32_VK.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ static bool CreateVulkanSurface(VkInstance instance, VkSurfaceKHR* out_surface);
struct BackendData {
SystemInterface_Win32 system_interface;
RenderInterface_VK render_interface;
TextInputMethodEditor_Win32 text_input_method_editor;

HINSTANCE instance_handle = nullptr;
std::wstring instance_name;
Expand Down Expand Up @@ -160,13 +161,20 @@ bool Backend::Initialize(const char* window_name, int width, int height, bool al
::SetForegroundWindow(window_handle);
::SetFocus(window_handle);

// Provide a backend-specific text input handler to manage the IME.
Rml::SetTextInputHandler(&data->text_input_method_editor);

return true;
}

void Backend::Shutdown()
{
RMLUI_ASSERT(data);

// As we forcefully override the global text input handler, we must reset it before the data is destroyed to avoid any potential use-after-free.
if (Rml::GetTextInputHandler() == &data->text_input_method_editor)
Rml::SetTextInputHandler(nullptr);

data->render_interface.Shutdown();

::DestroyWindow(data->window_handle);
Expand Down Expand Up @@ -315,7 +323,7 @@ static LRESULT CALLBACK WindowProcedureHandler(HWND window_handle, UINT message,
if (key_down_callback && !key_down_callback(context, rml_key, rml_modifier, native_dp_ratio, true))
return 0;
// Otherwise, hand the event over to the context by calling the input handler as normal.
if (!RmlWin32::WindowProcedure(context, window_handle, message, w_param, l_param))
if (!RmlWin32::WindowProcedure(context, data->text_input_method_editor, window_handle, message, w_param, l_param))
return 0;
// The key was not consumed by the context either, try keyboard shortcuts of lower priority.
if (key_down_callback && !key_down_callback(context, rml_key, rml_modifier, native_dp_ratio, false))
Expand All @@ -326,7 +334,7 @@ static LRESULT CALLBACK WindowProcedureHandler(HWND window_handle, UINT message,
default:
{
// Submit it to the platform handler for default input handling.
if (!RmlWin32::WindowProcedure(data->context, window_handle, message, w_param, l_param))
if (!RmlWin32::WindowProcedure(data->context, data->text_input_method_editor, window_handle, message, w_param, l_param))
return 0;
}
break;
Expand Down
241 changes: 240 additions & 1 deletion Backends/RmlUi_Platform_Win32.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
#include "RmlUi_Platform_Win32.h"
#include "RmlUi_Include_Windows.h"
#include <RmlUi/Core/Context.h>
#include <RmlUi/Core/Core.h>
#include <RmlUi/Core/Input.h>
#include <RmlUi/Core/StringUtilities.h>
#include <RmlUi/Core/SystemInterface.h>
#include <RmlUi/Core/TextInputContext.h>
#include <RmlUi/Core/TextInputHandler.h>
#include <string.h>

// Used to interact with the input method editor (IME). Users of MinGW should manually link to this.
Expand All @@ -57,6 +60,8 @@ SystemInterface_Win32::SystemInterface_Win32()
cursor_unavailable = LoadCursor(nullptr, IDC_NO);
}

SystemInterface_Win32::~SystemInterface_Win32() = default;

void SystemInterface_Win32::SetWindow(HWND in_window_handle)
{
window_handle = in_window_handle;
Expand Down Expand Up @@ -185,13 +190,53 @@ std::wstring RmlWin32::ConvertToUTF16(const Rml::String& str)
return wstr;
}

bool RmlWin32::WindowProcedure(Rml::Context* context, HWND window_handle, UINT message, WPARAM w_param, LPARAM l_param)
static int IMEGetCursorPosition(HIMC context)
{
return ImmGetCompositionString(context, GCS_CURSORPOS, nullptr, 0);
}

static std::wstring IMEGetCompositionString(HIMC context, bool finalize)
{
DWORD type = finalize ? GCS_RESULTSTR : GCS_COMPSTR;
int len_bytes = ImmGetCompositionString(context, type, nullptr, 0);

if (len_bytes <= 0)
return {};

int len_chars = len_bytes / sizeof(TCHAR);
Rml::UniquePtr<TCHAR[]> buffer(new TCHAR[len_chars + 1]);
ImmGetCompositionString(context, type, buffer.get(), len_bytes);

#ifdef UNICODE
return std::wstring(buffer.get(), len_chars);
#else
return RmlWin32::ConvertToUTF16(Rml::String(buffer.get(), len_chars));
#endif
}

static void IMECompleteComposition(HWND window_handle)
{
if (HIMC context = ImmGetContext(window_handle))
{
ImmNotifyIME(context, NI_COMPOSITIONSTR, CPS_COMPLETE, NULL);
ImmReleaseContext(window_handle, context);
}
}

bool RmlWin32::WindowProcedure(Rml::Context* context, TextInputMethodEditor_Win32& text_input_method_editor, HWND window_handle, UINT message,
WPARAM w_param, LPARAM l_param)
{
if (!context)
return true;

static bool tracking_mouse_leave = false;

// If the user tries to interact with the window by using the mouse in any way, end the
// composition by committing the current string. This behavior is identical to other
// browsers and is expected, yet, Windows does not send any IME messages in such a case.
if (text_input_method_editor.IsComposing() && message >= WM_LBUTTONDOWN && message <= WM_MBUTTONDBLCLK)
IMECompleteComposition(window_handle);

bool result = true;

switch (message)
Expand Down Expand Up @@ -266,6 +311,67 @@ bool RmlWin32::WindowProcedure(Rml::Context* context, HWND window_handle, UINT m
}
}
break;
case WM_IME_STARTCOMPOSITION:
text_input_method_editor.StartComposition();
// Prevent the native composition window from appearing by capturing the message.
result = false;
break;
case WM_IME_ENDCOMPOSITION:
if (text_input_method_editor.IsComposing())
text_input_method_editor.ConfirmComposition(Rml::StringView());
break;
case WM_IME_COMPOSITION:
{
HIMC imm_context = ImmGetContext(window_handle);

// Not every IME starts a composition.
if (!text_input_method_editor.IsComposing())
text_input_method_editor.StartComposition();

if (!!(l_param & GCS_CURSORPOS))
{
// The cursor position is the wchar_t offset in the composition string. Because we
// work with UTF-8 and not UTF-16, we will have to convert the character offset.
int cursor_pos = IMEGetCursorPosition(imm_context);

std::wstring composition = IMEGetCompositionString(imm_context, false);
Rml::String converted = RmlWin32::ConvertToUTF8(composition.substr(0, cursor_pos));
cursor_pos = (int)Rml::StringUtilities::LengthUTF8(converted);

text_input_method_editor.SetCursorPosition(cursor_pos, true);
}

if (!!(l_param & CS_NOMOVECARET))
{
// Suppress the cursor position update. CS_NOMOVECARET is always a part of a more
// complex message which means that the cursor is updated from a different event.
text_input_method_editor.SetCursorPosition(-1, false);
}

if (!!(l_param & GCS_RESULTSTR))
{
std::wstring composition = IMEGetCompositionString(imm_context, true);
text_input_method_editor.ConfirmComposition(RmlWin32::ConvertToUTF8(composition));
}

if (!!(l_param & GCS_COMPSTR))
{
std::wstring composition = IMEGetCompositionString(imm_context, false);
text_input_method_editor.SetComposition(RmlWin32::ConvertToUTF8(composition));
}

// The composition has been canceled.
if (!l_param)
text_input_method_editor.CancelComposition();

ImmReleaseContext(window_handle, imm_context);
}
break;
case WM_IME_CHAR:
case WM_IME_REQUEST:
// Ignore WM_IME_CHAR and WM_IME_REQUEST to block the system from appending the composition string.
result = false;
break;
default: break;
}

Expand Down Expand Up @@ -510,3 +616,136 @@ Rml::Input::KeyIdentifier RmlWin32::ConvertKey(int win32_key_code)

return Rml::Input::KI_UNKNOWN;
}

TextInputMethodEditor_Win32::TextInputMethodEditor_Win32() :
input_context(nullptr), composing(false), cursor_pos(-1), composition_range_start(0), composition_range_end(0)
{}

void TextInputMethodEditor_Win32::OnActivate(Rml::TextInputContext* _input_context)
{
input_context = _input_context;
}

void TextInputMethodEditor_Win32::OnDeactivate(Rml::TextInputContext* _input_context)
{
if (input_context == _input_context)
input_context = nullptr;
}

void TextInputMethodEditor_Win32::OnDestroy(Rml::TextInputContext* _input_context)
{
if (input_context == _input_context)
input_context = nullptr;
}

bool TextInputMethodEditor_Win32::IsComposing() const
{
return composing;
}

void TextInputMethodEditor_Win32::StartComposition()
{
RMLUI_ASSERT(!composing);
composing = true;
}

void TextInputMethodEditor_Win32::EndComposition()
{
if (input_context != nullptr)
input_context->SetCompositionRange(0, 0);

RMLUI_ASSERT(composing);
composing = false;

composition_range_start = 0;
composition_range_end = 0;
}

void TextInputMethodEditor_Win32::CancelComposition()
{
RMLUI_ASSERT(IsComposing());

if (input_context != nullptr)
{
// Purge the current composition string.
input_context->SetText(Rml::StringView(), composition_range_start, composition_range_end);
// Move the cursor back to where the composition began.
input_context->SetCursorPosition(composition_range_start);
}

EndComposition();
}

void TextInputMethodEditor_Win32::SetComposition(Rml::StringView composition)
{
RMLUI_ASSERT(IsComposing());

SetCompositionString(composition);
UpdateCursorPosition();

// Update the composition range only if the cursor can be moved around. Editors working with a single
// character (e.g., Hangul IME) should have no visual feedback; they use a selection range instead.
if (cursor_pos != -1 && input_context != nullptr)
input_context->SetCompositionRange(composition_range_start, composition_range_end);
}

void TextInputMethodEditor_Win32::ConfirmComposition(Rml::StringView composition)
{
RMLUI_ASSERT(IsComposing());

SetCompositionString(composition);

if (input_context != nullptr)
{
input_context->SetCompositionRange(composition_range_start, composition_range_end);
input_context->CommitComposition();
}

// Move the cursor to the end of the string.
SetCursorPosition(composition_range_end - composition_range_start, true);

EndComposition();
}

void TextInputMethodEditor_Win32::SetCursorPosition(int _cursor_pos, bool update)
{
RMLUI_ASSERT(IsComposing());

cursor_pos = _cursor_pos;

if (update)
UpdateCursorPosition();
}

void TextInputMethodEditor_Win32::SetCompositionString(Rml::StringView composition)
{
if (input_context == nullptr)
return;

// Retrieve the composition range if it is missing.
if (composition_range_start == 0 && composition_range_end == 0)
input_context->GetSelectionRange(composition_range_start, composition_range_end);

input_context->SetText(composition, composition_range_start, composition_range_end);

size_t length = Rml::StringUtilities::LengthUTF8(composition);
composition_range_end = composition_range_start + (int)length;
}

void TextInputMethodEditor_Win32::UpdateCursorPosition()
{
// Cursor position update happens before a composition is set; ignore this event.
if (input_context == nullptr || (composition_range_start == 0 && composition_range_end == 0))
return;

if (cursor_pos != -1)
{
int position = composition_range_start + cursor_pos;
input_context->SetCursorPosition(position);
}
else
{
// If the API reports no cursor position, select the entire composition string for a better UX.
input_context->SetSelectionRange(composition_range_start, composition_range_end);
}
}
Loading

0 comments on commit d2ce132

Please sign in to comment.