Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Text Input Method Editor #541

Merged
merged 42 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e6c3b54
Add visual feedback for IME composition in text inputs
ShawnCZek Nov 17, 2023
8369f7c
Reset the IME composition range when the input value changes.
ShawnCZek Nov 18, 2023
b9a3220
Comment unused parameters.
ShawnCZek Nov 18, 2023
16614a6
Implement the WidgetTextInput::GetLineIMEComposition() method.
ShawnCZek Nov 18, 2023
25a3c0b
Fix implicit type conversion.
ShawnCZek Nov 18, 2023
b004492
Create a sample of an advanced use of IME.
ShawnCZek Feb 13, 2024
567ad45
Implement UTF-16 to UTF-8 conversion instead of using the Windows bac…
ShawnCZek Feb 13, 2024
ad1f30f
Move ConvertCharacterOffsetToByteOffset and ConvertByteOffsetToCharac…
ShawnCZek Mar 9, 2024
30be693
Rename UpdateCompositionRange to SetCompositionRange.
ShawnCZek Mar 9, 2024
d31daf8
Move the IME implementation from a sample to the library.
ShawnCZek Mar 16, 2024
a043fb9
Use GNU Unifont for the IME sample.
ShawnCZek Mar 16, 2024
2632627
Deactivate only the same text input method context.
ShawnCZek Mar 16, 2024
92d8f70
Properly export new classes and function.
ShawnCZek Mar 16, 2024
ca458c2
Add back TextInputMethodContext::GetScreenBounds().
ShawnCZek Mar 16, 2024
9c90052
Respect the text input length restriction with IME composition.
ShawnCZek Mar 16, 2024
8d57c25
Load Win32 system fonts in IME sample
mikke89 Apr 15, 2024
d31d8bd
Fix text disappearing from textarea.
ShawnCZek Apr 24, 2024
3f801d7
Add the IME sample only if a Win32 backend is used.
ShawnCZek Apr 25, 2024
2583895
Fix a conditional for locating system font files.
ShawnCZek May 14, 2024
a5c9995
Explain the IME sample.
ShawnCZek May 15, 2024
350e3f6
Use window_handle instead of GetActiveWindow().
ShawnCZek May 15, 2024
b8bd402
Remove coupling of IME contetx via friend keyword.
ShawnCZek May 15, 2024
e49dc87
Ensure that valid pointers are accessed during the lifetime of Widget…
ShawnCZek May 15, 2024
c1219a6
Remove inconsistent IME prefix from method names.
ShawnCZek May 15, 2024
853a777
Construct IME context only when in use.
ShawnCZek May 15, 2024
6699d57
Make TextInputMethodEditor::EndComposition() an implementation detail.
ShawnCZek May 15, 2024
a269d23
Make the text input handler multipurpose.
ShawnCZek Jun 9, 2024
f549e05
Rename SetIMERange to SetCompositionRange.
ShawnCZek Jun 9, 2024
045f781
Merge branch 'master' into ime_visual_feedback
ShawnCZek Jun 9, 2024
eb955c7
Comment out unused parameter variables.
ShawnCZek Jun 9, 2024
d8094b3
Revert "Construct IME context only when in use."
ShawnCZek Jun 27, 2024
c8df9a1
Rename instances of TextInputContext to input_context.
ShawnCZek Jun 27, 2024
25588b2
Fix header files of TextInputContext and TextInputHandler.
ShawnCZek Jun 27, 2024
d944506
Document TextInputContext and TextInputHandler classes.
ShawnCZek Jun 28, 2024
9abf64c
Move the ownership of the IME editor to backends.
ShawnCZek Jun 28, 2024
869f0d0
Complete the composition when the user tries to interact with other e…
ShawnCZek Jun 28, 2024
e11e96e
Rename OnBlur/OnFocus to OnActivate/OnDeactivate.
ShawnCZek Jun 28, 2024
f3bb2bc
Rename text_input_method_context to text_input_context.
ShawnCZek Jun 28, 2024
fa4d3bc
Fix the name of the include guard for TextInputContext and TextInputH…
ShawnCZek Jun 30, 2024
3a16064
Simplify the ownership of text input contexts.
ShawnCZek Jun 30, 2024
874b3f8
Add a missing class definition.
ShawnCZek Jun 30, 2024
3d9ec36
Store handler in the text widget input context
mikke89 Jun 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions Backends/RmlUi_Platform_Win32.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,67 @@
#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.
#ifdef _MSC_VER
#pragma comment(lib, "imm32")
#endif

