借evdev之力 Linux全局热键魔改造

霓虹语标题我都想好了。evdevの力を貸して、Linuxでホットキーの魔改造

Linux用户就像Minecraft玩家,虽然大家玩的都是Minecraft,但是,卧槽,我们一定是在玩不同的游戏(见到建筑师的MC作品时来自小白的惊叹)。要让自己的Linux别人不会用,别人的Linux自己不会用,最重要的当然是要把快捷键改得惊天地泣鬼神。

作为一个Vim瘾君子,我的需求就是手尽量不要离开主键盘区。对,方向键我都不想按。于是我想通过一些组合键去实现上下左右。有人认为CapsLock按起来方便,我自己比较喜欢按Alt,因为就在空格键旁边,触手可及。总的来说,我希望Alt+[H|J|K|L]分别变成左下上右,Alt+0变成Home, Alt+4变成End。

我自己试用过很多修改键位或者添加热键的工具,包括著名的AutoKey。很可惜,它们大都不好用。比如说,我采取这样的操作序列Alt Down, K Down, K Up, Alt Up,这些软件大多会采取在检测到K Down的时候,同时发出Alt Up, ↑ Down,以便撤销掉先前一个Alt Down的作用,然后发出键按下的事件。这里有一个问题,就是很多GUI在Alt Down, Alt Up之后,会唤出菜单,从而失焦于输入框。

另外一方面就是,真的不是哪里都能用。至少,你不可能拿它去玩赛车游戏。你也不能在TTY中继续使用这些热键。此外还有许许多多的地方不能使用。热键是一种会上瘾的东西,在它失效的时候,你就会有戒断综合征。想摔键盘。

evdev和uinput

我试过从GNOME和X11入手,貌似没有什么好用的方案。XGrabKeyboard一定程度上可以做到接管的效果,但是要指定窗口,似乎还会让窗口失焦。总而言之稍微有点太绕。不过Linux最好的一点,就是它很裸露。如果从高于驱动低于X11的层入手,兴许会有比较好的效果。

evdev内核中通用的输入设备驱动,它为设备提供了/dev/input下字符设备接口。它非常底层,内核在进行中断处理后,第一时间就将输入数据交由它处理。但是它一点都不反直觉,甚至还提供了很好用的工具libevdev,可以直接用Python处理消息。不少人改游戏手柄都是通过evdev进行的。

uinput是一个特殊的虚拟设备,它允许你直接在用户态向内核插入输入事件 —— 一般而言就是直接向/dev/uinput写数据,不过当然,要服从libevdev提供的接口/数据结构。这些事件之后会在另一个evdev字符设备里,假装是物理设备的输入,被X的libinput取出来或者由TTY转为stdin。

Quick Start

这种实验性的东西就不用C写了。evdev提供了十分好用的Python Bindings,我们可以直接在Python里写我们的快捷键配置。我们先用pip install evdev安装它,然后在用它提供的示例脚本来测试一下evdev输入的究竟是什么东西:sudo python -m evdev.evtest /dev/input/by-path/platform-i8042-serio-0-event-kbd(这里我键盘的路径是i8042键盘控制器上的,你需要根据你电脑上的配置来调整)

time 1504189579.19    type 4 (EV_MSC), code 4    (MSC_SCAN), value 21
time 1504189579.19    type 1 (EV_KEY), code 21   (KEY_Y), value 1
time 1504189579.19    --------- SYN_REPORT --------
time 1504189579.28    type 4 (EV_MSC), code 4    (MSC_SCAN), value 21
time 1504189579.28    type 1 (EV_KEY), code 21   (KEY_Y), value 0
time 1504189579.28    --------- SYN_REPORT --------
time 1504189579.29    type 4 (EV_MSC), code 4    (MSC_SCAN), value 18
time 1504189579.29    type 1 (EV_KEY), code 18   (KEY_E), value 1
time 1504189579.29    --------- SYN_REPORT --------
time 1504189579.4     type 4 (EV_MSC), code 4    (MSC_SCAN), value 18
time 1504189579.4     type 1 (EV_KEY), code 18   (KEY_E), value 0
time 1504189579.4     --------- SYN_REPORT --------
time 1504189579.48    type 4 (EV_MSC), code 4    (MSC_SCAN), value 31
time 1504189579.48    type 1 (EV_KEY), code 31   (KEY_S), value 1
time 1504189579.48    --------- SYN_REPORT --------
time 1504189579.64    type 4 (EV_MSC), code 4    (MSC_SCAN), value 31
time 1504189579.64    type 1 (EV_KEY), code 31   (KEY_S), value 0
time 1504189579.64    --------- SYN_REPORT --------

