LinLingo

v1.2.0 English

為什麼我不攔截鍵盤

做一個「在任何地方打字都能即時翻譯」的工具,最直覺的做法是攔截鍵盤。 我沒有這樣做,因為那會讓中文、日文、韓文的輸入法整組失效。 這篇說明原因,以及我改用什麼做法。

直覺的做法:攔截鍵盤

Windows 提供低階鍵盤掛鉤(SetWindowsHookExW(WH_KEYBOARD_LL, ...)), 可以在按鍵送進任何應用程式之前先看一眼,甚至把它吃掉。

對翻譯工具來說這非常誘人:使用者在任何輸入框打字,我都能即時看到, 翻譯完再模擬按鍵補回去。使用者完全感覺不到中間發生了什麼事。

問題出在「吃掉按鍵」這個動作。

中文輸入法不是「打一個鍵送一個字」

注音、拼音這類輸入法的實際流程是:

  1. 你依序按下 ㄋ、ㄧ、ˇ
  2. 輸入法把這些按鍵收進一個組字緩衝區,在畫面上顯示「ㄋㄧˇ」與候選字
  3. 你選字(按數字鍵或空白鍵)
  4. 輸入法才把「你」這個字提交給應用程式

關鍵在第 1 步到第 3 步之間完全沒有「已完成的文字」, 只有一串還沒定案的按鍵。

低階鍵盤掛鉤如果回傳 1(吃掉按鍵),這些按鍵根本不會進到輸入法。 輸入法收不到 ㄋ,自然組不出字。

症狀長什麼樣

你按了什麼期望攔截鍵盤後實際發生
ㄋ ㄧ ˇ出現候選字「你」什麼都沒出現
選字鍵「你」被送進輸入框沒有東西可以選
改打英文正常正常(所以一開始不容易發現問題)

這也是為什麼這類問題常常「上線後才被回報」—— 開發者自己用英文測試時一切正常。

為什麼「只在組字時放行」很難做對

聽起來很簡單:偵測到輸入法正在組字就放行,否則才攔截。實際上有三個障礙:

結果就是做出來的東西「大部分時候可以」, 但中文使用者會不定時遇到打不出字,而且難以重現。

代價與效益

攔截鍵盤不攔截(卡片輸入)
操作感受 看起來像魔法,直接在原輸入框打字 多一張卡片,但按鍵行為完全正常
中文/日文/韓文 組字被打斷 正常
密碼欄位 可能一併被攔截 不會經過,因為卡片是獨立視窗
需要處理的邊角 Backspace、選字、貼上、拖曳選取、組合鍵…每一種都要判斷 作業系統與輸入法自己處理

左邊那一欄的「邊角」比想像中多。一旦攔截了鍵盤,就等於要重新實作 一輪編輯器的行為——而且永遠追不上真正的編輯器。

我的做法:卡片本身就是輸入面

  1. 你在原本的輸入框按 Ctrl+Alt+T
  2. 一張浮動卡片出現並取得焦點
  3. 你在卡片裡用自己的語言打字——注音、拼音、日文 IME 全部正常
  4. 譯文即時顯示在卡片下方
  5. 按 Enter,譯文回填到原本的輸入框

關鍵是沒有人在猜你在打什麼。你打的字是進入一個真正的文字方塊, 作業系統與輸入法用它原本的方式運作。

這樣反而少掉很多問題:

實作上的幾個選擇

用 RegisterHotKey,不用鍵盤掛鉤

全域熱鍵有兩種做法:低階鍵盤掛鉤,或 RegisterHotKey。

RegisterHotKey 讓系統在偵測到組合鍵時送一個 WM_HOTKEY 給你, 按鍵本身仍然照常送到目前的視窗。這正是我要的—— 我只想被通知,不想擋住任何東西。

註冊三個熱鍵就夠了:

熱鍵用途
Ctrl+Alt+T開關翻譯模式
Ctrl+Alt+C把焦點拉回卡片
Ctrl+Alt+Enter在任何地方直接回填譯文

值得一提的是 Ctrl+Alt+Enter 這種「在別人的視窗裡也要生效」的熱鍵, 只有 RegisterHotKey 做得到——鍵盤掛鉤反而做不到, 因為它拿不到「現在是哪個輸入框有焦點」的可靠資訊。

焦點:SetForegroundWindow 的前景鎖

Windows 限制只有前景程序能搶前景。工具列在背景執行,要把焦點交給卡片或還給目標輸入框時, 常常會被拒絕。

繞過的方式是在呼叫前先模擬一次 Alt 的按下與放開:

inject_key(VK_MENU)
inject_key(VK_MENU, KEYEVENTF_KEYUP)
user32.SetForegroundWindow(target)

這個小動作會讓系統認為「使用者剛剛有輸入」, 因而解除前景鎖定。Alt 本身不會產生任何輸入內容,是安全的選擇。

回填:剪貼簿 + Ctrl+V

把譯文送進別人的輸入框有兩種做法:逐鍵模擬,或透過剪貼簿。

逐鍵模擬(SendInput)聽起來比較乾淨,實際上很容易出錯: 中文、日文、emoji 不一定能用 VK_PACKET 正確送出, 而且會觸發目標應用程式的按鍵處理邏輯。

剪貼簿 + Ctrl+V 幾乎在所有應用程式都可靠, 唯一的缺點是會覆蓋使用者原本的剪貼簿內容。

卡片定位:為什麼不用 Tk 的 geometry

這是一個小但容易卡住的地方。 Tk 的 geometry() 把負數座標解讀成「距離畫面右側/下側多少」, 所以「把視窗移到左邊那顆螢幕」這種需求會直接失敗,視窗會跳到奇怪的角落。

解法是繞過 Tk,直接用 Win32 的 SetWindowPos, 並帶上 SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE—— 只改位置,不動大小、不動疊放順序、也不搶焦點。

DPI:多螢幕不同縮放的比例問題

兩顆螢幕縮放比例不同時,未宣告 DPI 感知的程序會拿到被系統換算過的座標, 視窗就會偏移。開頭先宣告一次即可:

user32.SetProcessDpiAwarenessContext(-4)   # PER_MONITOR_AWARE_V2

那什麼時候我還是會用掛鉤

掛鉤不是邪惡的,只是被用錯地方。如果工具的目的是記錄或阻擋按鍵 (例如密碼管理員、巨集錄製),掛鉤就是正確的工具。

差別在於你的工具需不需要知道「使用者打了什麼字」。 需要,就得面對輸入法的問題;不需要,就不要碰鍵盤。

結語

「不攔截鍵盤」讓我少掉一整類 bug,代價只是多一張卡片。 對一個要在各種應用程式裡工作的工具來說,這個交換很划算。

如果你也在做類似的工具,希望這篇能幫你少走一段路。

LinLingo 是把上述取捨實作出來的 Windows 即時翻譯工具: 按 Ctrl+Alt+T 開卡片、打字、Enter 回填, 注音與拼音都能正常組字。 看說明 · 下載 · 原始碼