class TextInputMethodEditor final : public Rml::TextInputHandler {
public:
TextInputMethodEditor();

void OnFocus(Rml::SharedPtr<Rml::TextInputContext> context) override;
void OnBlur(Rml::TextInputContext* context) override;

/// Check that a composition is currently active.
/// @return True if we are composing, false otherwise.
bool IsComposing() const;

void StartComposition();
void CancelComposition();

/// Set the composition string.
/// @param[in] composition A string to be set.
void SetComposition(Rml::StringView composition);

/// End the current composition by confirming the composition string.
/// @param[in] composition A string to confirm.
void ConfirmComposition(Rml::StringView composition);

/// Set the cursor position within the composition.
/// @param[in] cursor_pos A character position of the cursor within the composition string.
/// @param[in] update Update the cursor position within active input contexts.
void SetCursorPosition(int cursor_pos, bool update);

private:
void EndComposition();
void SetCompositionString(Rml::StringView composition);

void UpdateCursorPosition();

private:
// An actively used text input method context.
Rml::WeakPtr<Rml::TextInputContext> context;

// A flag to mark a composition is currently active.
bool composing;
// Character position of the cursor in the composition string.
int cursor_pos;

// Composition range (character position) relative to the text input value.
int composition_range_start;
int composition_range_end;
};
static TextInputMethodEditor text_input_method_editor;

SystemInterface_Win32::SystemInterface_Win32()
{
LARGE_INTEGER time_ticks_per_second;
Expand All @@ -55,8 +106,12 @@ SystemInterface_Win32::SystemInterface_Win32()
cursor_cross = LoadCursor(nullptr, IDC_CROSS);
cursor_text = LoadCursor(nullptr, IDC_IBEAM);
cursor_unavailable = LoadCursor(nullptr, IDC_NO);

Rml::SetTextInputHandler(&text_input_method_editor);
}

SystemInterface_Win32::~SystemInterface_Win32() = default;

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

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
}

bool RmlWin32::WindowProcedure(Rml::Context* context, HWND window_handle, UINT message, WPARAM w_param, LPARAM l_param)
{
if (!context)
Expand Down Expand Up @@ -266,6 +345,70 @@ 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:
// Not every IME starts a composition.
if (!text_input_method_editor.IsComposing())
text_input_method_editor.StartComposition();

if (!!(l_param & GCS_CURSORPOS))
{
if (auto imm_context = ImmGetContext(window_handle))
{
// 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))
{
if (auto imm_context = ImmGetContext(window_handle))
{
std::wstring composition = IMEGetCompositionString(imm_context, true);
text_input_method_editor.ConfirmComposition(RmlWin32::ConvertToUTF8(composition));
}
}

if (!!(l_param & GCS_COMPSTR))
{
if (auto imm_context = ImmGetContext(window_handle))
{
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();
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 +653,134 @@ Rml::Input::KeyIdentifier RmlWin32::ConvertKey(int win32_key_code)

return Rml::Input::KI_UNKNOWN;
}

TextInputMethodEditor::TextInputMethodEditor() : composing(false), cursor_pos(-1), composition_range_start(0), composition_range_end(0) {}

void TextInputMethodEditor::OnFocus(Rml::SharedPtr<Rml::TextInputContext> _context)
{
context = _context;
}

void TextInputMethodEditor::OnBlur(Rml::TextInputContext* _context)
{
if (context.lock().get() == _context)
context.reset();
}

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

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

void TextInputMethodEditor::EndComposition()
{
if (Rml::SharedPtr<Rml::TextInputContext> _context = context.lock())
_context->SetCompositionRange(0, 0);

RMLUI_ASSERT(composing);
composing = false;

composition_range_start = 0;
composition_range_end = 0;
}

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

if (Rml::SharedPtr<Rml::TextInputContext> _context = context.lock())
{
// Purge the current composition string.
_context->SetText(Rml::StringView(), composition_range_start, composition_range_end);
// Move the cursor back to where the composition began.
_context->SetCursorPosition(composition_range_start);
}

EndComposition();
}

void TextInputMethodEditor::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)
if (Rml::SharedPtr<Rml::TextInputContext> _context = context.lock())
_context->SetCompositionRange(composition_range_start, composition_range_end);
}

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

SetCompositionString(composition);

if (Rml::SharedPtr<Rml::TextInputContext> _context = context.lock())
{
_context->SetCompositionRange(composition_range_start, composition_range_end);
_context->CommitComposition();
}

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

EndComposition();
}

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

