在 Linux 上配置 SpaceFn 键盘布局
SpaceFn 的介绍
SpaceFn 是 spiceBar 2013年在 The SpaceFN layout: trying to end keyboard inflation 中提出来的一种针对 60% 键盘的改良布局方案,主要的特点是将空格作为 Fn 键使用。
在这个布局中,空格键具有短按空格和长按 Fn 两种状态。作为键盘中最大的按键,平常打字的过程中,左右手的拇指都是直接放在空格上的,使用空格作为 Fn 键,那么切换键层的时候手指完全不需要额外移动就可以完成。
配置方案
Linux 下的键盘映射通常使用 Xmodmap 和 Xcape 来配置,但是两者都不能将普通按键的长按状态修改为 Fn,只能另寻他法。目前来说最为可行的有两种方案:
第一种是修改 Xorg 驱动源码,重新编译后替换当前系统的驱动。虽然作者在文章中已经详细说明实现原理、完整的编译和替换步骤,并且还提供了自动编译的 Dockerfile,但是仍然不推荐非专业人员采用这种方案,因为:
- 需要系统侵入,误操作会导致键盘无法响应等系统问题
- 适用性差,系统升级或者更改其他系统后需要重复编译和替换,并存在版本兼容问题
第二种实现方案更加柔和,通过 libevdev 库实现对键盘输入的拦截和修改。由于程序可以自由的开启和关闭,不仅可以避免侵入导致的系统问题,还可以实现接入键盘自动配置,免去手动操作的麻烦。因此在本文中只讨论该方案的配置方法。
编译及运行依赖说明
- 系统需支持 uinput,且需具备读写权限
- 具备
/dev/input/eventXX
等字符设备接口的读权限 - 安装 spacefn-evdev 必须的编译和运行时依赖: gcc、make、libevdev-dev 以及 libevdev
简单来说就是需要 root 权限啦,但每次都 sudo 非常不方便,推荐采用如下方式:
$ sudo groupadd uinput # 创建 uinput 用户组
$ sudo chgrp uinput /dev/uinput # 修改 /dev/uinput 设备的用户组
$ sudo chmod g+rw /dev/uinput # 增加 uinput 组的读写权限
$ sudo usermod -a -G uinput your-username # 将自己加入 uinput 用户组
$ sudo usermod -a -G input your-username # 将自己加入 input 用户组
编译
spacefn-evdev 的编译非常简单:
$ git clone https://github.com/abrasive/spacefn-evdev.git
$ cd spacefn-evdev
$ make
编译完成后可以在当前目录下找到 spacefn 可执行文件,设置一下执行权限:
$ chmod +x spacefn
运行
直接运行 spacefn 会看到使用说明:
$ ./spacefn
usage: ./spacefn /dev/input/...
spacefn 需要目标键盘的设备路径作为参数。键盘设备通常可以在 /dev/input/
目录下找到,以 Thinkpad X1 的默认键盘作为运行示例:
$ ls /dev/input/by-path/ | grep kbd
platform-i8042-serio-0-event-kbd
$ ./spacefn /dev/input/by-path/platform-i8042-serio-0-event-kbd
运行后就可以将空格键当作 fn 键,停用时直接关闭程序就可以了。
自定义按键映射
spacefn-evdev 作者在代码中只定义了几个简单按键映射作为示例,可以在源码 key_map
函数中看到:
// Key mapping {{{1
unsigned int key_map(unsigned int code) {
switch (code) {
case KEY_BRIGHTNESSDOWN: // my magical escape button,自定义退出键
exit(0);
case KEY_J: // j 映射为左键
return KEY_LEFT;
case KEY_K: // k 映射为下键
return KEY_DOWN;
case KEY_L: // l 映射为上键
return KEY_UP;
case KEY_SEMICOLON: // ; 映射为右键
return KEY_RIGHT;
case KEY_M: // m 映射为 HOME 键
return KEY_HOME;
case KEY_COMMA: // , 映射为 PAGEDOWN 键
return KEY_PAGEDOWN;
case KEY_DOT: // . 映射为 PAGEUP 键
return KEY_PAGEUP;
case KEY_SLASH: // / 映射为 END 键
return KEY_END;
case KEY_B: // b 映射为空格键
return KEY_SPACE;
}
return 0;
}
所有的映射表都硬编码在代码里,因此每次修改后都需要重新编译才能生效。自定义按键时如果需要查找对应键的常量名,可以参考 /usr/include/linux/input-event-codes.h。
外接键盘
如果只有一个键盘可以直接将命令以 Daemon 的形式运行,多个键盘时就会比较麻烦,每次接入键盘都必须手动运行。如果再遇到 USB 接触不良的键盘可能会有砸键盘的冲动,深有体会。
通过 udev 脚本和 systemd 还可以实现插入键盘自动启用 spacefn,移除键盘时关闭程序的效果,免去手动运行的烦恼。
这里以 Poker 升级版键盘为例,先编写 udev 脚本处理插入键盘时需要的操作:
$ cat /etc/udev/rules.d/99-spacefn-keyboard-mapping.rules
ACTION=="bind", SUBSYSTEM=="usb", ENV{ID_SERIAL}=="*Poker*", SYMLINK+="poker"
ACTION=="bind", SUBSYSTEM=="usb", ENV{ID_SERIAL}=="*Poker*", RUN+="/bin/systemctl --no-block start spacefn-poker.service"
我在键盘插入时为键盘创建一个名为 /dev/poker
的设备链接,方便后续使用。第二行脚本会在 Poker 键盘接入后自动启动名为 spacefn-poker
的 systemd 服务。
spacefn-poker.service 服务:
$ cat /etc/systemd/system/spacefn-poker.service
[Unit]
Description=Poker Keyboard spacefn service
BindsTo=dev-poker.device
[Service]
Type=simple
ExecStart=/usr/local/bin/spacefn-poker
Unit
中的 BindsTo 参数将服务和 dev-poker.device 绑定,对应我们在 udev 脚本中生成的 /dev/poker
。键盘被移除时系统会自动移除 /dev/poker
,systemd 也会自动关闭服务。
服务运行时执行的真正命令:
$ cat /usr/local/bin/spacefn-poker
#!/bin/bash
POKER_KBD="/dev/input/by-id/usb-Heng_Yu_Technology_Poker-event-kbd"
/usr/local/bin/spacefn $POKER_KBD > /dev/null 2>&1
所有脚本准备完毕后还需重新载入 udev 和 systemd 配置才会生效:
$ sudo udevadm control -R
$ sudo systemctl daemon-reload
其他
如果想在系统重启后保持 uinput
的读写权限,可以将 uinput
添加到 /etc/modules.d/
中,并且创建对应的 udev 规则:
$ cat /etc/modules.d/uinput.conf
uinput
$ cat /etc/udev/rules.d/40-uinput.rules
SUBSYSTEM=="misc", KERNEL=="uinput", MODE="0660", GROUP="uinput"
直接在终端中运行 spacefn-evdev 可能会出现回车键被长按,终端不停向下滚动的现象。因为当你按下回车键时 shell 就已经开始执行拦截程序,会导致终端无法接收到释放回车键的事件,这时只要重按回车就可以恢复正常。
另外可以在拦截键盘事件之前先等待一段时间,待终端接收到释放回车键事件之后再执行:
static void wait_for_enter_release(void) {
struct timeval timeout;
timeout.tv_sec = 0;
timeout.tv_usec = 200000;
select(0, NULL, NULL, NULL, &timeout);
}
int main(int argc, char **argv) { // {{{1
// ......
err = libevdev_uinput_create_from_device(idev, uifd, &odev);
if (err) {
fprintf(stderr, "Failed: (%d) %s\n", -err, strerror(err));
return 1;
}
// 等待释放
wait_for_enter_release();
err = libevdev_grab(idev, LIBEVDEV_GRAB);
if (err) {
fprintf(stderr, "Failed: (%d) %s\n", -err, strerror(err));
return 1;
}
run_state_machine();
}
或者更粗暴一些:
#include <unistd.h>
int main(int argc, char **argv) { // {{{1
// ......
err = libevdev_uinput_create_from_device(idev, uifd, &odev);
if (err) {
fprintf(stderr, "Failed: (%d) %s\n", -err, strerror(err));
return 1;
}
// sleep
usleep(200000);
err = libevdev_grab(idev, LIBEVDEV_GRAB);
if (err) {
fprintf(stderr, "Failed: (%d) %s\n", -err, strerror(err));
return 1;
}
run_state_machine();
}
更或是直接在 libevdev_grab 之前手动向 fd 写入一个回车键释放事件也是可以的。
编写 udev 脚本的一些基本调试方法:
# 开启 udevadm 调试日志
$ sudo udevadm control --log-priority=debug
# 查看编写 udev 脚本时可以用到的键盘信息
$ sudo udevadm info /dev/input/by-path/platform-i8042-serio-0-event-kbd
P: /devices/platform/i8042/serio0/input/input3/event3
N: input/event3
S: input/by-path/platform-i8042-serio-0-event-kbd
E: BACKSPACE=guess
E: DEVLINKS=/dev/input/by-path/platform-i8042-serio-0-event-kbd
E: DEVNAME=/dev/input/event3
E: DEVPATH=/devices/platform/i8042/serio0/input/input3/event3
E: ID_BUS=i8042
E: ID_INPUT=1
E: ID_INPUT_KEY=1
E: ID_INPUT_KEYBOARD=1
E: ID_PATH=platform-i8042-serio-0
E: ID_PATH_TAG=platform-i8042-serio-0
E: ID_SERIAL=noserial
E: LIBINPUT_ATTR_KEYBOARD_INTEGRATION=internal
E: LIBINPUT_DEVICE_GROUP=11/1/1:isa0060/serio0
E: MAJOR=13
E: MINOR=67
E: SUBSYSTEM=input
E: TAGS=:power-switch:
E: USEC_INITIALIZED=3545024
E: XKBLAYOUT=cn
E: XKBMODEL=pc105
# 查看在键盘接入时的 udev 脚本中指定的命令是否成功运行
$ tail -f /var/log/syslog |grep RUN
配置文件
最近混用 60% 和 80% 键盘遇到了按键映射冲突的问题,一套映射无法满足不同键盘。另外在开发时发现 KDE 的虚拟桌面很好用,希望将 KEY_SPACE + KEY_1
映射到 KEY_LEFTCTRL + KEY_F1
这样的组合来快速切换桌面。
于是 Folk 了一份代码自己修改了一下,增加了简单的配置文件,大致格式如下:
keyboard = "/dev/input/by-path/platform-i8042-serio-0-event-kbd"
keys_map = (
[2, 59, 29], # KEY_1 KEY_F1 KEY_LEFTCTRL
[3, 60, 29], # KEY_2 KEY_F2 KEY_LEFTCTRL
[4, 61, 0], # KEY_3 KEY_F3
[5, 62, 0], # KEY_4 KEY_F4
[6, 63, 0], # KEY_5 KEY_F5
[7, 64, 0], # KEY_6 KEY_F6
[8, 65, 0], # KEY_7 KEY_F7
[9, 66, 0], # KEY_8 KEY_F8
[10, 67, 0], # KEY_9 KEY_F9
[11, 68, 0], # KEY_0 KEY_F10
[12, 87, 0], # KEY_MINUS KEY_F11
[13, 88, 0], # KEY_EQUAL KEY_F12
[1, 41, 42], # KEY_ESC KEY_GRAVE KEY_LEFTSHIFT
[35, 105, 0], # KEY_H KEY_LEFT
[36, 108, 0], # KEY_J KEY_DOWN
[37, 103, 0], # KEY_K KEY_UP
[38, 106, 0], # KEY_L KEY_RIGHT
[48, 57, 0], # KEY_B KEY_SPACE
[50, 102, 0] # KEY_M KEY_HOME
);
配置文件只有两个参数,一个键盘设备路径,一个是按键的映射。按键的映射新增了一个拓展键,可以实现前面提到的组合映射。有兴趣的可以前往 spacefn-evdev 查看修改的代码。