我平时用英文键盘布局写代码,想说句话就得先切到中文输入法。我希望按 RAlt + Space 就能直接开始语音输入。

一开始我以为输入法的语音快捷键只在中文布局下才生效。后来发现,微信输入法只要激活过,就会注册一个真正的全局快捷键,切回英文也能触发。但这时它没有权限往当前窗口插入文本,只能把识别的文字放到剪贴板,还得自己粘贴一下。

我本来就在用 AutoHotkey,比如之前分享过解决 Windows 中文输入法下 Shift + Space 快捷键的困扰。于是想让脚本先切到中文,再转发语音快捷键。但是微信输入法的全局快捷键优先级比 AutoHotkey 高。

解决办法是把两组快捷键分开:RAlt + Space 留给 AutoHotkey,微信输入法改用 RWin + RAlt + Space。平时只按前一组,切换布局和转发都交给脚本。

TL;DR

先把微信输入法里「开启和结束语音输入」的快捷键改成 RWin + RAlt + Space。

下面的 AutoHotkey v2 脚本的效果是:英文布局下按 RAlt + Space,自动切到中文输入法并触发语音;再按一次结束,等语音面板关闭后切回英文。原本就在中文布局下,则只转发开启和结束的快捷键,不切换布局。

$>!Space::{
  static panel := "语音输入 ahk_class wetype.flutter.setting"
  static restoreHKL := 0

  ending := WinExist(panel)
  KeyWait "Space"
  KeyWait "RAlt"

  if ending {
    Send "{RAlt down}{RWin down}{Space}{RWin up}{RAlt up}"
    if WinWaitClose(panel, , 3) {
      if restoreHKL
        ApplyHKL(restoreHKL)
      restoreHKL := 0
    }
    return
  }

  restoreHKL := 0
  prev := ForegroundHKL()
  if !ChineseIMEActive() {
    ActivateIME("00000804")
    deadline := A_TickCount + 1000
    while !ChineseIMEActive() && A_TickCount < deadline
      Sleep 20
    if !ChineseIMEActive()
      return
    if (prev & 0x3FF) = 0x09
      restoreHKL := prev
  }
  Sleep 100
  Send "{RAlt down}{RWin down}{Space}{RWin up}{RAlt up}"
}

ChineseIMEActive() => (ForegroundHKL() & 0xFFFF) = 0x0804

ForegroundHKL() {
  hwnd := DllCall("GetForegroundWindow", "ptr")
  tid := DllCall("GetWindowThreadProcessId", "ptr", hwnd, "ptr", 0, "uint")
  return DllCall("GetKeyboardLayout", "uint", tid, "ptr")
}

ActivateIME(klid) {
  if hkl := DllCall("LoadKeyboardLayout", "str", klid, "uint", 0x101, "ptr")
    ApplyHKL(hkl)
}

ApplyHKL(hkl) {
  hwnd := DllCall("GetForegroundWindow", "ptr")
  tid := DllCall("GetWindowThreadProcessId", "ptr", hwnd, "ptr", 0, "uint")
  me := DllCall("GetCurrentThreadId", "uint")
  DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 1)
  DllCall("ActivateKeyboardLayout", "ptr", hkl, "uint", 0)
  DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 0)
  PostMessage 0x0050, 0, hkl, , "ahk_id " hwnd
}

两组快捷键分工

RAlt + Space 是给自己按的,RWin + RAlt + Space 是给脚本发的。这样脚本就有机会先处理布局,再通知微信输入法。

KeyWait "Space"
KeyWait "RAlt"
Send "{RAlt down}{RWin down}{Space}{RWin up}{RAlt up}"

先等手上的按键松开,再完整发送另一组组合键,避免物理按键和模拟按键混在一起。

中文状态下也不能再用 #HotIf 跳过整个热键了,因为微信输入法已经不认原来的组合键。现在中英文都由脚本接管,只在需要时切换布局。

切换输入法

切换用三件套。LoadKeyboardLayout 拿到 HKL,再通过 AttachThreadInput 把自己挂到前台线程上、调 ActivateKeyboardLayout,最后补一个 WM_INPUTLANGCHANGEREQUEST:

DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 1)
DllCall("ActivateKeyboardLayout", "ptr", hkl, "uint", 0)
DllCall("AttachThreadInput", "uint", me, "uint", tid, "int", 0)
PostMessage 0x0050, 0, hkl, , "ahk_id " hwnd

ActivateKeyboardLayout 单独调只对自己的线程生效,所以必须先 AttachThreadInput。

要注意 HKL 层面分不出具体是哪个中文输入法:微软拼音、微信输入法、豆包的 langid 都是 0x0804,切过去的是上次用的那个。所以判断是否已经是中文,比较低 16 位就够了:

ChineseIMEActive() => (ForegroundHKL() & 0xFFFF) = 0x0804

切换是异步的,PostMessage 发完不代表已经生效,所以要轮询等到真的切过去。脚本最多等一秒,没切成功就直接返回,不再发送语音快捷键;成功后再留 100 毫秒缓冲。

语音结束后切回英文

既然是英文布局下触发的,说完话自然应该回到英文。现在用同一个快捷键开启和结束,就可以在第二次按键时处理切回,不用从开始录音起一直等着。

微信输入法的语音面板是一个独立的顶层窗口。每次按键先用 WinExist 看它在不在:不在就是开始,在就是结束。找窗口类名的办法是写个脚本记录一段时间内新出现的所有窗口,然后去触发一次语音:

class=wetype.flutter.setting
title=语音输入
exe=wetype_update.exe

开始时用 static restoreHKL 记住要恢复的布局,但只记英文:(prev & 0x3FF) = 0x09 判断的是主语言 ID,不限于某一种英文布局。原本就是中文则不记录,也就不会在结束后多切一次。

结束时先发快捷键,再等面板关闭:

if ending {
  Send "{RAlt down}{RWin down}{Space}{RWin up}{RAlt up}"
  if WinWaitClose(panel, , 3) {
    if restoreHKL
      ApplyHKL(restoreHKL)
    restoreHKL := 0
  }
  return
}

最多等三秒。面板还没关就不切,避免在语音还没结束时抢先换掉输入法。这个版本只在再次按 RAlt + Space 结束时恢复布局;如果用鼠标关闭面板,不会自动切回。

类名和标题是从微信输入法 2.1.4.6 抓的,升级后可能变。真变了,脚本就分不清是在开启还是结束语音,自动切回也会失效,需要重新确认窗口信息。