WPF 自定義文字框輸入法 IME 跟隨游標

abstractcyj發表於2022-02-28

本文告訴大家在 WPF 寫一個自定義的文字框,如何實現讓輸入法跟隨游標

本文非小白向,本文適合想開發自定義的文字框,從底層開始開發的文字庫的夥伴。在開始之前,期望瞭解了文字庫開發的基礎知識

本文實現的效果如下

實現

本文的方法參考了 WPF 官方倉庫的邏輯,可以在 WPF 倉庫的 wpf\src\Microsoft.DotNet.Wpf\src\PresentationFramework\System\Windows\Documents\ImmComposition.cs 檔案看到官方是如何讓 TextBox 控制元件獲取輸入法焦點,和在輸入游標變更時,修改輸入法的輸入框座標

先了解一下輸入法的相關知識。在 Windows 程式設計開發裡,輸入法框架有三套,其中用的最多的是第二套。第二套是採用 IMM 進行對接的。所謂 IMM 就是 Input Method Manager 也就是 輸入法管理器

相關的另一個縮寫詞 IME 則是 Input Method Editor 或者是 Input Method Engine 的縮寫,含義是輸入法編輯器或輸入法引擎

應用程式可以通過 IMM 對接輸入法。所用的 win32 的 API 重點是如下幾個

  • ImmGetContext 獲取輸入法上下文,用於後續所有的其他函式呼叫
  • ImmAssociateContext 關聯輸入法和對應的視窗,讓輸入法瞭解在哪個視窗輸入
  • ImmSetCompositionWindow 用來設定輸入法的視窗的座標,也是本文最重要的函式

本文接下來將告訴大家如何一步步實現封裝對 IME 輸入法呼叫,在本文最後將會給出所有的原始碼

這部分對輸入法的邏輯可以封裝為一個類,這樣上層就可以不關注細節邏輯。如例子程式碼,放在 IMESupporter 型別裡

為了方便文字框的接入,我們再定義一個介面,用於設定文字框需要實現一些方法,用來提供引數給 IMESupporter 使用才能進行接入

    /// <summary>
    /// 表示控制元件支援被輸入法
    /// </summary>
    interface IIMETextEditor
    {
        /// <summary>
        /// 獲取當前使用的字型名
        /// </summary>
        /// <returns></returns>
        string GetFontFamilyName();

        /// <summary>
        /// 獲取字號大小,單位和 WPF 的 FontSize 相同
        /// </summary>
        /// <returns></returns>
        int GetFontSize();

        /// <summary>
        /// 獲取輸入框的左上角的點,用於設定輸入法的左上角。此點相對於 <see cref="IIMETextEditor"/> 所在元素座標。對大部分控制元件來說,都應該是 0,0 點
        /// </summary>
        /// <returns></returns>
        Point GetTextEditorLeftTop();

        /// <summary>
        /// 獲取游標的輸入左上角的點。此點相對於 <see cref="IIMETextEditor"/> 所在元素座標
        /// </summary>
        /// <returns></returns>
        Point GetCaretLeftTop();
    }

對於如微軟拼音等輸入法,是支援設定輸入法的文字大小和字型。因此就需要文字框提供 GetFontFamilyName 和 GetFontSize 方法

而 GetCaretLeftTop 自然就是用來讓輸入法跟隨的。為了讓文字框可以做更多的定製,也需要 GetTextEditorLeftTop 方法,這個方法的返回值對大部分自定義的文字框控制元件來說,都應該是 0,0 點

在 IMESupporter 型別建構函式,期望傳入文字框控制元件,如此可以解決初始化值和監聽的鍋

    internal class IMESupporter<T> where T : UIElement, IIMETextEditor
    {
        // ReSharper disable InconsistentNaming
        public IMESupporter(T editor)
        {
            Editor = editor;
            // 忽略程式碼
        }
    }

為了同時約束傳入的文字框控制元件繼承 UIElement 和 IIMETextEditor 介面,用了泛形

在文字框控制元件 Editor 獲取焦點的時候,將需要喚起輸入法進行輸入。在 Editor 失去焦點的時候,就應該告訴輸入法當前不進行輸入

        public IMESupporter(T editor)
        {
            Editor = editor;
            Editor.GotKeyboardFocus += Editor_GotKeyboardFocus;
            Editor.LostKeyboardFocus += Editor_LostKeyboardFocus;
        }

        private T Editor { get; }

