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 @@ -