macOS中英文输入法自动切换方案
5 minute read
0x1 vim中英文自动切换
vim使用fcitx-vim-osx插件,使用中fcitx-vim-osx要求添加ctrl+shift+z为切换英文|百度五笔输入法快捷键

0x2 系统输入法设置
- 使用百度五笔输入法并设置百度五笔输入法中只能使用中文输入(“删除”百度五笔的英文输入方式),在将百度五笔切换成中文输入后如下设置(PS:使用系统自带的简体五笔也可以,但需要保留系统中只有2种输入法,一个是英文输入法,一个是五笔输入法)

- 使用hammerspoon实现按一下left command切换输入法.此外,该hammerspoon脚本还解决了以下问题:
- iTerm2和chrome等少数APP在切换到中文输入法后有时会依然输出英文(原因应该是由于这些APP自己维护了一个输入法状态导致不认系统的切换输入法指令),需要切换到其他APP后再切换回iTerm2才可以正常输出中文
- 在使用synergy时按一下windows系统中的left win键无法在mac中切换输入法
- 当app切换到iTerm2时自动将输入法切换为英文(在hammerspoon中实现,这个不能由keyboard maestro实现,会出问题)
-- ==============================================================
-- 放在 Mac 上:~/.hammerspoon/init.lua
--
-- 单按 Command → 切换 macOS 输入法。
-- 物理键盘和 Synergy 转发的 Win 键都由本脚本处理
-- (Karabiner 里原来那条 left_command → ctrl+shift+z 已经删掉,
-- 现在只有一个地方管这件事,不再有双触发和配置分散的问题)。
--
-- 中文输入源怎么来的:
-- Hammerspoon 1.0+ 是 Lua 5.4,没有 LuaJIT 的 ffi,枚举不了 TIS;
-- defaults 那张 plist 又只登记 Apple 自家的输入法,看不到第三方输入法。
-- 所以:发 Ctrl+Shift+Z 让系统轮转输入源,转到中文就记下它的 ID,
-- 然后恢复原状。全程自动,不做缓存(删掉的输入法 ID 依然"长得合法",
-- 缓存下来只会指着一个已经不存在的东西)。
--
-- 为什么要补焦点抖动:
-- iTerm2 / Chrome 这类自己实现输入栈的 App,不会在输入源变更时
-- 重建输入上下文,只在窗口重新成为 key window 时才重读。
-- 表现:菜单栏显示中文,打出来还是英文。
-- 办法:借 Hammerspoon(无窗口的后台程序)顶一下焦点,再切回来。
--
-- 「切到某些 App 自动回英文」也在本脚本里做(AUTO_EN_APPS),
-- 不用 Keyboard Maestro——这样抖动期间可以临时屏蔽,两者不会打架。
-- ==============================================================
local T = hs.eventtap.event.types
local EN = "com.apple.keylayout.US"
-- ---------- 中文输入源:自动识别 ----------
-- 只用来判断「轮转到的这个是不是中文输入法」,
-- 免得把越南语、日文之类误认成中文。
local CN_PATTERNS = {
"^com%.apple%.inputmethod%.SCIM", -- 简体:拼音 / 五笔 / 手写
"^com%.apple%.inputmethod%.TCIM", -- 繁体:仓颉 / 速成 / 五笔 / 拼音
"^com%.baidu%.inputmethod", -- 百度
"^com%.sogou%.inputmethod", -- 搜狗
"^com%.tencent%.inputmethod", -- 腾讯
"^com%.iflytek%.inputmethod", -- 讯飞
}
local function isChinese(id)
if not id or id == EN then return false end
if id:find("Handwriting", 1, true) then return false end
for _, p in ipairs(CN_PATTERNS) do
if id:match(p) then return true end
end
return false
end
local ZH -- 当前认定的中文输入源,识别完成后才有值
local discovering = false
-- 发一次「选择上一个输入源」。必须走 System Events:
-- hs.eventtap.keyStroke 用的是私有事件源,系统符号快捷键不认。
local function pressSwitch()
hs.osascript.applescript(
[[tell application "System Events" to key code 6 using {control down, shift down}]])
end
-- 异步轮转输入源,转到中文就记下来并恢复原状。
-- 必须异步:applescript 是同步阻塞的,阻塞期间事件循环不动,
-- 输入源不会真的切过去。
local function discoverZH(done)
if discovering then return end
discovering = true
local start = hs.keycodes.currentSourceID()
if isChinese(start) then
discovering = false
print("[IME] 中文输入源 = " .. start)
done(start)
return
end
local seen = { [start] = true }
local tries = 0
local function finish(id)
discovering = false
if id then
print("[IME] 中文输入源 = " .. id .. "(轮转识别)")
else
print("[IME] 自动识别失败:轮转完所有输入源都没找到中文输入法")
end
done(id)
end
local function step()
tries = tries + 1
if tries > 8 then
hs.keycodes.currentSourceID(start)
finish(nil)
return
end
pressSwitch()
-- 存到全局,避免定时器被 GC 掉导致回调不执行
hs.imeDiscoverTimer = hs.timer.doAfter(0.12, function()
local id = hs.keycodes.currentSourceID()
if isChinese(id) then
if id ~= start then hs.keycodes.currentSourceID(start) end
finish(id)
return
end
if seen[id] then -- 转回起点了,说明确实没有
hs.keycodes.currentSourceID(start)
finish(nil)
return
end
seen[id] = true
step()
end)
end
step()
end
-- ---------- 切到某些 App 时自动回英文 ----------
-- 原来这条规则在 Keyboard Maestro 里(「iTerm2 激活 → 切英文」)。
-- 搬过来的原因:焦点抖动会让 iTerm2 重新激活,KM 那条规则会跟着触发,
-- 把刚切好的中文又顶回英文。放在同一个进程里才能协调。
local AUTO_EN_APPS = {
["com.googlecode.iterm2"] = true,
}
-- 抖动进行中,屏蔽上面的自动切英文
local dancing = false
-- ---------- 切完之后补一次焦点抖动 ----------
-- 借道的 App 必须是无窗口的后台程序(agent):它激活时只产生
-- 「目标 App 失去 / 重获 active」的通知对,画面上什么都不变。
-- 换成 Finder 这种正常 App 会真的抢焦点,画面会闪。
local NEED_REFRESH = {
["com.googlecode.iterm2"] = true, -- iTerm2
-- ["com.google.Chrome"] = true, -- Chrome
-- ["com.microsoft.VSCode"] = true, -- VS Code
-- ["com.tinyspeck.slackmacgap"] = true, -- Slack
-- ["com.hnc.Discord"] = true, -- Discord
}
local REFRESH_VIA = "Hammerspoon"
local REFRESH_DELAY = 0.02 -- 顶出去多久再让回来(只要够 Hammerspoon 真的拿到前台)
local function refreshFrontApp()
local app = hs.application.frontmostApplication()
if not app then return end
local bid = app:bundleID()
if not bid or not NEED_REFRESH[bid] then return end
local pivot = hs.application.get(REFRESH_VIA)
if not pivot or pivot:bundleID() == bid then return end
dancing = true -- 先屏蔽自动切英文,再动焦点
pivot:activate()
hs.imeRefreshTimer = hs.timer.doAfter(REFRESH_DELAY, function()
local back = hs.application.get(bid)
if back then back:activate() end
-- 抖动结束后再解除屏蔽,避免自己的激活触发自动切英文。
-- 只要覆盖住 watcher 收到 activated 的那几毫秒就够,不必留长。
hs.imeRefreshTimer2 = hs.timer.doAfter(0.05, function()
dancing = false
end)
end)
end
-- ---------- 单按 Command 判定 ----------
-- 物理键盘(kc=55)和 Synergy(kc=0)现在都由本脚本切换。
-- 注意:切换权在这里,误判的代价就是"乱切输入法",所以判定要比
-- 以前更严——松开时如果还按着别的修饰键,就当组合键处理。
local CMD_PRESS_KC = { [0] = true } -- Synergy 的按下
local LEFT_CMD_KC = 55 -- 物理键盘左 Command
local RIGHT_CMD_KC = 54 -- 物理键盘右 Command(被挪作他用)
local CMD_RELEASE_KC = { [0] = true, [54] = true, [55] = true }
local pressed = false
local solo = false -- 按下期间有没有夹带其它键
local physicalUntil = 0
-- 切换延迟。0 = 收到按键立刻切。
-- 之前为了绕开「切太快 App 漏通知」用过 0.2,但那样手感明显变钝;
-- 现在改用焦点抖动来刷新上下文,延迟可以回到 0。
local SWITCH_DELAY = 0
local function armPress()
pressed, solo = true, true
-- 兜底:万一收不到「松开」事件,3 秒后自动复位
hs.timer.doAfter(3, function()
if pressed then pressed, solo = false, false end
end)
end
local function fire()
hs.timer.doAfter(SWITCH_DELAY, function()
if not ZH then return end -- 还在识别,这次先不管
local cur = hs.keycodes.currentSourceID()
local target = (cur == ZH) and EN or ZH
if hs.keycodes.currentSourceID(target) == false then
-- 这个 ID 已经不可用了(换了输入法、或者卸载了)。重新识别,
-- 识别完会自动切到新的中文输入法,用户不用管。
print("[IME] 输入源已失效,正在重新识别…")
ZH = nil
discoverZH(function(id)
ZH = id
if id and hs.keycodes.currentSourceID() ~= id then
hs.keycodes.currentSourceID(id)
end
end)
return
end
refreshFrontApp()
end)
end
local function release()
local wasSolo = solo
pressed, solo = false, false
if wasSolo then fire() end -- fire() 末尾会自己调 refreshFrontApp()
end
local function handle(e)
local t = e:getType()
local kc = e:getKeyCode()
local f = e:getFlags()
if t == T.flagsChanged then
if f.cmd then
if kc == LEFT_CMD_KC then
-- 物理键盘左 Command:本脚本负责切换。
-- 记时间窗,挡掉随后可能跟着来的 kc=0 余波,否则会被当成第二次按下。
physicalUntil = hs.timer.secondsSinceEpoch() + 0.3
if not pressed then armPress() end
return false
end
if kc == RIGHT_CMD_KC then
-- 右 Command 被 Karabiner 挪作他用,不归我们管。
-- 但仍然记时间窗,免得它的余波 kc=0 被当成 Synergy。
physicalUntil = hs.timer.secondsSinceEpoch() + 0.3
return false
end
if CMD_PRESS_KC[kc] and not pressed then
if hs.timer.secondsSinceEpoch() < physicalUntil then return false end
armPress()
end
else
if pressed then
-- 松开 Command 时还按着 Shift / Option / Ctrl,说明是组合键,不是单按
if f.shift or f.alt or f.ctrl then solo = false end
release()
end
end
return false
end
if t == T.keyUp then
if pressed and CMD_RELEASE_KC[kc] then release() end
return false
end
if t == T.keyDown then
-- 按下任何「真正的」按键,说明不是单按 Command
if not CMD_RELEASE_KC[kc] and pressed then
solo = false
end
return false
end
return false
end
-- 挂到全局,避免被 Lua 垃圾回收(写成 local 会在几秒后失效)
hs.imeTap = hs.eventtap.new({T.flagsChanged, T.keyDown, T.keyUp}, handle)
hs.imeTap:start()
-- 切到 AUTO_EN_APPS 里的 App 时自动回英文
hs.imeAppWatcher = hs.application.watcher.new(function(_, event, app)
if event ~= hs.application.watcher.activated then return end
if dancing then return end
local bid = app and app:bundleID()
if bid and AUTO_EN_APPS[bid] then
hs.keycodes.currentSourceID(EN)
end
end)
hs.imeAppWatcher:start()
-- ---------- 启动时识别中文输入源 ----------
discoverZH(function(id) ZH = id end)
- GhostSKB设置常用APP的初始输入法,如下:

- keyboard maestro设置常用APP被切换回到使用状态时的输入法,如下(注意,如果用了上面的GhostSKB就不用在Keyboard maestro中如下设置,两个都用了甚至可能会冲突出bug,同样,用了如下设置Keyboard maestro就不用再用上面的GhostSKB)(另外还需注意的是,在keyboard maestro的auto english中不要将iTerm2加入其中,iTerm2的激活时自动切换为english已经由hammerspoon实现且必须由hammerspoon实现,否则在iTerm2中由英文切换为中文时会变成英文到中文到英文,因为iTerm2切换输入法会产生一个iTerm2的激活事件):


- 由于上面将chrome的默认输入法设置为英文输入,导致在经常使用的google搜索中文关键字时需要切换一次,可用keyboard maestro如下解决:

0x3 效果
- 系统只保留两种输入法:系统自带英文输入法/百度五笔的中文输入方式
- “删除”百度五笔的英文输入方式后,可在系统中cmd一键切换中英文输入法
- 常用应用启动和切换到使用中时都会使用默认输入法