cursor_pos = _cursor_pos;

if (update)
UpdateCursorPosition();
}

void TextInputMethodEditor::SetCompositionString(Rml::StringView composition)
{
if (context.expired())
return;

Rml::SharedPtr<Rml::TextInputContext> _context = context.lock();

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

_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::UpdateCursorPosition()
{
// Cursor position update happens before a composition is set; ignore this event.
if (composition_range_start == 0 && composition_range_end == 0)
return;

if (Rml::SharedPtr<Rml::TextInputContext> _context = context.lock())
{
if (cursor_pos != -1)
{
int position = composition_range_start + cursor_pos;
_context->SetCursorPosition(position);
}
else
{
// If the API reports no cursor position, select the entire composition string for a better UX.
_context->SetSelectionRange(composition_range_start, composition_range_end);
}
}
}
1 change: 1 addition & 0 deletions Backends/RmlUi_Platform_Win32.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
class SystemInterface_Win32 : public Rml::SystemInterface {
public:
SystemInterface_Win32();
~SystemInterface_Win32();

// Optionally, provide or change the window to be used for setting the mouse cursor, clipboard text and IME position.
void SetWindow(HWND window_handle);
Expand Down
9 changes: 8 additions & 1 deletion Include/RmlUi/Core/Context.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class DataModelConstructor;
class DataTypeRegister;
class ScrollController;
class RenderManager;
class TextInputHandler;
enum class EventId : uint16_t;

/**
Expand All @@ -60,7 +61,8 @@ class RMLUICORE_API Context : public ScriptInterface {
/// Constructs a new, uninitialised context. This should not be called directly, use CreateContext() instead.
/// @param[in] name The name of the context.
/// @param[in] render_manager The render manager used for this context.
Context(const String& name, RenderManager* render_manager);
/// @param[in] text_input_handler The text input handler used for this context.
Context(const String& name, RenderManager* render_manager, TextInputHandler* text_input_handler);
/// Destroys a context.
virtual ~Context();

Expand Down Expand Up @@ -250,6 +252,9 @@ class RMLUICORE_API Context : public ScriptInterface {
/// Retrieves the render manager which can be used to submit changes to the render state.
RenderManager& GetRenderManager();

/// Obtains the text input handler.
TextInputHandler* GetTextInputHandler() const;

/// Sets the instancer to use for releasing this object.
/// @param[in] instancer The context's instancer.
void SetInstancer(ContextInstancer* instancer);
Expand Down Expand Up @@ -372,6 +377,8 @@ class RMLUICORE_API Context : public ScriptInterface {

UniquePtr<DataTypeRegister> default_data_type_register;

TextInputHandler* text_input_handler;

// Time in seconds until Update and Render should be called again. This allows applications to only redraw the ui if needed.
// See RequestNextUpdate() and NextUpdateRequested() for details.
double next_update_timeout = 0;
Expand Down
4 changes: 3 additions & 1 deletion Include/RmlUi/Core/ContextInstancer.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

namespace Rml {

class TextInputHandler;
class RenderManager;
class Context;
class Event;
Expand All @@ -52,8 +53,9 @@ class RMLUICORE_API ContextInstancer : public Releasable {
/// Instances a context.
/// @param[in] name Name of this context.
/// @param[in] render_manager The render manager used for this context.
/// @param[in] text_input_handler The text input handler used for this context.
/// @return The instanced context.
virtual ContextPtr InstanceContext(const String& name, RenderManager* render_manager) = 0;
virtual ContextPtr InstanceContext(const String& name, RenderManager* render_manager, TextInputHandler* text_input_handler) = 0;

/// Releases a context previously created by this context.
/// @param[in] context The context to release.
Expand Down
Loading