<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
本文告訴大家在 WPF 寫一個自定義的文字方塊,如何實現讓輸入法跟隨遊標
本文非小白向,本文適合想開發自定義的文字方塊,從底層開始開發的文字型檔的夥伴。在開始之前,期望瞭解了文字型檔開發的基礎知識
本文實現的效果如下
實現
本文的方法參考了 WPF 官方倉庫的邏輯,可以在WPF倉庫的wpfsrcMicrosoft.DotNet.WpfsrcPresentationFrameworkSystemWindowsDocumentsImmComposition.cs
檔案看到官方是如何讓TextBox控制元件獲取輸入法焦點,和在輸入遊標變更時,修改輸入法的輸入框座標
先了解一下輸入法的相關知識。在 Windows 程式設計開發裡,輸入法框架有三套,其中用的最多的是第二套。第二套是採用 IMM 進行對接的。所謂 IMM 就是 Input Method Manager 也就是 輸入法管理器
相關的另一個縮寫詞 IME 則是 Input Method Editor 或者是 Input Method Engine 的縮寫,含義是輸入法編輯器或輸入法引擎
應用程式可以通過 IMM 對接輸入法。所用的 win32 的 API 重點是如下幾個
本文接下來將告訴大家如何一步步實現封裝對 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;
這樣就完成了文字方塊讓輸入法跟隨輸入的功能
可以通過如下方式獲取本文的原始碼,先建立一個空資料夾,接著使用命令列 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在自定義文字方塊中實現輸入法跟隨遊標的文章就介紹到這了,更多相關WPF輸入法跟隨遊標內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45