根據 WPF 的約定,對自定義的支援輸入法的控制元件,需要設定 IsInputMethodSuspendedProperty 附加屬性,如下面程式碼

            InputMethod.SetIsInputMethodSuspended(editor, true);

Editor_GotKeyboardFocus 需要實現的邏輯是調起輸入法和設定初始的輸入框的座標。如上文,開始之前,需要先拿到輸入法上下文。在拿到輸入法上下文之前,可以先獲取預設的 IME 類視窗控制程式碼。先獲取預設的 IME 類視窗控制程式碼是為了在多程式嵌入視窗時,讓微軟拼音輸入法的輸入框跟隨輸入游標而不是在左上角

            _defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(IntPtr.Zero);

以上的 _defaultImeWnd 是一個欄位,在 IMESupporter 裡定義如下欄位和屬性

        private T Editor { get; }

        private IntPtr _defaultImeWnd;
        private IntPtr _currentContext;
        private IntPtr _previousContext;
        private HwndSource? _hwndSource;

        private bool _isUpdatingCompositionWindow;

這裡有一個細節是 ImmGetDefaultIMEWnd 也許會返回 0x00 空值。什麼時候會返回空值?如開啟一個 Win32Dialog 視窗,如 OpenFileDialog 或 SaveFileDialog 等,之後關閉,那麼此時也許 ImmGetDefaultIMEWnd 將會返回空值

拿到空值,需要重新繫結輸入法,告訴輸入法當前的視窗獲取輸入焦點,可以使用如下程式碼,通過修改附加屬性的值,通過附加屬性變更呼叫到 WPF 框架的邏輯,從而修復此問題

            if (_defaultImeWnd == IntPtr.Zero)
            {
                // 如果拿到了空的預設 IME 視窗了,那麼此時也許是作為巢狀視窗放入到另一個程式的視窗
                // 拿不到就需要重新整理一下。否則微軟拼音輸入法將在螢幕的左上角上
                RefreshInputMethodEditors();

                // 忽略程式碼
            }

        /// <summary>
        /// 重新整理 IME 的 ITfThreadMgr 狀態,用於修復開啟 Win32Dialog 之後關閉,輸入法無法輸入中文問題
        /// </summary>
        /// 原因是在開啟 Win32Dialog 之後,將會讓 ITfThreadMgr 失去焦點。因此需要使用本方法重新整理,通過 InputMethod 的 IsInputMethodEnabledProperty 屬性呼叫到 InputMethod 的 EnableOrDisableInputMethod 方法,在這裡面呼叫到 TextServicesContext.DispatcherCurrent.SetFocusOnDefaultTextStore 方法,從而呼叫到 SetFocusOnDim(DefaultTextStore.Current.DocumentManager) 的程式碼,將 DefaultTextStore.Current.DocumentManager 設定為 ITfThreadMgr 的焦點,重新繫結 IME 輸入法
        /// 但是即使如此,依然拿不到 <see cref="_defaultImeWnd"/> 的初始值。依然需要重新開啟和關閉 WPF 視窗才能拿到
        /// [Can we public the `DefaultTextStore.Current.DocumentManager` property to create custom TextEditor with IME · Issue #6139 · dotnet/wpf](https://github.com/dotnet/wpf/issues/6139 )
        private void RefreshInputMethodEditors()
        {
            if (InputMethod.GetIsInputMethodEnabled(Editor))
            {
                InputMethod.SetIsInputMethodEnabled(Editor, false);
            }

            if (InputMethod.GetIsInputMethodSuspended(Editor))
            {
                InputMethod.SetIsInputMethodSuspended(Editor, false);
            }

            InputMethod.SetIsInputMethodEnabled(Editor, true);
            InputMethod.SetIsInputMethodSuspended(Editor, true);
        }

