LEEDOM

Dec 22, 2022

使用Hammerspoon实现NVIM的输入法自动切换

背景

之前花了一段时间搭建好了Neovim的配置,并使用Neovide作为GUI进行使用,大部分情况下使用起来是非常舒服的,但是中文的切换是一件让人有点不爽的事情,很多时候,想第一时间按下代码,却发现使用的是中文输入,这个时候还需要手动的切换输入法为英文输入状态。虽然是一个小小的操作,但是在很多时候确实极致体验上的一块巨大的绊脚石。

于是上Google搜了一番是否有一些现成的方案,相信肯定不是我一个人有这种不优雅的体验。先说说具体的诉求,我想在除了插入模式之外的模式,保持英文输入状态,仅在插入模式下,可以手动切换中英文。按照这个思路搜了一圈,果然在很早之前就有人提供了一系列的解决方案,主要是两个层级,一是从输入法级别去解决,二是从VIM本身去解决。

  • 输入法上解决的思路就是,切换输入法。我目前使用的是搜狗输入法,其内置的高级功能最多支持打开某个App时,将输入法状态切换为英文输入,无法响应VIM本身的模式切换。所以搜狗输入法本身来说是无法满足这个需求,社区提供的方案是使用RIME开源输入法,对应Mac平台的实现是鼠须管,于是我第一时间就按照配置弄出来了一个版本试试,用了几个星期后,发现这个输入法想要得心应手,还需要比较多的配置和折腾,但就我目前这个需求来说,它没有很好的满足,至少在使用期间感觉不太好用。
  • VIM本身解决的思路就是使用Plugin,也确实有很多大佬写了一些Plugin去解决这个,不过我没有具体的尝试,先去Github上看了下对应仓库及issue,发现很多都没有怎么维护了,而且从issue的情况来看,似乎没有很完美的解决这个诉求(如Ctrl-c切换模式、输入法切换慢、切换到其他App后再回来输入法的状态不对之类的)。所以这个方案直接就放弃了。

正好前几天因为要解决电脑上双屏工作时,App应用切换屏幕的需求,在这个需求中了解到了一款极具效率化的工具Hammerspoon,这个工具使用Lua进行脚本编写,提供与MacOS交互的相关API。所以顿时脑子里就生成了一个想法,如果可以监听到我使用的App及相关按键,并可以控制输入法的切换,那这一套流程正好就能解决我的需求。

几个核心的点:监听键盘输入事件,监听App焦点(当前是哪个App处于获取焦点的状态–活跃),具备切换输入法的能力。顺着这个思路去搜索了下Hammerspoon的API文档和一些使用的教程,发现虽然之前没有人实现过我这个需求,但是几个核心的点都是有使用的场景,也就是说整个操作的上,是可行的。

准备工作

说下我的相关环境:

  • 系统: macOS Monterey 12.5
  • Neovim: 0.8.1
  • Hammerspoon: Version 0.9.97 (6267)
  • LuaJIT: 2.1.0-beta3
  • Neovide: 0.10.3
  • 输入法: 搜狗输入法

Hammerspoon直接从官网下载安装,然后点击顶部的图标后,点击Open Config即可打开对应的配置目录,其在磁盘的位置是~/.hammerspoon

开发

我的的配置结构如下:

1
2
3
4
5
6
7
hammerspoon/
├── init.lua
└── modules
├── hotkey.lua
├── launcher.lua
├── neovim.lua
└── windows.lua

init.lua中声明相关的Module,主要的逻辑都写在了neovim.lua文件中。

App的监听

因为不想对其他的App的使用产生影响,所以需要监听到使用Neovide时,才开启相关的逻辑,这个功能是通过hs.window订阅窗口聚焦事件来实现的:

1
2
3
4
5
6
7
8
window.filter.default:subscribe(window.filter.windowFocused, function(_, appName)
if appName == "neovide" then
...实现切换逻辑...
else
-- 将输入法切换回搜狗
change2Sougou()
end
end)

这样就能实现仅当使用Neovide时,才开始我们相关的逻辑。

输入法的切换

我们最终的目的是通过一系列的逻辑实现自动切换输入法,所以这一步很重要(要是这个实现不了,前面的逻辑都是白搭了)。Hammerspoon中也提供了API让我们可以很轻松的实现输入法的切换:hs.keycodes.currentSourceID("com.sougou.inputmethod.sogou.pinyin"),如果使用的不是搜狗,这里Google就可以搜索到查看当前系统上所装应用的ID,对应的替换这里就可以了。这样能直接切换到对应的输入法,对我而言,主要是英文和搜狗的切换,Mac自带的英文输入就可以满足,其ID是com.apple.keylayout.ABC

这里可能有一个疑问,搜狗本身就具备中英文的输入,为啥不直接切换搜狗的中文状态呢,其实要这样做,也不是不可以,不过我自己感觉,上诉的方案基本满足了我的使用;另一个原因是,Hammerspoon所交互的都是系统的API,是没法直接和App本身打交道的(无法去获取App的状态),所以如果需要知道搜狗输入法目前的输入状态是英文还是中文,就需要开机就开启一个事件监听,来监听CapsLockShift键的点击状态,以此来存储输入法的状态,这种方案我感觉不是很舒服,同时社区有人基于这个方案做的显示输入法的状态,是有一定的Bug的,就是这个监听可能会死掉,我自己也不喜欢后台有一个这样的服务一直运行着。

