diff --git a/samples/ControlCatalog/Pages/TextBoxPage.xaml b/samples/ControlCatalog/Pages/TextBoxPage.xaml index 233b309caf..0a5ccdcfff 100644 --- a/samples/ControlCatalog/Pages/TextBoxPage.xaml +++ b/samples/ControlCatalog/Pages/TextBoxPage.xaml @@ -18,7 +18,7 @@ Custom context flyout - + @@ -47,6 +47,19 @@ SelectionStart="5" SelectionEnd="22" SelectionBrush="Green" SelectionForegroundBrush="Yellow"/> + + + + diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 00fc6002d1..32428bea53 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -533,7 +533,7 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { _presenter = e.NameScope.Get("PART_TextPresenter"); - _imClient.SetPresenter(_presenter); + _imClient.SetPresenter(_presenter, this); if (IsFocused) { _presenter?.ShowCaret(); diff --git a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs index c5a729afae..334db2cafd 100644 --- a/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs +++ b/src/Avalonia.Controls/TextBoxTextInputMethodClient.cs @@ -1,5 +1,6 @@ using System; using Avalonia.Controls.Presenters; +using Avalonia.Input; using Avalonia.Input.TextInput; using Avalonia.VisualTree; @@ -7,9 +8,26 @@ namespace Avalonia.Controls { internal class TextBoxTextInputMethodClient : ITextInputMethodClient { + private InputElement _parent; private TextPresenter _presenter; private IDisposable _subscription; - public Rect CursorRectangle => _presenter?.GetCursorRectangle() ?? default; + public Rect CursorRectangle + { + get + { + if (_parent == null || _presenter == null) + { + return default; + } + var transform = _presenter.TransformToVisual(_parent); + if (transform == null) + { + return default; + } + return _presenter.GetCursorRectangle().TransformToAABB(transform.Value); + } + } + public event EventHandler CursorRectangleChanged; public IVisual TextViewVisual => _presenter; public event EventHandler TextViewVisualChanged; @@ -23,9 +41,11 @@ namespace Avalonia.Controls public string TextAfterCursor => null; private void OnCaretIndexChanged(int index) => CursorRectangleChanged?.Invoke(this, EventArgs.Empty); - - public void SetPresenter(TextPresenter presenter) + + + public void SetPresenter(TextPresenter presenter, InputElement parent) { + _parent = parent; _subscription?.Dispose(); _subscription = null; _presenter = presenter; diff --git a/src/Avalonia.Input/InputMethod.cs b/src/Avalonia.Input/InputMethod.cs new file mode 100644 index 0000000000..8098b18c47 --- /dev/null +++ b/src/Avalonia.Input/InputMethod.cs @@ -0,0 +1,32 @@ +namespace Avalonia.Input +{ + public class InputMethod + { + /// + /// A dependency property that enables alternative text inputs. + /// + public static readonly AvaloniaProperty IsInputMethodEnabledProperty = + AvaloniaProperty.RegisterAttached("IsInputMethodEnabled", true); + + /// + /// Setter for IsInputMethodEnabled AvaloniaProperty + /// + public static void SetIsInputMethodEnabled(InputElement target, bool value) + { + target.SetValue(IsInputMethodEnabledProperty, value); + } + + /// + /// Getter for IsInputMethodEnabled AvaloniaProperty + /// + public static bool GetIsInputMethodEnabled(InputElement target) + { + return target.GetValue(IsInputMethodEnabledProperty); + } + + private InputMethod() + { + + } + } +} diff --git a/src/Avalonia.Input/TextInput/InputMethodManager.cs b/src/Avalonia.Input/TextInput/InputMethodManager.cs index 207ba6096e..dafd397348 100644 --- a/src/Avalonia.Input/TextInput/InputMethodManager.cs +++ b/src/Avalonia.Input/TextInput/InputMethodManager.cs @@ -8,9 +8,14 @@ namespace Avalonia.Input.TextInput private ITextInputMethodImpl? _im; private IInputElement? _focusedElement; private ITextInputMethodClient? _client; + private IDisposable? _subscribeDisposable; private readonly TransformTrackingHelper _transformTracker = new TransformTrackingHelper(); - public TextInputMethodManager() => _transformTracker.MatrixChanged += UpdateCursorRect; + public TextInputMethodManager() + { + _transformTracker.MatrixChanged += UpdateCursorRect; + InputMethod.IsInputMethodEnabledProperty.Changed.Subscribe(OnIsInputMethodEnabledChanged); + } private ITextInputMethodClient? Client { @@ -40,6 +45,7 @@ namespace Avalonia.Input.TextInput _im?.SetOptions(optionsQuery); _transformTracker?.SetVisual(_client?.TextViewVisual); UpdateCursorRect(); + _im?.SetActive(true); } else @@ -50,6 +56,14 @@ namespace Avalonia.Input.TextInput } } + private void OnIsInputMethodEnabledChanged(AvaloniaPropertyChangedEventArgs obj) + { + if (ReferenceEquals(obj.Sender, _focusedElement)) + { + TryFindAndApplyClient(); + } + } + private void OnTextViewVisualChanged(object sender, EventArgs e) => _transformTracker.SetVisual(_client?.TextViewVisual); @@ -57,6 +71,7 @@ namespace Avalonia.Input.TextInput { if (_im == null || _client == null || _focusedElement?.VisualRoot == null) return; + var transform = _focusedElement.TransformToVisual(_focusedElement.VisualRoot); if (transform == null) _im.SetCursorRect(default); @@ -75,17 +90,23 @@ namespace Avalonia.Input.TextInput if(_focusedElement == element) return; _focusedElement = element; - + var inputMethod = (element?.VisualRoot as ITextInputMethodRoot)?.InputMethod; - if(_im != inputMethod) + if (_im != inputMethod) _im?.SetActive(false); _im = inputMethod; - - if (_focusedElement == null || _im == null) + + TryFindAndApplyClient(); + } + + private void TryFindAndApplyClient() + { + if (_focusedElement is not InputElement focused || + _im == null || + !InputMethod.GetIsInputMethodEnabled(focused)) { Client = null; - _im?.SetActive(false); return; } @@ -93,7 +114,7 @@ namespace Avalonia.Input.TextInput { RoutedEvent = InputElement.TextInputMethodClientRequestedEvent }; - + _focusedElement.RaiseEvent(clientQuery); Client = clientQuery.Client; } diff --git a/src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs b/src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs new file mode 100644 index 0000000000..38605efa22 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Input/Imm32CaretManager.cs @@ -0,0 +1,38 @@ +using System; +using static Avalonia.Win32.Interop.UnmanagedMethods; + +namespace Avalonia.Win32.Input +{ + internal struct Imm32CaretManager + { + private bool _isCaretCreated; + + public void TryCreate(int _langId, IntPtr hwnd) + { + if (!_isCaretCreated) + { + if (_langId == LANG_ZH || _langId == LANG_JA) + { + _isCaretCreated = CreateCaret(hwnd, IntPtr.Zero, 2, 10); + } + } + } + + public void TryMove(int x, int y) + { + if (_isCaretCreated) + { + SetCaretPos(x, y); + } + } + + public void TryDestroy() + { + if (_isCaretCreated) + { + DestroyCaret(); + _isCaretCreated = false; + } + } + } +} diff --git a/src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs b/src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs new file mode 100644 index 0000000000..71e33554f1 --- /dev/null +++ b/src/Windows/Avalonia.Win32/Input/Imm32InputMethod.cs @@ -0,0 +1,231 @@ +using System; +using Avalonia.Input.TextInput; +using Avalonia.Threading; + +using static Avalonia.Win32.Interop.UnmanagedMethods; + +namespace Avalonia.Win32.Input +{ + /// + /// A Windows input method editor based on Windows Input Method Manager (IMM32). + /// + class Imm32InputMethod : ITextInputMethodImpl + { + public IntPtr HWND { get; private set; } + private IntPtr _defaultImc; + private WindowImpl _parent; + private bool _active; + private bool _showCompositionWindow; + private Imm32CaretManager _caretManager = new(); + private bool _showCandidateList; + private ushort _langId; + private const int _caretMargin = 1; + + public void SetLanguageAndWindow(WindowImpl parent, IntPtr hwnd, IntPtr HKL) + { + if (HWND != hwnd) + { + _defaultImc = IntPtr.Zero; + } + HWND = hwnd; + _parent = parent; + _active = false; + _langId = PRIMARYLANGID(LGID(HKL)); + _showCompositionWindow = true; + _showCandidateList = true; + + IsComposing = false; + } + + //Dependant on CurrentThread. When Avalonia will support Multiple Dispatchers - + //every Dispatcher should have their own InputMethod. + public static Imm32InputMethod Current { get; } = new Imm32InputMethod(); + + private IntPtr DefaultImc + { + get + { + if (_defaultImc == IntPtr.Zero && + HWND != IntPtr.Zero) + { + _defaultImc = ImmGetContext(HWND); + ImmReleaseContext(HWND, _defaultImc); + } + + if (_defaultImc == IntPtr.Zero) + { + _defaultImc = ImmCreateContext(); + } + + return _defaultImc; + } + } + + public void Reset() + { + if (IsComposing) + { + Dispatcher.UIThread.Post(() => + { + ImmNotifyIME(DefaultImc, NI_COMPOSITIONSTR, CPS_COMPLETE, 0); + ImmReleaseContext(HWND, DefaultImc); + IsComposing = false; + }); + } + } + + public void SetActive(bool active) + { + _active = active; + Dispatcher.UIThread.Post(() => + { + if (active) + { + if (DefaultImc != IntPtr.Zero) + { + _caretManager.TryCreate(_langId, HWND); + // Load the default IME context. + // NOTE(hbono) + // IMM ignores this call if the IME context is loaded. Therefore, we do + // not have to check whether or not the IME context is loaded. + ImmAssociateContext(HWND, _defaultImc); + } + } + else + { + // A renderer process have moved its input focus to a password input + // when there is an ongoing composition, e.g. a user has clicked a + // mouse button and selected a password input while composing a text. + // For this case, we have to complete the ongoing composition and + // clean up the resources attached to this object BEFORE DISABLING THE IME. + if (IsComposing) + { + ImmNotifyIME(DefaultImc, NI_COMPOSITIONSTR, CPS_COMPLETE, 0); + ImmReleaseContext(HWND, DefaultImc); + IsComposing = false; + } + ImmAssociateContext(HWND, IntPtr.Zero); + _caretManager.TryDestroy(); + } + }); + } + + public void SetCursorRect(Rect rect) + { + var focused = GetActiveWindow() == HWND; + if (!focused) + { + return; + } + Dispatcher.UIThread.Post(() => + { + IntPtr himc = DefaultImc; + if (himc == IntPtr.Zero) + { + return; + } + + MoveImeWindow(rect, himc); + ImmReleaseContext(HWND, himc); + }); + } + + // see: https://chromium.googlesource.com/experimental/chromium/src/+/bf09a5036ccfb77d2277247c66dc55daf41df3fe/chrome/browser/ime_input.cc + // see: https://engine.chinmaygarde.com/window__win32_8cc_source.html + private void MoveImeWindow(Rect rect, IntPtr himc) + { + var p1 = rect.TopLeft; + var p2 = rect.BottomRight; + var s = _parent?.DesktopScaling ?? 1; + var (x1, y1, x2, y2) = ((int) (p1.X * s), (int) (p1.Y * s), (int) (p2.X * s), (int) (p2.Y * s)); + + if (!_showCompositionWindow && + _langId == LANG_ZH) + { + // Chinese IMEs ignore function calls to ::ImmSetCandidateWindow() + // when a user disables TSF (Text Service Framework) and CUAS (Cicero + // Unaware Application Support). + // On the other hand, when a user enables TSF and CUAS, Chinese IMEs + // ignore the position of the current system caret and uses the + // parameters given to ::ImmSetCandidateWindow() with its 'dwStyle' + // parameter CFS_CANDIDATEPOS. + // Therefore, we do not only call ::ImmSetCandidateWindow() but also + // set the positions of the temporary system caret. + var candidateForm = new CANDIDATEFORM + { + dwIndex = 0, + dwStyle = CFS_CANDIDATEPOS, + ptCurrentPos = new POINT {X = x2, Y = y2} + }; + ImmSetCandidateWindow(himc, ref candidateForm); + } + + _caretManager.TryMove(x2, y2); + + if (_showCompositionWindow) + { + ConfigureCompositionWindow(x1, y1, himc, y2 - y1); + // Don't need to set the position of candidate window. + return; + } + + if (_langId == LANG_KO) + { + // Chinese IMEs and Japanese IMEs require the upper-left corner of + // the caret to move the position of their candidate windows. + // On the other hand, Korean IMEs require the lower-left corner of the + // caret to move their candidate windows. + y2 += _caretMargin; + } + + // Need to return here since some Chinese IMEs would stuck if set + // candidate window position with CFS_EXCLUDE style. + if (_langId == LANG_ZH) + { + return; + } + + // Japanese IMEs and Korean IMEs also use the rectangle given to + // ::ImmSetCandidateWindow() with its 'dwStyle' parameter CFS_EXCLUDE + // to move their candidate windows when a user disables TSF and CUAS. + // Therefore, we also set this parameter here. + var excludeRectangle = new CANDIDATEFORM + { + dwIndex = 0, + dwStyle = CFS_EXCLUDE, + ptCurrentPos = new POINT {X = x1, Y = y1}, + rcArea = new RECT {left = x1, top = y1, right = x2, bottom = y2 + _caretMargin} + }; + ImmSetCandidateWindow(himc, ref excludeRectangle); + } + + private static void ConfigureCompositionWindow(int x1, int y1, IntPtr himc, int height) + { + var compForm = new COMPOSITIONFORM + { + dwStyle = CFS_POINT, + ptCurrentPos = new POINT {X = x1, Y = y1}, + }; + ImmSetCompositionWindow(himc, ref compForm); + + var logFont = new LOGFONT() + { + lfHeight = height, + lfQuality = 5 //CLEARTYPE_QUALITY + }; + ImmSetCompositionFont(himc, ref logFont); + } + + public void SetOptions(TextInputOptionsQueryEventArgs options) + { + // we're skipping this. not usable on windows + } + + public bool IsComposing { get; set; } + + ~Imm32InputMethod() + { + _caretManager.TryDestroy(); + } + } +} diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 3b2b99fb0c..c74c5fbc01 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -1551,6 +1551,112 @@ namespace Avalonia.Win32.Interop [DllImport("user32.dll")] internal static extern int SetWindowCompositionAttribute(IntPtr hwnd, ref WindowCompositionAttributeData data); + [DllImport("imm32.dll", SetLastError = true)] + public static extern IntPtr ImmGetContext(IntPtr hWnd); + [DllImport("imm32.dll", SetLastError = true)] + public static extern IntPtr ImmAssociateContext(IntPtr hWnd, IntPtr hIMC); + [DllImport("imm32.dll", SetLastError = true)] + public static extern IntPtr ImmCreateContext(); + [DllImport("imm32.dll")] + public static extern bool ImmReleaseContext(IntPtr hWnd, IntPtr hIMC); + [DllImport("imm32.dll")] + public static extern bool ImmSetOpenStatus(IntPtr hIMC, bool flag); + [DllImport("imm32.dll")] + public static extern bool ImmSetActiveContext(IntPtr hIMC, bool flag); + [DllImport("imm32.dll")] + public static extern bool ImmSetStatusWindowPos(IntPtr hIMC, ref POINT lpptPos); + [DllImport("imm32.dll")] + public static extern bool ImmIsIME(IntPtr HKL); + [DllImport("imm32.dll")] + public static extern bool ImmSetCandidateWindow(IntPtr hIMC, ref CANDIDATEFORM lpCandidate); + [DllImport("imm32.dll")] + public static extern bool ImmSetCompositionWindow(IntPtr hIMC, ref COMPOSITIONFORM lpComp); + [DllImport("imm32.dll")] + public static extern bool ImmSetCompositionFont(IntPtr hIMC, ref LOGFONT lf); + [DllImport("imm32.dll")] + public static extern bool ImmNotifyIME(IntPtr hIMC, int dwAction, int dwIndex, int dwValue); + [DllImport("user32.dll")] + public static extern bool CreateCaret(IntPtr hwnd, IntPtr hBitmap, int nWidth, int nHeight); + [DllImport("user32.dll")] + public static extern bool SetCaretPos(int X, int Y); + [DllImport("user32.dll")] + public static extern bool DestroyCaret(); + [DllImport("user32.dll")] + public static extern IntPtr GetKeyboardLayout(int idThread); + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern int LCIDToLocaleName(uint Locale, StringBuilder lpName, int cchName, int dwFlags); + + public static uint MAKELCID(uint lgid, uint srtid) + { + return (((uint)(ushort)srtid) << 16) | + ((ushort)lgid); + } + + public static ushort PRIMARYLANGID(uint lgid) + { + return (ushort)(lgid & 0x3ff); + } + + public static uint LGID(IntPtr HKL) + { + return (uint)(HKL.ToInt32() & 0xffff); + } + + public const int SORT_DEFAULT = 0; + public const int LANG_ZH = 0x0004; + public const int LANG_JA = 0x0011; + public const int LANG_KO = 0x0012; + + public const int CFS_FORCE_POSITION = 0x0020; + public const int CFS_CANDIDATEPOS = 0x0040; + public const int CFS_EXCLUDE = 0x0080; + public const int CFS_POINT = 0x0002; + public const int CFS_RECT = 0x0001; + public const uint ISC_SHOWUICOMPOSITIONWINDOW = 0x80000000; + + public const int NI_COMPOSITIONSTR = 21; + public const int CPS_COMPLETE = 1; + public const int CPS_CONVERT = 2; + public const int CPS_REVERT = 3; + public const int CPS_CANCEL = 4; + + [StructLayout(LayoutKind.Sequential)] + internal struct CANDIDATEFORM + { + public int dwIndex; + public int dwStyle; + public POINT ptCurrentPos; + public RECT rcArea; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct COMPOSITIONFORM + { + public int dwStyle; + public POINT ptCurrentPos; + public RECT rcArea; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct LOGFONT + { + public int lfHeight; + public int lfWidth; + public int lfEscapement; + public int lfOrientation; + public int lfWeight; + public byte lfItalic; + public byte lfUnderline; + public byte lfStrikeOut; + public byte lfCharSet; + public byte lfOutPrecision; + public byte lfClipPrecision; + public byte lfQuality; + public byte lfPitchAndFamily; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string lfFaceName; + } + [StructLayout(LayoutKind.Sequential)] internal struct WindowCompositionAttributeData { diff --git a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs index eaf6b47f42..89d5009da5 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using System.Text; using Avalonia.Controls; using Avalonia.Controls.Remote; using Avalonia.Input; @@ -34,6 +35,7 @@ namespace Avalonia.Win32 case WindowActivate.WA_CLICKACTIVE: { Activated?.Invoke(); + UpdateInputMethod(GetKeyboardLayout(0)); break; } @@ -472,6 +474,35 @@ namespace Avalonia.Win32 case WindowsMessage.WM_KILLFOCUS: LostFocus?.Invoke(); break; + + case WindowsMessage.WM_INPUTLANGCHANGE: + { + UpdateInputMethod(lParam); + // call DefWindowProc to pass to all children + break; + } + case WindowsMessage.WM_IME_SETCONTEXT: + { + // TODO if we implement preedit, disable the composition window: + // lParam = new IntPtr((int)(((uint)lParam.ToInt64()) & ~ISC_SHOWUICOMPOSITIONWINDOW)); + UpdateInputMethod(GetKeyboardLayout(0)); + break; + } + case WindowsMessage.WM_IME_CHAR: + case WindowsMessage.WM_IME_COMPOSITION: + case WindowsMessage.WM_IME_COMPOSITIONFULL: + case WindowsMessage.WM_IME_CONTROL: + case WindowsMessage.WM_IME_KEYDOWN: + case WindowsMessage.WM_IME_KEYUP: + case WindowsMessage.WM_IME_NOTIFY: + case WindowsMessage.WM_IME_SELECT: + break; + case WindowsMessage.WM_IME_STARTCOMPOSITION: + Imm32InputMethod.Current.IsComposing = true; + break; + case WindowsMessage.WM_IME_ENDCOMPOSITION: + Imm32InputMethod.Current.IsComposing = false; + break; } #if USE_MANAGED_DRAG @@ -500,6 +531,20 @@ namespace Avalonia.Win32 } } + private void UpdateInputMethod(IntPtr hkl) + { + // note: for non-ime language, also create it so that emoji panel tracks cursor + var langid = LGID(hkl); + if (langid == _langid && Imm32InputMethod.Current.HWND == Hwnd) + { + return; + } + _langid = langid; + + Imm32InputMethod.Current.SetLanguageAndWindow(this, Hwnd, hkl); + + } + private static int ToInt32(IntPtr ptr) { if (IntPtr.Size == 4) diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 0a031cd5bf..4c3165eaf9 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -6,6 +6,7 @@ using Avalonia.Controls; using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; using Avalonia.OpenGL; using Avalonia.OpenGL.Angle; using Avalonia.OpenGL.Egl; @@ -25,7 +26,8 @@ namespace Avalonia.Win32 /// Window implementation for Win32 platform. /// public partial class WindowImpl : IWindowImpl, EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo, - ITopLevelImplWithNativeControlHost + ITopLevelImplWithNativeControlHost, + ITopLevelImplWithTextInputMethod { private static readonly List s_instances = new List(); @@ -87,6 +89,7 @@ namespace Avalonia.Win32 private bool _isCloseRequested; private bool _shown; private bool _hiddenWindowIsParent; + private uint _langid; public WindowImpl() { @@ -122,7 +125,7 @@ namespace Avalonia.Win32 CreateWindow(); _framebuffer = new FramebufferManager(_hwnd); - + UpdateInputMethod(GetKeyboardLayout(0)); if (glPlatform != null) { if (_isUsingComposition) @@ -1353,5 +1356,7 @@ namespace Avalonia.Win32 public void Dispose() => _owner._resizeReason = _restore; } + + public ITextInputMethodImpl TextInputMethod => Imm32InputMethod.Current; } }