这里我按了yes三个键,可以看到,每一个动作(按下或释放,表现在EV_KEY的value的1或0上),都会产生三个事件,分别是EV_MSCEV_KEYEV_SYN。根据 https://lp007819.wordpress.com/2013/02/12/再谈linux-input子系统/的说法,事实上是有四个消息的发出,但是第一个通常不被支持(隐身了),第二个MSC_SCAN通常会被应用程序忽略,第三个EV_KEY才是真正会被接收的,第四个是同步,可以看到就是用来产生萌萌的分界线的(笑)

有了这一层认识我们就知道,三个事件合起来,才是一次真正的输入。

开始动工

就我的需求而言,我脑子里第一个浮现的抽象机制就是状态机,不知为何。一个比较合适的设计是两层的自动机,第一层用来为这三个一组的事件分组,分好组之后成为第二层状态机的输入,一个二元组(key-scan-code, up/down/hold)。第二层状态机我花了好些时间去构思,结果大概是这样的:

State Input Pattern Transition Action
Normal (Left Alt, Down) Alt -
Normal ELSE Normal inject
Alt (J/K/H/L/0/4, *) Mapped mapped
Alt (Left Alt, Up) Normal inject_alt_down, inject_alt_up
Alt ELSE Inject inject_alt_down, inject
Inject (J/K/H/L/0/4, *) Mapped inject_alt_up, mapped
Inject (Left Alt, Up) Normal inject_alt_up
Inject ELSE Inject inject
Mapped (J/K/H/L/0/4, *) Mapped mapped
Mapped (Left Alt, Up) Normal -
Mapped ELSE Inject inject_alt_down, inject

是不是头都晕了。把它画出来或许会比较清楚,不过我也没这个闲心拿绘图软件再画一遍了。有四个状态,分别是:

  • 正常状态 (Normal),除了Alt以外所有键都直接发射到uinput里

  • 刚按了下Alt键 (Alt),现在还不能确定Alt键会否形成组合键

  • 插入状态 (Inject),不是我们想要的热键,连刚才的Alt一起发射到uinput里

  • 映射状态 (Mapped),是热键,把映射过之后的键发射出去,比如按下了K就发射Up

Inject和Mapped状态之间转换时,还要把一些Alt键的动作补充一下,以防误导其它应用程序。

关于evdev本身的使用上,evdev的文档已经说得非常详尽。在这个脚本里,仅仅用到了少量的功能,比如从/dev/input里读事件,我是block read,但是你也可以用select或者epoll去异步完成这些操作。:

# 留意!需要Root权限!
dev = evdev.InputDevice('/dev/input/by-path/platform-i8042-serio-0-event-kbd')

for event in dev.read_loop():
    kev = evdev.categorize(event)
    ks.input(kev) # 第一层状态机
    process(ks) # 第二层状态机,以第一层状态的结果为输入

Inject这样的操作就是往uinput里面一次过写三个事件(含同步):

ui = evdev.UInput()

def __inject(keycode, keystate):
    global ui
 
    ui.write(ecodes.EV_MSC, ecodes.MSC_SCAN, keycode)
    ui.write(ecodes.EV_KEY, keycode, keystate)
    ui.syn()

当然,最重要的一点是怎样做到独占读,也就是托管整个设备的事件处理不让它侧漏呢?我们为它设置GRAB。Grab了之后,整个系统只有当前进程读得到键盘的输入。如果这个设备已经被别人Grab了,这个操作就会失败。

dev.grab()
dev.ungrab()

整份代码已经上传到 https://github.com/Shihira/la...,欢迎斧正。

参考资料

阅读 2.9k

推荐阅读
Shihira
用户专栏

桜、雪、電車

12 人关注
18 篇文章
专栏主页