Skip to content

Commit 39c8b22

Browse files
Add IME Support
Co-authored-by: SadPencil <[email protected]>
1 parent 21958a2 commit 39c8b22

File tree

4 files changed

+273
-1
lines changed

4 files changed

+273
-1
lines changed

src/TSMapEditor/Rendering/GameClass.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using TSMapEditor.Misc;
1717
using TSMapEditor.Settings;
1818
using TSMapEditor.UI;
19+
using TSMapEditor.UI.IME;
1920

2021
#if !DEBUG
2122
using System.Windows.Forms;
@@ -194,6 +195,9 @@ protected override void Initialize()
194195
PanelBorderColor = new Color(196, 196, 196)
195196
};
196197

198+
IMEHandler imeHandler = IMEHandler.Create(this);
199+
windowManager.IMEHandler = imeHandler;
200+
197201
InitMainMenu();
198202
}
199203

src/TSMapEditor/TSMapEditor.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@
486486
</None>
487487
</ItemGroup>
488488
<ItemGroup>
489+
<PackageReference Include="ImeSharp" Version="1.4.1" />
489490
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
490491
<PackageReference Include="MonoGame.Framework.WindowsDX" Version="3.8.3" />
491492
<PackageReference Include="Rampastring.Tools" Version="2.0.7" />
@@ -536,4 +537,4 @@
536537
<Message Text="Restoring dotnet tools" Importance="High" />
537538
<Exec Command="dotnet tool restore" />
538539
</Target>
539-
</Project>
540+
</Project>
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#nullable enable
2+
using System;
3+
using System.Collections.Concurrent;
4+
using System.Diagnostics;
5+
6+
using Microsoft.Xna.Framework;
7+
8+
using Rampastring.XNAUI;
9+
using Rampastring.XNAUI.Input;
10+
using Rampastring.XNAUI.XNAControls;
11+
12+
namespace TSMapEditor.UI.IME;
13+
public abstract class IMEHandler : IIMEHandler
14+
{
15+
bool IIMEHandler.TextCompositionEnabled => TextCompositionEnabled;
16+
public abstract bool TextCompositionEnabled { get; protected set; }
17+
18+
private XNATextBox? _IMEFocus = null;
19+
public XNATextBox? IMEFocus
20+
{
21+
get => _IMEFocus;
22+
protected set
23+
{
24+
_IMEFocus = value;
25+
Debug.Assert(!_IMEFocus?.IMEDisabled ?? true, "IME focus should not be assigned from a textbox with IME disabled");
26+
}
27+
}
28+
29+
private string _composition = string.Empty;
30+
31+
public string Composition
32+
{
33+
get => _composition;
34+
protected set
35+
{
36+
string old = _composition;
37+
_composition = value;
38+
OnCompositionChanged(old, value);
39+
}
40+
}
41+
42+
public bool CompositionEmpty => string.IsNullOrEmpty(_composition);
43+
44+
protected bool IMEEventReceived = false;
45+
protected bool LastActionIMEChatInput = true;
46+
47+
private void OnCompositionChanged(string oldValue, string newValue)
48+
{
49+
//Debug.WriteLine($"IME: OnCompositionChanged: {newValue.Length - oldValue.Length}");
50+
51+
IMEEventReceived = true;
52+
// It seems that OnIMETextInput() is always triggered after OnCompositionChanged(). We expect such a behavior.
53+
LastActionIMEChatInput = false;
54+
}
55+
56+
protected ConcurrentDictionary<XNATextBox, Action<char>?> TextBoxHandleChatInputCallbacks = [];
57+
58+
public virtual int CompositionCursorPosition { get; set; }
59+
60+
public static IMEHandler Create(Game game)
61+
{
62+
return new WinFormsIMEHandler(game);
63+
}
64+
65+
public abstract void SetTextInputRectangle(Rectangle rectangle);
66+
67+
public abstract void StartTextComposition();
68+
69+
public abstract void StopTextComposition();
70+
71+
protected virtual void OnIMETextInput(char character)
72+
{
73+
//Debug.WriteLine($"IME: OnIMETextInput: {character} {(short)character}; IMEFocus is null? {IMEFocus == null}");
74+
75+
IMEEventReceived = true;
76+
LastActionIMEChatInput = true;
77+
78+
if (IMEFocus != null)
79+
{
80+
TextBoxHandleChatInputCallbacks.TryGetValue(IMEFocus, out var handleChatInput);
81+
handleChatInput?.Invoke(character);
82+
}
83+
}
84+
85+
public void SetIMETextInputRectangle(WindowManager manager)
86+
{
87+
// When the client window resizes, we should call SetIMETextInputRectangle()
88+
if (manager.SelectedControl is XNATextBox textBox)
89+
SetIMETextInputRectangle(textBox);
90+
}
91+
92+
private void SetIMETextInputRectangle(XNATextBox sender)
93+
{
94+
WindowManager windowManager = sender.WindowManager;
95+
96+
Rectangle textBoxRect = sender.RenderRectangle();
97+
double scaleRatio = windowManager.ScaleRatio;
98+
99+
Rectangle rect = new()
100+
{
101+
X = (int)(textBoxRect.X * scaleRatio + windowManager.SceneXPosition),
102+
Y = (int)(textBoxRect.Y * scaleRatio + windowManager.SceneYPosition),
103+
Width = (int)(textBoxRect.Width * scaleRatio),
104+
Height = (int)(textBoxRect.Height * scaleRatio)
105+
};
106+
107+
// The following code returns a more accurate location based on the current InputPosition.
108+
// However, as SetIMETextInputRectangle() does not automatically update with changes in InputPosition
109+
// (e.g., due to scrolling or mouse clicks altering the textbox's input position without shifting focus),
110+
// accuracy becomes inconsistent. Sometimes it's precise, other times it's off,
111+
// which is arguably worse than a consistent but manageable inaccuracy.
112+
// This inconsistency could lead to a confusing user experience,
113+
// as the input rectangle's position may not reliably reflect the current input position.
114+
// Therefore, unless whenever InputPosition is changed, SetIMETextInputRectangle() is raised
115+
// -- which requires more time to investigate and test, it's commented out for now.
116+
//var vec = Renderer.GetTextDimensions(
117+
// sender.Text.Substring(sender.TextStartPosition, sender.InputPosition),
118+
// sender.FontIndex);
119+
//rect.X += (int)(vec.X * scaleRatio);
120+
121+
SetTextInputRectangle(rect);
122+
}
123+
124+
void IIMEHandler.OnSelectedChanged(XNATextBox sender)
125+
{
126+
if (sender.WindowManager.SelectedControl == sender)
127+
{
128+
StopTextComposition();
129+
130+
if (!sender.IMEDisabled && sender.Enabled && sender.Visible)
131+
{
132+
IMEFocus = sender;
133+
134+
// Update the location of IME based on the textbox
135+
SetIMETextInputRectangle(sender);
136+
137+
StartTextComposition();
138+
}
139+
else
140+
{
141+
IMEFocus = null;
142+
}
143+
}
144+
else if (sender.WindowManager.SelectedControl is not XNATextBox)
145+
{
146+
// Disable IME since the current selected control is not XNATextBox
147+
IMEFocus = null;
148+
StopTextComposition();
149+
}
150+
151+
// Note: if sender.WindowManager.SelectedControl != sender and is XNATextBox,
152+
// another OnSelectedChanged() will be triggered,
153+
// so we do not need to handle this case
154+
}
155+
156+
void IIMEHandler.RegisterXNATextBox(XNATextBox sender, Action<char>? handleCharInput)
157+
=> TextBoxHandleChatInputCallbacks[sender] = handleCharInput;
158+
159+
void IIMEHandler.KillXNATextBox(XNATextBox sender)
160+
=> TextBoxHandleChatInputCallbacks.TryRemove(sender, out _);
161+
162+
bool IIMEHandler.HandleScrollLeftKey(XNATextBox sender)
163+
=> !CompositionEmpty;
164+
165+
bool IIMEHandler.HandleScrollRightKey(XNATextBox sender)
166+
=> !CompositionEmpty;
167+
168+
bool IIMEHandler.HandleBackspaceKey(XNATextBox sender)
169+
{
170+
bool handled = !LastActionIMEChatInput;
171+
LastActionIMEChatInput = true;
172+
//Debug.WriteLine($"IME: HandleBackspaceKey: handled: {handled}");
173+
return handled;
174+
}
175+
176+
bool IIMEHandler.HandleDeleteKey(XNATextBox sender)
177+
{
178+
bool handled = !LastActionIMEChatInput;
179+
LastActionIMEChatInput = true;
180+
//Debug.WriteLine($"IME: HandleDeleteKey: handled: {handled}");
181+
return handled;
182+
}
183+
184+
bool IIMEHandler.GetDrawCompositionText(XNATextBox sender, out string composition, out int compositionCursorPosition)
185+
{
186+
if (IMEFocus != sender || CompositionEmpty)
187+
{
188+
composition = string.Empty;
189+
compositionCursorPosition = 0;
190+
return false;
191+
}
192+
193+
composition = Composition;
194+
compositionCursorPosition = CompositionCursorPosition;
195+
return true;
196+
}
197+
198+
bool IIMEHandler.HandleCharInput(XNATextBox sender, char input)
199+
=> TextCompositionEnabled;
200+
201+
bool IIMEHandler.HandleEnterKey(XNATextBox sender)
202+
=> false;
203+
204+
bool IIMEHandler.HandleEscapeKey(XNATextBox sender)
205+
{
206+
//Debug.WriteLine($"IME: HandleEscapeKey: handled: {IMEEventReceived}");
207+
return IMEEventReceived;
208+
}
209+
210+
void IIMEHandler.OnTextChanged(XNATextBox sender) { }
211+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#nullable enable
2+
using System;
3+
4+
using ImeSharp;
5+
6+
using Microsoft.Xna.Framework;
7+
8+
using Rampastring.Tools;
9+
10+
namespace TSMapEditor.UI.IME;
11+
12+
/// <summary>
13+
/// Integrate IME to XNA framework.
14+
/// </summary>
15+
internal class WinFormsIMEHandler : IMEHandler
16+
{
17+
public override bool TextCompositionEnabled
18+
{
19+
get => InputMethod.Enabled;
20+
protected set
21+
{
22+
if (value != InputMethod.Enabled)
23+
InputMethod.Enabled = value;
24+
}
25+
}
26+
27+
public WinFormsIMEHandler(Game game)
28+
{
29+
Logger.Log($"Initialize WinFormsIMEHandler.");
30+
if (game?.Window?.Handle == null)
31+
throw new Exception("The handle of game window should not be null");
32+
33+
InputMethod.Initialize(game.Window.Handle);
34+
InputMethod.TextInputCallback = OnIMETextInput;
35+
InputMethod.TextCompositionCallback = (compositionText, cursorPosition) =>
36+
{
37+
Composition = compositionText.ToString();
38+
CompositionCursorPosition = cursorPosition;
39+
};
40+
}
41+
42+
public override void StartTextComposition()
43+
{
44+
//Debug.WriteLine("IME: StartTextComposition");
45+
TextCompositionEnabled = true;
46+
}
47+
48+
public override void StopTextComposition()
49+
{
50+
//Debug.WriteLine("IME: StopTextComposition");
51+
TextCompositionEnabled = false;
52+
}
53+
54+
public override void SetTextInputRectangle(Rectangle rect)
55+
=> InputMethod.SetTextInputRect(rect.X, rect.Y, rect.Width, rect.Height);
56+
}

0 commit comments

Comments
 (0)