在 Linux 上配置 SpaceFn 键盘布局

SpaceFn 的介绍

SpaceFn 是 spiceBar 2013年在 The SpaceFN layout: trying to end keyboard inflation 中提出来的一种针对 60% 键盘的改良布局方案,主要的特点是将空格作为 Fn 键使用。

SpaceFn base layer SpaceFn fn layer

在这个布局中,空格键具有短按空格和长按 Fn 两种状态。作为键盘中最大的按键,平常打字的过程中,左右手的拇指都是直接放在空格上的,使用空格作为 Fn 键,那么切换键层的时候手指完全不需要额外移动就可以完成。

配置方案

Linux 下的键盘映射通常使用 Xmodmap 和 Xcape 来配置,但是两者都不能将普通按键的长按状态修改为 Fn,只能另寻他法。目前来说最为可行的有两种方案:

第一种是修改 Xorg 驱动源码,重新编译后替换当前系统的驱动。虽然作者在文章中已经详细说明实现原理、完整的编译和替换步骤,并且还提供了自动编译的 Dockerfile,但是仍然不推荐非专业人员采用这种方案,因为:

第二种实现方案更加柔和,通过 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 查看修改的代码。

参考资料