除了給 ImmGetDefaultIMEWnd 傳入 IntPtr.Zero 可以獲取之外,還可以傳入當前的 Editor 所在的 HwndSource 進行獲取,這裡的 HwndSource 就相當於或者說大多數時候是等於 Editor 所在的視窗

            _hwndSource = (HwndSource) (PresentationSource.FromVisual(Editor) ??
                                       throw new ArgumentNullException(nameof(Editor)));

            if (_defaultImeWnd == IntPtr.Zero)
            {
                // 如果拿到了空的預設 IME 視窗了,那麼此時也許是作為巢狀視窗放入到另一個程式的視窗
                // 拿不到就需要重新整理一下。否則微軟拼音輸入法將在螢幕的左上角上
                RefreshInputMethodEditors();

                // 嘗試通過 _hwndSource 也就是文字所在的視窗去獲取
                _defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(_hwndSource.Handle);

                // 忽略程式碼
            }

如果繼續獲取不到,那麼可以嘗試使用 GetForegroundWindow 獲取。使用 GetForegroundWindow 獲取到的也許不是正確的,但是能進入此分支,也好過沒有輸入法

                _defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(_hwndSource.Handle);

                if (_defaultImeWnd == IntPtr.Zero)
                {
                    // 如果依然獲取不到,那麼使用當前啟用的視窗,在準備輸入的時候
                    // 當前的視窗大部分都是對的
                    // 進入這裡,是儘可能恢復輸入法,拿到的 GetForegroundWindow 雖然預計是不對的
                    // 也好過沒有輸入法
                    _defaultImeWnd = IMENative.ImmGetDefaultIMEWnd(Win32.User32.GetForegroundWindow());
                }

接下來通過 _defaultImeWnd 獲取輸入法上下文,如下面程式碼

            // 使用 DefaultIMEWnd 可以比較好解決微軟拼音的輸入法到螢幕左上角的問題
            _currentContext = IMENative.ImmGetContext(_defaultImeWnd);

如果從 _defaultImeWnd 拿不到,則使用 _hwndSource.Handle 獲取

            _currentContext = IMENative.ImmGetContext(_defaultImeWnd);
            if (_currentContext == IntPtr.Zero)
            {
                _currentContext = IMENative.ImmGetContext(_hwndSource.Handle);
            }

獲取上下文之後,將輸入法上下文和當前視窗關聯起來。對於只實現第二套輸入法框架的輸入法,應用程式呼叫 ImmAssociateContext 關聯,即可調起此輸入法在關聯的視窗輸入

            // 對 Win32 使用第二套輸入法框架的輸入法,可以採用 ImmAssociateContext 關聯
            // 但是對實現 TSF 第三套輸入法框架的輸入法,在應用程式對接第三套輸入法框架
            // 就需要呼叫 ITfThreadMgr 的 SetFocus 方法。剛好 WPF 對接了
            _previousContext = IMENative.ImmAssociateContext(_hwndSource.Handle, _currentContext);

輸入法在輸入過程中,將會通過 Windows 訊息和當前視窗進行通訊,如獲取輸入框所需的座標和輸入文字等。因此我們需要加上 Hook 訊息,用於告訴輸入法座標。但不需要處理輸入的文字的邏輯,因為輸入文字的邏輯等在 WPF 已有處理

            _previousContext = IMENative.ImmAssociateContext(_hwndSource.Handle, _currentContext);
            _hwndSource.AddHook(WndProc);

關於 WndProc 的函式邏輯,我們放在後面

在 WPF 框架裡,會對第三套輸入法有進行支援,於是就需要呼叫 ITfThreadMgr 這個 COM 元件進行關聯焦點,如下面程式碼

            // 儘管文件說傳遞null是無效的,但這似乎有助於在與WPF共享的預設輸入上下文中啟用IME輸入法
            // 這裡需要了解的是,在 WPF 的邏輯,是需要傳入 DefaultTextStore.Current.DocumentManager 才符合預期
            IMENative.ITfThreadMgr? threadMgr = IMENative.GetTextFrameworkThreadManager();
            threadMgr?.SetFocus(IntPtr.Zero);

初始化的過程還需要給輸入法的輸入框一個初始化的座標,可使用 Win32 的 ImmSetCompositionWindow 進行設定。在進行設定之前,需要獲取到文字框的輸入游標相對於視窗的座標,用於給輸入法使用

下面程式碼從文字框獲取文字框實現介面的獲取游標和輸入框左上角

            var textEditorLeftTop = Editor.GetTextEditorLeftTop();
            var caretLeftTop = Editor.GetCaretLeftTop();

