diff --git a/ClientGUI/ClientGUI.csproj b/ClientGUI/ClientGUI.csproj
index 704269c1f..5826752cc 100644
--- a/ClientGUI/ClientGUI.csproj
+++ b/ClientGUI/ClientGUI.csproj
@@ -5,4 +5,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ClientGUI/IME/DummyIMEHandler.cs b/ClientGUI/IME/DummyIMEHandler.cs
new file mode 100644
index 000000000..33b812a15
--- /dev/null
+++ b/ClientGUI/IME/DummyIMEHandler.cs
@@ -0,0 +1,16 @@
+#nullable enable
+using Microsoft.Xna.Framework;
+
+namespace ClientGUI.IME
+{
+ internal class DummyIMEHandler : IMEHandler
+ {
+ public DummyIMEHandler() { }
+
+ public override bool TextCompositionEnabled { get => false; protected set { } }
+
+ public override void SetTextInputRectangle(Rectangle rectangle) { }
+ public override void StartTextComposition() { }
+ public override void StopTextComposition() { }
+ }
+}
diff --git a/ClientGUI/IME/IMEHandler.cs b/ClientGUI/IME/IMEHandler.cs
new file mode 100644
index 000000000..5fe846ece
--- /dev/null
+++ b/ClientGUI/IME/IMEHandler.cs
@@ -0,0 +1,226 @@
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+
+using Microsoft.Xna.Framework;
+
+using Rampastring.XNAUI;
+using Rampastring.XNAUI.Input;
+using Rampastring.XNAUI.XNAControls;
+
+namespace ClientGUI.IME;
+public abstract class IMEHandler : IIMEHandler
+{
+ bool IIMEHandler.TextCompositionEnabled => TextCompositionEnabled;
+ public abstract bool TextCompositionEnabled { get; protected set; }
+
+ private XNATextBox? _IMEFocus = null;
+ public XNATextBox? IMEFocus
+ {
+ get => _IMEFocus;
+ protected set
+ {
+ _IMEFocus = value;
+ Debug.Assert(!_IMEFocus?.IMEDisabled ?? true, "IME focus should not be assigned from a textbox with IME disabled");
+ }
+ }
+
+ private string _composition = string.Empty;
+
+ public string Composition
+ {
+ get => _composition;
+ protected set
+ {
+ string old = _composition;
+ _composition = value;
+ OnCompositionChanged(old, value);
+ }
+ }
+
+ public bool CompositionEmpty => string.IsNullOrEmpty(_composition);
+
+ protected bool IMEEventReceived = false;
+ protected bool LastActionIMEChatInput = true;
+
+ private void OnCompositionChanged(string oldValue, string newValue)
+ {
+ //Debug.WriteLine($"IME: OnCompositionChanged: {newValue.Length - oldValue.Length}");
+
+ IMEEventReceived = true;
+ // It seems that OnIMETextInput() is always triggered after OnCompositionChanged(). We expect such a behavior.
+ LastActionIMEChatInput = false;
+ }
+
+ protected ConcurrentDictionary?> TextBoxHandleChatInputCallbacks = [];
+
+ public virtual int CompositionCursorPosition { get; set; }
+
+ public static IMEHandler Create(Game game)
+ {
+#if DX
+ return new WinFormsIMEHandler(game);
+#elif XNA
+ // Warning: Think carefully before enabling WinFormsIMEHandler for XNA builds!
+ // It *might* occasionally crash due to an unknown stack overflow issue.
+ // This *might* be caused by both ImeSharp and XNAUI hooking into WndProc.
+ // ImeSharp: https://github.com/ryancheung/ImeSharp/blob/dc2243beff9ef48eb37e398c506c905c965f8e68/ImeSharp/InputMethod.cs#L170
+ // XNAUI: https://github.com/Rampastring/Rampastring.XNAUI/blob/9a7d5bb3e47ea50286ee05073d0a6723bc6d764d/Input/KeyboardEventInput.cs#L79
+ //
+ // That said, you can try returning a WinFormsIMEHandler and test if it is stable enough now. Who knows?
+ return new DummyIMEHandler();
+#elif GL
+ return new SdlIMEHandler(game);
+#else
+#error Unknown variant
+#endif
+ }
+
+ public abstract void SetTextInputRectangle(Rectangle rectangle);
+
+ public abstract void StartTextComposition();
+
+ public abstract void StopTextComposition();
+
+ protected virtual void OnIMETextInput(char character)
+ {
+ //Debug.WriteLine($"IME: OnIMETextInput: {character} {(short)character}; IMEFocus is null? {IMEFocus == null}");
+
+ IMEEventReceived = true;
+ LastActionIMEChatInput = true;
+
+ if (IMEFocus != null)
+ {
+ TextBoxHandleChatInputCallbacks.TryGetValue(IMEFocus, out var handleChatInput);
+ handleChatInput?.Invoke(character);
+ }
+ }
+
+ public void SetIMETextInputRectangle(WindowManager manager)
+ {
+ // When the client window resizes, we should call SetIMETextInputRectangle()
+ if (manager.SelectedControl is XNATextBox textBox)
+ SetIMETextInputRectangle(textBox);
+ }
+
+ private void SetIMETextInputRectangle(XNATextBox sender)
+ {
+ WindowManager windowManager = sender.WindowManager;
+
+ Rectangle textBoxRect = sender.RenderRectangle();
+ double scaleRatio = windowManager.ScaleRatio;
+
+ Rectangle rect = new()
+ {
+ X = (int)(textBoxRect.X * scaleRatio + windowManager.SceneXPosition),
+ Y = (int)(textBoxRect.Y * scaleRatio + windowManager.SceneYPosition),
+ Width = (int)(textBoxRect.Width * scaleRatio),
+ Height = (int)(textBoxRect.Height * scaleRatio)
+ };
+
+ // The following code returns a more accurate location based on the current InputPosition.
+ // However, as SetIMETextInputRectangle() does not automatically update with changes in InputPosition
+ // (e.g., due to scrolling or mouse clicks altering the textbox's input position without shifting focus),
+ // accuracy becomes inconsistent. Sometimes it's precise, other times it's off,
+ // which is arguably worse than a consistent but manageable inaccuracy.
+ // This inconsistency could lead to a confusing user experience,
+ // as the input rectangle's position may not reliably reflect the current input position.
+ // Therefore, unless whenever InputPosition is changed, SetIMETextInputRectangle() is raised
+ // -- which requires more time to investigate and test, it's commented out for now.
+ //var vec = Renderer.GetTextDimensions(
+ // sender.Text.Substring(sender.TextStartPosition, sender.InputPosition),
+ // sender.FontIndex);
+ //rect.X += (int)(vec.X * scaleRatio);
+
+ SetTextInputRectangle(rect);
+ }
+
+ void IIMEHandler.OnSelectedChanged(XNATextBox sender)
+ {
+ if (sender.WindowManager.SelectedControl == sender)
+ {
+ StopTextComposition();
+
+ if (!sender.IMEDisabled && sender.Enabled && sender.Visible)
+ {
+ IMEFocus = sender;
+
+ // Update the location of IME based on the textbox
+ SetIMETextInputRectangle(sender);
+
+ StartTextComposition();
+ }
+ else
+ {
+ IMEFocus = null;
+ }
+ }
+ else if (sender.WindowManager.SelectedControl is not XNATextBox)
+ {
+ // Disable IME since the current selected control is not XNATextBox
+ IMEFocus = null;
+ StopTextComposition();
+ }
+
+ // Note: if sender.WindowManager.SelectedControl != sender and is XNATextBox,
+ // another OnSelectedChanged() will be triggered,
+ // so we do not need to handle this case
+ }
+
+ void IIMEHandler.RegisterXNATextBox(XNATextBox sender, Action? handleCharInput)
+ => TextBoxHandleChatInputCallbacks[sender] = handleCharInput;
+
+ void IIMEHandler.KillXNATextBox(XNATextBox sender)
+ => TextBoxHandleChatInputCallbacks.TryRemove(sender, out _);
+
+ bool IIMEHandler.HandleScrollLeftKey(XNATextBox sender)
+ => !CompositionEmpty;
+
+ bool IIMEHandler.HandleScrollRightKey(XNATextBox sender)
+ => !CompositionEmpty;
+
+ bool IIMEHandler.HandleBackspaceKey(XNATextBox sender)
+ {
+ bool handled = !LastActionIMEChatInput;
+ LastActionIMEChatInput = true;
+ //Debug.WriteLine($"IME: HandleBackspaceKey: handled: {handled}");
+ return handled;
+ }
+
+ bool IIMEHandler.HandleDeleteKey(XNATextBox sender)
+ {
+ bool handled = !LastActionIMEChatInput;
+ LastActionIMEChatInput = true;
+ //Debug.WriteLine($"IME: HandleDeleteKey: handled: {handled}");
+ return handled;
+ }
+
+ bool IIMEHandler.GetDrawCompositionText(XNATextBox sender, out string composition, out int compositionCursorPosition)
+ {
+ if (IMEFocus != sender || CompositionEmpty)
+ {
+ composition = string.Empty;
+ compositionCursorPosition = 0;
+ return false;
+ }
+
+ composition = Composition;
+ compositionCursorPosition = CompositionCursorPosition;
+ return true;
+ }
+
+ bool IIMEHandler.HandleCharInput(XNATextBox sender, char input)
+ => TextCompositionEnabled;
+
+ bool IIMEHandler.HandleEnterKey(XNATextBox sender)
+ => false;
+
+ bool IIMEHandler.HandleEscapeKey(XNATextBox sender)
+ {
+ //Debug.WriteLine($"IME: HandleEscapeKey: handled: {IMEEventReceived}");
+ return IMEEventReceived;
+ }
+
+ void IIMEHandler.OnTextChanged(XNATextBox sender) { }
+}
diff --git a/ClientGUI/IME/SdlIMEHandler.cs b/ClientGUI/IME/SdlIMEHandler.cs
new file mode 100644
index 000000000..4a6a019fa
--- /dev/null
+++ b/ClientGUI/IME/SdlIMEHandler.cs
@@ -0,0 +1,17 @@
+#nullable enable
+using Microsoft.Xna.Framework;
+
+namespace ClientGUI.IME;
+
+///
+/// Integrate IME to DesktopGL(SDL2) platform.
+///
+///
+/// Note: We were unable to provide reliable input method support for
+/// SDL2 due to the lack of a way to be able to stabilize hooks for
+/// the SDL2 main loop.
+/// Perhaps this requires some changes in Monogame.
+///
+internal sealed class SdlIMEHandler(Game game) : DummyIMEHandler
+{
+}
\ No newline at end of file
diff --git a/ClientGUI/IME/WinFormsIMEHandler.cs b/ClientGUI/IME/WinFormsIMEHandler.cs
new file mode 100644
index 000000000..0ad98490e
--- /dev/null
+++ b/ClientGUI/IME/WinFormsIMEHandler.cs
@@ -0,0 +1,56 @@
+#nullable enable
+using System;
+
+using ImeSharp;
+
+using Microsoft.Xna.Framework;
+
+using Rampastring.Tools;
+
+namespace ClientGUI.IME;
+
+///
+/// Integrate IME to XNA framework.
+///
+internal class WinFormsIMEHandler : IMEHandler
+{
+ public override bool TextCompositionEnabled
+ {
+ get => InputMethod.Enabled;
+ protected set
+ {
+ if (value != InputMethod.Enabled)
+ InputMethod.Enabled = value;
+ }
+ }
+
+ public WinFormsIMEHandler(Game game)
+ {
+ Logger.Log($"Initialize WinFormsIMEHandler.");
+ if (game?.Window?.Handle == null)
+ throw new Exception("The handle of game window should not be null");
+
+ InputMethod.Initialize(game.Window.Handle);
+ InputMethod.TextInputCallback = OnIMETextInput;
+ InputMethod.TextCompositionCallback = (compositionText, cursorPosition) =>
+ {
+ Composition = compositionText.ToString();
+ CompositionCursorPosition = cursorPosition;
+ };
+ }
+
+ public override void StartTextComposition()
+ {
+ //Debug.WriteLine("IME: StartTextComposition");
+ TextCompositionEnabled = true;
+ }
+
+ public override void StopTextComposition()
+ {
+ //Debug.WriteLine("IME: StopTextComposition");
+ TextCompositionEnabled = false;
+ }
+
+ public override void SetTextInputRectangle(Rectangle rect)
+ => InputMethod.SetTextInputRect(rect.X, rect.Y, rect.Width, rect.Height);
+}
diff --git a/DXMainClient/DXGUI/GameClass.cs b/DXMainClient/DXGUI/GameClass.cs
index 25df2e507..6b12ff85d 100644
--- a/DXMainClient/DXGUI/GameClass.cs
+++ b/DXMainClient/DXGUI/GameClass.cs
@@ -1,6 +1,7 @@
-using ClientCore;
+using ClientCore;
using ClientCore.CnCNet5;
using ClientGUI;
+using ClientGUI.IME;
using DTAClient.Domain;
using DTAClient.DXGUI.Generic;
using ClientCore.Extensions;
@@ -10,7 +11,8 @@
using Rampastring.Tools;
using Rampastring.XNAUI;
using System;
-using ClientGUI;
+using System.Diagnostics;
+using System.IO;
using DTAClient.Domain.Multiplayer;
using DTAClient.Domain.Multiplayer.CnCNet;
using DTAClient.DXGUI.Multiplayer;
@@ -23,13 +25,8 @@
using Microsoft.Extensions.Hosting;
using Rampastring.XNAUI.XNAControls;
using MainMenu = DTAClient.DXGUI.Generic.MainMenu;
-#if DX || (GL && WINFORMS)
-using System.Diagnostics;
-using System.IO;
-#endif
#if WINFORMS
using System.Windows.Forms;
-using System.IO;
#endif
namespace DTAClient.DXGUI
@@ -144,8 +141,10 @@ protected override void Initialize()
#endif
InitializeUISettings();
- WindowManager wm = new WindowManager(this, graphics);
+ WindowManager wm = new(this, graphics);
wm.Initialize(content, ProgramConstants.GetBaseResourcePath());
+ IMEHandler imeHandler = IMEHandler.Create(this);
+ wm.IMEHandler = imeHandler;
wm.ControlINIAttributeParsers.Add(new TranslationINIParser());
@@ -192,6 +191,11 @@ protected override void Initialize()
// SetGraphicsMode(wm, currentWindowSize.Width, currentWindowSize.Height, centerOnScreen: false);
// }
//};
+
+ wm.WindowSizeChangedByUser += (sender, e) =>
+ {
+ imeHandler.SetIMETextInputRectangle(wm);
+ };
}
#endif
diff --git a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLoginWindow.cs b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLoginWindow.cs
index b34486995..ba1b02e84 100644
--- a/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLoginWindow.cs
+++ b/DXMainClient/DXGUI/Multiplayer/CnCNet/CnCNetLoginWindow.cs
@@ -49,6 +49,7 @@ public override void Initialize()
tbPlayerName.Name = "tbPlayerName";
tbPlayerName.ClientRectangle = new Rectangle(Width - 132, 50, 120, 19);
tbPlayerName.MaximumTextLength = ClientConfiguration.Instance.MaxNameLength;
+ tbPlayerName.IMEDisabled = true;
string defgame = ClientConfiguration.Instance.LocalGame;
lblPlayerName = new XNALabel(WindowManager);
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 65a8c052b..1914bd9c4 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,7 +1,7 @@
true
- 2.3.22
+ 2.5.1
8.0.0
@@ -9,6 +9,7 @@
+
@@ -51,7 +52,6 @@
-