那么把这个方案的代码落实一下就如下了:

1
2
3
4
5
6
7
local sougouId = "com.sougou.inputmethod.sogou.pinyin"

function change2sougou()
if keycodes.currentSourceID() ~= sougouId then
keycodes.cureentSourceID(sougouId)
end
end

Tip:这里有一点需要注意,因为我除了聊天之外,其余App大部分输入都需要的是英文状态,所以我把搜狗输入法的默认状态设置为英文,这样输入体验是很丝滑衔接的。

键盘事件的监听

键盘事件需要监听的内容有两种,一是普通的输入,即a,b,c,1,2,3这些键,二是修饰键(ModifyKey),即Control、Command、Shift、Option这些键,这在Hammerspoon中是两种不同的事件监听,分别对应的是hs.eventtap.event.types.keyDownhs.eventtap.event.types.flagsChanged,这两种监听的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
eventtap.new({eventtap.event.types.keyDown}, function(e)
local type = e:getType()
if type == eventtap.event.types.keyDown then
local pressedKey = keycodes.map[e:getKeyCode()]
...实现和NVIM相关的逻辑...
end
end)

eventtap.new({eventtap.event.types.flagsChanged}, function(e)
local mods = e:getFlags()
if mods["ctrl"] then
...按下了ctrl键的逻辑...
end
end)

到这里,整个功能的大概结构就完成了。接下来就是针对VIM中的模式切换写具体的逻辑了

实现自适应的输入法切换

VIM中,进入插入模式的方式,主要有以下几种

  • 普通模式下通过i键进入插入模式;
  • 普通模式下通过a、A、I、o、O等进入插入模式;
  • 普通模式下通过r、R进行单字符和多字符替换(这种情况仍然是普通模式,但是可能需要替换的字符为中文);
  • 可视模式下通过c键进入插入模式,这里的可视模式包括了块可视(Ctrl-v进入Block Visual Mode)和行可视(Shift-v进入Line Visual Mode);

这样看,所涉及的模式主要是三个NOR、VIS、INS,做好定义

1
2
3
NOR = "Normal Mode"
VIS = "Visual Mode"
INS = "Insert Mode"

这四种方式,前三个可以归为一类,所以可以定义一个数组insertKeys = {"i", "I", "a", "A", "o", "O", "r", "R"}

整个逻辑的分支有三个:

  1. 切换到普通模式,这个时候需要将输入法设置为英文;
  2. 普通模式下切换到插入模式,将输入法设置为搜狗;
  3. 可视模式下切换到插入模式,将输入法设置为搜狗;
  4. 这一点是针对我自己的,我配置了快捷键实现在插入模式下保存并返回普通模式,所以有插入模式下通过快捷键切换到普通模式,将输入法设置为英文

结合前面监听修饰键,需要将修饰键暂存起来,用于判断是点击了那些组合键,所以用一个变量lastModify来保存比普通键先按下的修饰键。

再在普通键的监听中根据上面的思路写逻辑,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if pressKey == ECS then
-- 按下了esc键,返回普通模式,切换到abc
curMode = NOR
change2Abc()
elseif curMode == NOR then
-- 在普通模式下,键入insertKeys的键都会进入插入模式,将输入法切换到搜狗
if contains(insertKeys, pressKey) then
curMode = INS
change2Sougou()
else
-- 判断几种可视模式,如果满足,代表切换到了可视模式中,后面的键入会走可视模式的分支
local lineVis = lastModify == S and pressKey == "v"
local blockVis = lastModify == C and pressKey == "v"
local standardVis = pressKey == "v"
if standardVis or lineVis or blockVis then
curMode = VIS
-- 每一次判断了修饰键,都需要重置
lastModify = ""
end
-- 因为还是非插入模式,此时还是需要切换到abc
change2Abc()
end
elseif curMode == INS then
-- 自定义按键command+s保存并返回普通模式
if pressKey == "s" and lastModify == M then
curMode = NOR
change2Abc()
else
lastModify = ""
end
elseif curMode == VIS then
-- 可视模式下,进入插入模式
if pressKey == "c" then
curMode = INS
change2Sougou()
end
end

到这里,整个功能基本算完成了。最后再收一下尾巴,想想前面Plugin遇到的问题,如果我中途切换到其他App,切换了输入法,那回到Neovide中,状态不就不对了么,比如我处于普通模式,应该是abc,切换到其他App修改为搜狗中文输入后,在Neovide还是至少需要按一下Shift来切换。所以回到最开始监听App切换那儿,做一个状态的判断:

1
2
3
if curMode ~= INS then
change2Abc()
end

非输入模式下,就切换abc。这样当你再回到Neovide时,就可以丝滑的进行操作,而不用担心当前输入法的状态了。

OLDER >