接下來使用如下程式碼將座標轉換為相對於視窗的

            var hIMC = _currentContext;
            HwndSource source = _hwndSource;

            var textEditorLeftTop = Editor.GetTextEditorLeftTop();
            var caretLeftTop = Editor.GetCaretLeftTop();

            var transformToAncestor = Editor.TransformToAncestor(source.RootVisual);

            var textEditorLeftTopForRootVisual = transformToAncestor.Transform(textEditorLeftTop);
            var caretLeftTopForRootVisual = transformToAncestor.Transform(caretLeftTop);

對 surface 裝置來說,需要進行更多的處理

            //解決surface上輸入法游標位置不正確
            //現象是surface上游標的位置需要乘以2才能正確,普通電腦上沒有這個問題
            //且此問題與DPI無關,目前用CaretWidth可以有效判斷
            caretLeftTopForRootVisual = new Point(caretLeftTopForRootVisual.X / SystemParameters.CaretWidth,
                caretLeftTopForRootVisual.Y / SystemParameters.CaretWidth);

獲取到的座標傳入到 ImmSetCompositionWindow 方法

            //const int CFS_DEFAULT = 0x0000;
            //const int CFS_RECT = 0x0001;
            const int CFS_POINT = 0x0002;
            //const int CFS_FORCE_POSITION = 0x0020;
            //const int CFS_EXCLUDE = 0x0080;
            //const int CFS_CANDIDATEPOS = 0x0040;

            var form = new IMENative.CompositionForm();
            form.dwStyle = CFS_POINT;
            form.ptCurrentPos.x = (int) Math.Max(caretLeftTopForRootVisual.X, textEditorLeftTopForRootVisual.X);
            form.ptCurrentPos.y = (int) Math.Max(caretLeftTopForRootVisual.Y, textEditorLeftTopForRootVisual.Y);
            //if (_isSoftwarePinYinOverWin7)
            //{
            //    form.ptCurrentPos.y += (int) characterBounds.Height;
            //}

            IMENative.ImmSetCompositionWindow(hIMC, ref form);

以上註釋的 _isSoftwarePinYinOverWin7 的邏輯是判斷在系統版本大於 Win7 的系統,如 Win10 系統上,使用微軟拼音輸入法,微軟拼音輸入法在幾個版本,需要修改 Y 座標,加上輸入的行高才可以。但是在一些 Win10 版本,通過補丁又修了這個問題

以上就完成了輸入法的初始化邏輯

接下來就是需要處理 Windows 訊息了,如在收到 WM_INPUTLANGCHANGE 訊息時,需要重新獲取輸入法上下文

        private IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            switch (msg)
            {
            	// 忽略程式碼
                case IMENative.WM_INPUTLANGCHANGE:
                    if (_hwndSource != null)
                    {
                        CreateContext();
                    }

            	// 忽略程式碼
                    break;
            }

            return IntPtr.Zero;
        }

以上獲取輸入法上下文 CreateContext 方法是獲取 _currentContext 的邏輯

在收到 WM_IME_COMPOSITION 訊息,需要更新輸入法的輸入框的座標

        private IntPtr WndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
        {
            switch (msg)
            {
            	// 忽略程式碼
                case IMENative.WM_IME_COMPOSITION:
                    UpdateCompositionWindow();
                    break;
            	// 忽略程式碼
            }

            return IntPtr.Zero;
        }

以上的 UpdateCompositionWindow 方法是呼叫 ImmSetCompositionWindow 方法設定座標的方法

關於此 IMESupporter 型別的所有程式碼,可以從下文獲取

接下來是對接 IMESupporter 和具體的文字框

先在自定義的文字框 TextEditor 控制元件上繼承 IIMETextEditor 介面。為了方便除錯,我們先寫測試邏輯,獲取的輸入游標就是上次滑鼠點選的點以及固定的字型字號

    public partial class TextEditor : FrameworkElement, IIMETextEditor
    {
        // 忽略程式碼

        protected override void OnRender(DrawingContext drawingContext)
        {
            drawingContext.DrawRectangle(Brushes.Black,null,new Rect(MouseDownPoint,new Size(3,30)));
            base.OnRender(drawingContext);
        }

        protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters)
        {
        	// 讓控制元件接收點選
            return new PointHitTestResult(this, hitTestParameters.HitPoint);
        }

        protected override void OnMouseDown(MouseButtonEventArgs e)
        {
            MouseDownPoint = e.GetPosition(this);
            Focus();
            InvalidateVisual();
        }
        
        private Point MouseDownPoint { get; set; }

        string IIMETextEditor.GetFontFamilyName()
        {
            return "微軟雅黑";
        }

        int IIMETextEditor.GetFontSize()
        {
            return 30;
        }

        Point IIMETextEditor.GetTextEditorLeftTop()
        {
            // 相對於當前輸入框的座標
            return new Point(0, 0);
        }

        Point IIMETextEditor.GetCaretLeftTop()
        {
            return MouseDownPoint;
        }
    }

在 OnMouseDown 方法裡面,需要呼叫 Focus 獲取焦點,同時更新一下模擬的游標。模擬的游標是在 OnRender 方法裡面,使用畫出一個矩形模擬的,沒有做閃爍

為了讓控制元件能接收鍵盤訊息,需要設定 FocusableProperty 屬性。為了接收 Tab 鍵,而不是被切到其他控制元件,需要設定 KeyboardNavigation 的 IsTabStopProperty 和 TabNavigationProperty 附加屬性。因為這是作用在所有的自定義文字框 TextEditor 控制元件上的,因此可以在 TextEditor 的靜態建構函式,進行更改預設值,程式碼如下

        static TextEditor()
        {
            // 用於接收 Tab 按鍵,而不是被切換焦點
            KeyboardNavigation.IsTabStopProperty.OverrideMetadata(typeof(TextEditor),
                new FrameworkPropertyMetadata(true));
            KeyboardNavigation.TabNavigationProperty.OverrideMetadata(typeof(TextEditor),
                new FrameworkPropertyMetadata(KeyboardNavigationMode.None));

            // 用於獲取焦點邏輯
            FocusableProperty.OverrideMetadata(typeof(TextEditor),
                new FrameworkPropertyMetadata(true));
        }

完成 TextEditor 控制元件的配置,就可以對接 IMESupporter 類,對接方法是建立即可

        public TextEditor()
        {
            // 忽略程式碼

            _imeSupporter = new IMESupporter<TextEditor>(this);
        }

        private readonly IMESupporter<TextEditor> _imeSupporter;

這樣就完成了文字框讓輸入法跟隨輸入的功能

程式碼

本文所有程式碼放在githubgitee 歡迎訪問

可以通過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin b3a1fffece8284d0b84407aa13d949de6a2f1536

以上使用的是 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git

獲取程式碼之後,開啟 LightTextEditorPlus.sln 檔案

參考文件

WPF 簡單聊聊如何使用 DrawGlyphRun 繪製文字

Can we public the DefaultTextStore.Current.DocumentManager property to create custom TextEditor with IME · Issue #6139 · dotnet/wpf

自己寫了一個輸入法, Windows下的五筆

我的Win32輸入法程式設計心得

文件管理器 - Win32 apps Microsoft Docs

分段 - Win32 apps Microsoft Docs

輸入法編輯器 (IME) 要求 - Windows apps Microsoft Docs

CefSharp/WpfIMEKeyboardHandler.cs at bfa8ccf24c7694a80ec42b8f3d6d1683b144ec68 · cefsharp/CefSharp

ITfContextOwnerCompositionSink (msctf.h) - Win32 apps Microsoft Docs

WM_IME_SETCONTEXT message (Winuser.h) - Win32 apps Microsoft Docs

IME Level 3 app equivalent with Text services

ITfThreadMgr::AssociateFocus (msctf.h) - Win32 apps Microsoft Docs

ITfThreadMgr::SetFocus (msctf.h) - Win32 apps Microsoft Docs

ImmSetCompositionStringA function (imm.h) - Win32 apps Microsoft Docs

WM_IME_COMPOSITION message (Winuser.h) - Win32 apps Microsoft Docs

LOGFONTA (wingdi.h) - Win32 apps Microsoft Docs

ImmGetContext function (imm.h) - Win32 apps Microsoft Docs

Input Context - Win32 apps Microsoft Docs

About Input Method Manager - Win32 apps Microsoft Docs

Developing IME-Aware Multiple-thread Applications - Win32 apps Microsoft Docs

c++ - ImmGetContext returns zero always - Stack Overflow

[AHK]輸入法狀態提示,中文狀態提示“中”,英文狀態提示“EN”[轉] - 生命在等待中延續 - 部落格園

相關文章